From 537b641eadbe28efcbd2e8376b91b896e8e9b873 Mon Sep 17 00:00:00 2001 From: lwih Date: Tue, 2 Apr 2024 13:52:22 +0200 Subject: [PATCH] feat(components): introduce Banner component --- e2e/base/components/Banner.spec.tsx | 69 ++++++++++ .../Banner/__tests__/getBannerPalette.test.ts | 29 +++++ src/components/Banner/index.tsx | 122 ++++++++++++++++++ src/components/Banner/utils.ts | 25 ++++ src/index.ts | 2 + stories/components/Banner.stories.tsx | 53 ++++++++ 6 files changed, 300 insertions(+) create mode 100644 e2e/base/components/Banner.spec.tsx create mode 100644 src/components/Banner/__tests__/getBannerPalette.test.ts create mode 100644 src/components/Banner/index.tsx create mode 100644 src/components/Banner/utils.ts create mode 100644 stories/components/Banner.stories.tsx diff --git a/e2e/base/components/Banner.spec.tsx b/e2e/base/components/Banner.spec.tsx new file mode 100644 index 000000000..37211b598 --- /dev/null +++ b/e2e/base/components/Banner.spec.tsx @@ -0,0 +1,69 @@ +import { StoryBox } from '../../../.storybook/components/StoryBox' +import { Level } from '../../../src' +import { _Banner as BannerStory } from '../../../stories/components/Banner.stories' +import { mountAndWait } from '../utils' + +context(`Story`, () => { + describe('default visibility', () => { + it('should not show the banner when isHiddenByDefault is true', () => { + mountAndWait( + + + some text + + + ) + cy.get('.banner').should('not.be.visible') + }) + it('should show the banner when isHiddenByDefault is false', () => { + mountAndWait( + + + some text + + + ) + cy.get('.banner').should('be.visible') + }) + it('should show the banner when isHiddenByDefault is undefined', () => { + mountAndWait( + + + some text + + + ) + cy.get('.banner').should('be.visible') + }) + }) + describe('closable version', () => { + it('should disappear when the action button is clicked', () => { + mountAndWait( + + + some text + + + ) + cy.get('.banner-button').click() + cy.get('.banner').should('not.be.visible') + }) + }) + describe('collapsible version', () => { + it('should collapse when the action button is clicked', () => { + mountAndWait( + + + some text + + + ) + // check it's fully opened + cy.get('.banner').invoke('outerHeight').should('be.gt', 10) + // click on the hide button + cy.get('.banner-button').click() + // check it's shrunk + cy.get('.banner').invoke('outerHeight').should('be.equal', 10) + }) + }) +}) diff --git a/src/components/Banner/__tests__/getBannerPalette.test.ts b/src/components/Banner/__tests__/getBannerPalette.test.ts new file mode 100644 index 000000000..c2dafb130 --- /dev/null +++ b/src/components/Banner/__tests__/getBannerPalette.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from '@jest/globals' + +import { Level } from '../../../constants' +import { THEME } from '../../../theme' +import { getBannerPalette } from '../utils' + +describe('getBannerPalette', () => { + test('should return a red palette for the Error level', () => { + expect(getBannerPalette(Level.ERROR)).toStrictEqual({ + backgroundColor: THEME.color.maximumRed15, + borderColor: THEME.color.maximumRed, + color: THEME.color.maximumRed + }) + }) + test('should return a yellow palette for the Warning level', () => { + expect(getBannerPalette(Level.WARNING)).toStrictEqual({ + backgroundColor: THEME.color.goldenPoppy25, + borderColor: THEME.color.goldenPoppy, + color: THEME.color.charcoal + }) + }) + test('should return a green palette for the success level', () => { + expect(getBannerPalette(Level.SUCCESS)).toStrictEqual({ + backgroundColor: THEME.color.mediumSeaGreen25, + borderColor: THEME.color.mediumSeaGreen, + color: THEME.color.mediumSeaGreen + }) + }) +}) diff --git a/src/components/Banner/index.tsx b/src/components/Banner/index.tsx new file mode 100644 index 000000000..9fb7bb586 --- /dev/null +++ b/src/components/Banner/index.tsx @@ -0,0 +1,122 @@ +import { IconButton } from '@elements/IconButton' +import { LinkButton } from '@elements/LinkButton' +import { isString } from 'lodash' +import { type ReactNode, useState } from 'react' +import styled from 'styled-components' + +import { getBannerPalette } from './utils' +import { Accent, Icon, Level, Size } from '../../constants' + +export type BannerProps = { + children: string | ReactNode + isClosable: boolean + isCollapsible: boolean + isHiddenByDefault: boolean | undefined + level: Level + top: string +} + +interface WrapperProps { + $isCollapsed: boolean + $isCollapsible: boolean + $isHidden: boolean + $level: Level + $top: string +} +const Wrapper = styled.div` + display: ${(p: WrapperProps) => (p.$isHidden ? 'none' : 'flex')}; + flex-direction: row; + justify-content: space-between; + align-items: center; + position: absolute; + background-color: ${(p: WrapperProps) => getBannerPalette(p.$level).backgroundColor}; + width: 100%; + min-width: 100%; + max-width: 100%; + padding: 0 2rem; + top: ${(p: WrapperProps) => `${p.$top}`}; + z-index: 1000; + height: ${(p: WrapperProps) => (!p.$isHidden && p.$isCollapsed ? '10px' : '50px')}; + border-bottom: ${({ $isCollapsible, $level }: WrapperProps) => + $isCollapsible ? `4px solid ${getBannerPalette($level).borderColor}` : 'none'}; + box-shadow: ${({ $isCollapsible }: WrapperProps) => ($isCollapsible ? 'none' : '0px 3px 4px #7077854D')}; + transition: height 0.3s ease; +` + +interface ContentWrapperProps { + $level: Level +} +const ContentWrapper = styled.div` + color: ${(p: ContentWrapperProps) => getBannerPalette(p.$level).color}; + align-self: center; + flex-grow: 2; + text-align: center; + font-size: 16px; + font-weight: 500; +` + +const ButtonWrapper = styled.div` + align-self: center; +` + +function Banner({ children, isClosable, isCollapsible, isHiddenByDefault, level, top }: Readonly) { + const [isHidden, setIsHidden] = useState(!!isHiddenByDefault) + const [isCollapsed, setIsCollapsed] = useState(false) + const [isCollapsing, setIsCollapsing] = useState(false) + const [hasCollapsed, setHasCollapsed] = useState(false) + + const enterHover = (): void => { + if (!isHidden && isCollapsed && !isCollapsing) { + setIsCollapsed(false) + } + setIsCollapsing(false) + } + const leaveHover = (): void => { + if (!isHidden && hasCollapsed) { + setIsCollapsed(true) + } + } + + const onClickAction = (): void => { + if (isClosable) { + setIsHidden(true) + } else if (isCollapsible) { + setIsCollapsing(true) + setIsCollapsed(true) + setHasCollapsed(true) + } + } + + return ( + + {!isHidden && !isCollapsed && ( + <> + {isString(children) ?

{children}

: <>{children}}
+ onClickAction()} + title={isClosable ? 'fermer' : 'masquer'} + > + {isClosable && ( + + )} + {!isClosable && isCollapsible && Masquer} + + + )} +
+ ) +} + +Banner.displayName = 'Banner' + +export { Banner } diff --git a/src/components/Banner/utils.ts b/src/components/Banner/utils.ts new file mode 100644 index 000000000..e93214ad2 --- /dev/null +++ b/src/components/Banner/utils.ts @@ -0,0 +1,25 @@ +import { Level } from '../../constants' +import { THEME } from '../../theme' + +export const getBannerPalette = (level: Level) => { + if (level === Level.ERROR) { + return { + backgroundColor: THEME.color.maximumRed15, + borderColor: THEME.color.maximumRed, + color: THEME.color.maximumRed + } + } + if (level === Level.WARNING) { + return { + backgroundColor: THEME.color.goldenPoppy25, + borderColor: THEME.color.goldenPoppy, + color: THEME.color.charcoal + } + } + + return { + backgroundColor: THEME.color.mediumSeaGreen25, + borderColor: THEME.color.mediumSeaGreen, + color: THEME.color.mediumSeaGreen + } +} diff --git a/src/index.ts b/src/index.ts index 0e01f3941..4f021cd1e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,7 @@ export { SideMenu } from './components/SideMenu' export { SingleTag } from './elements/SingleTag' export { MapMenuDialog } from './components/MapMenuDialog' export { Message } from './components/Message' +export { Banner } from './components/Banner' export { Button } from './elements/Button' export { Field } from './elements/Field' @@ -198,6 +199,7 @@ export type { NotifierProps } from './components/Notifier' export type { SideMenuProps } from './components/SideMenu' export type { SingleTagProps } from './elements/SingleTag' export type { MessageProps } from './components/Message' +export type { BannerProps } from './components/Banner' export type { ButtonProps } from './elements/Button' export type { FieldProps } from './elements/Field' diff --git a/stories/components/Banner.stories.tsx b/stories/components/Banner.stories.tsx new file mode 100644 index 000000000..13bfb2538 --- /dev/null +++ b/stories/components/Banner.stories.tsx @@ -0,0 +1,53 @@ +import { ARG_TYPE, META_DEFAULTS } from '../../.storybook/constants' +import { generateStoryDecorator } from '../../.storybook/utils/generateStoryDecorator' +import { Level, THEME } from '../../src' +import { Banner } from '../../src/components/Banner' + +import type { BannerProps } from '../../src/components/Banner' +import type { Meta } from '@storybook/react' + +/* eslint-disable sort-keys-fix/sort-keys-fix */ +const meta: Meta = { + ...META_DEFAULTS, + + title: 'Components/Banner', + component: Banner, + + argTypes: { + isHiddenByDefault: ARG_TYPE.OPTIONAL_BOOLEAN, + level: ARG_TYPE.OPTIONAL_LEVEL, + isCollapsible: ARG_TYPE.BOOLEAN, + isClosable: ARG_TYPE.BOOLEAN + }, + + args: { + isHiddenByDefault: false, + isCollapsible: true, + isClosable: false, + level: Level.SUCCESS, + children: 'This is the content of the banner', + top: '76px' + }, + + decorators: [generateStoryDecorator()] +} +/* eslint-enable sort-keys-fix/sort-keys-fix */ + +export default meta + +export function _Banner(props: BannerProps) { + return ( +
+
+

This is a header

+
+ +
+ ) +}