diff options
| author | Armand Philippot <git@armandphilippot.com> | 2022-05-24 19:35:12 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2022-05-24 19:35:12 +0200 |
| commit | c85ab5ad43ccf52881ee224672c41ec30021cf48 (patch) | |
| tree | 8058808d9bfca19383f120c46b34d99ff2f89f63 /src/components/molecules/buttons | |
| parent | 52404177c07a2aab7fc894362fb3060dff2431a0 (diff) | |
| parent | 11b9de44a4b2f305a6a484187805e429b2767118 (diff) | |
refactor: use storybook and atomic design (#16)
BREAKING CHANGE: rewrite most of the Typescript types, so the content format (the meta in particular) needs to be updated.
Diffstat (limited to 'src/components/molecules/buttons')
12 files changed, 513 insertions, 0 deletions
diff --git a/src/components/molecules/buttons/back-to-top.module.scss b/src/components/molecules/buttons/back-to-top.module.scss new file mode 100644 index 0000000..77ee97b --- /dev/null +++ b/src/components/molecules/buttons/back-to-top.module.scss @@ -0,0 +1,51 @@ +@use "@styles/abstracts/functions" as fun; + +.wrapper { + .link { + width: clamp(#{fun.convert-px(48)}, 8vw, #{fun.convert-px(55)}); + height: clamp(#{fun.convert-px(48)}, 8vw, #{fun.convert-px(55)}); + + svg { + width: 100%; + } + + :global { + .arrow-head { + transform: translateY(30%) scale(1.2); + transition: all 0.45s ease-in-out 0s; + } + + .arrow-bar { + opacity: 0; + transform: translateY(30%) scaleY(0); + transition: transform 0.45s ease-in-out 0s, opacity 0.1s linear 0.2s; + } + } + + &:hover, + &:focus { + :global { + .arrow-head { + transform: translateY(0) scale(1); + } + + .arrow-bar { + opacity: 1; + transform: translateY(0) scaleY(1); + } + } + + svg { + :global { + animation: pulse 1.2s ease-in-out 0.6s infinite; + } + } + } + + &:active { + svg { + animation-play-state: paused; + } + } + } +} diff --git a/src/components/molecules/buttons/back-to-top.stories.tsx b/src/components/molecules/buttons/back-to-top.stories.tsx new file mode 100644 index 0000000..a338b8b --- /dev/null +++ b/src/components/molecules/buttons/back-to-top.stories.tsx @@ -0,0 +1,47 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import BackToTopComponent from './back-to-top'; + +/** + * BackToTop - Storybook Meta + */ +export default { + title: 'Molecules/Buttons', + component: BackToTopComponent, + argTypes: { + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the button wrapper.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + target: { + control: { + type: 'text', + }, + description: 'An element id (without hashtag) to use as anchor.', + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta<typeof BackToTopComponent>; + +const Template: ComponentStory<typeof BackToTopComponent> = (args) => ( + <BackToTopComponent {...args} /> +); + +/** + * Buttons Stories - Back to top + */ +export const BackToTop = Template.bind({}); +BackToTop.args = { + target: 'top', +}; diff --git a/src/components/molecules/buttons/back-to-top.test.tsx b/src/components/molecules/buttons/back-to-top.test.tsx new file mode 100644 index 0000000..2b3a0a9 --- /dev/null +++ b/src/components/molecules/buttons/back-to-top.test.tsx @@ -0,0 +1,10 @@ +import { render, screen } from '@test-utils'; +import BackToTop from './back-to-top'; + +describe('BackToTop', () => { + it('renders a BackToTop link', () => { + render(<BackToTop target="top" />); + expect(screen.getByRole('link')).toHaveAccessibleName('Back to top'); + expect(screen.getByRole('link')).toHaveAttribute('href', '/#top'); + }); +}); diff --git a/src/components/molecules/buttons/back-to-top.tsx b/src/components/molecules/buttons/back-to-top.tsx new file mode 100644 index 0000000..bd1925a --- /dev/null +++ b/src/components/molecules/buttons/back-to-top.tsx @@ -0,0 +1,43 @@ +import ButtonLink, { + type ButtonLinkProps, +} from '@components/atoms/buttons/button-link'; +import Arrow from '@components/atoms/icons/arrow'; +import { FC } from 'react'; +import { useIntl } from 'react-intl'; +import styles from './back-to-top.module.scss'; + +export type BackToTopProps = Pick<ButtonLinkProps, 'target'> & { + /** + * Set additional classnames to the button wrapper. + */ + className?: string; +}; + +/** + * BackToTop component + * + * Render a back to top link. + */ +const BackToTop: FC<BackToTopProps> = ({ className = '', target }) => { + const intl = useIntl(); + const linkName = intl.formatMessage({ + defaultMessage: 'Back to top', + description: 'BackToTop: link text', + id: 'm+SUSR', + }); + + return ( + <div className={`${styles.wrapper} ${className}`}> + <ButtonLink + shape="square" + target={`#${target}`} + aria-label={linkName} + className={styles.link} + > + <Arrow direction="top" /> + </ButtonLink> + </div> + ); +}; + +export default BackToTop; diff --git a/src/components/molecules/buttons/heading-button.module.scss b/src/components/molecules/buttons/heading-button.module.scss new file mode 100644 index 0000000..3c69221 --- /dev/null +++ b/src/components/molecules/buttons/heading-button.module.scss @@ -0,0 +1,44 @@ +@use "@styles/abstracts/functions" as fun; + +.icon { + transition: all 0.25s ease-in-out 0s; +} + +.wrapper { + display: flex; + flex-flow: row nowrap; + align-items: center; + justify-content: space-between; + gap: var(--spacing-md); + width: 100%; + padding: 0 var(--spacing-2xs); + position: sticky; + top: 0; + background: inherit; + border: none; + border-top: fun.convert-px(2) solid var(--color-primary-dark); + border-bottom: fun.convert-px(2) solid var(--color-primary-dark); + cursor: pointer; + + .heading { + padding: var(--spacing-2xs) 0; + background: none; + font-size: var(--font-size-xl); + font-weight: 500; + text-align: left; + } + + &:hover, + &:focus { + .icon { + background: var(--color-primary-light); + color: var(--color-fg-inverted); + transform: scale(1.25); + + &::before, + &::after { + background: var(--color-bg); + } + } + } +} diff --git a/src/components/molecules/buttons/heading-button.stories.tsx b/src/components/molecules/buttons/heading-button.stories.tsx new file mode 100644 index 0000000..59f7be9 --- /dev/null +++ b/src/components/molecules/buttons/heading-button.stories.tsx @@ -0,0 +1,105 @@ +import headingStories from '@components/atoms/headings/heading.stories'; +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { useState } from 'react'; +import HeadingButtonComponent from './heading-button'; + +/** + * HeadingButton - Storybook Meta + */ +export default { + title: 'Molecules/Buttons/HeadingButton', + component: HeadingButtonComponent, + argTypes: { + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the button.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + expanded: { + control: { + type: null, + }, + description: 'Heading button state (plus or minus).', + type: { + name: 'boolean', + required: true, + }, + }, + level: { + control: { + type: 'number', + min: 1, + max: 6, + }, + description: 'Heading level.', + type: { + name: 'number', + required: true, + }, + }, + setExpanded: { + control: { + type: null, + }, + description: 'Callback function to set heading button state.', + type: { + name: 'function', + required: true, + }, + }, + title: { + control: { + type: 'text', + }, + description: 'Heading title.', + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta<typeof HeadingButtonComponent>; + +const Template: ComponentStory<typeof HeadingButtonComponent> = ({ + expanded, + setExpanded: _setExpanded, + ...args +}) => { + const [isExpanded, setIsExpanded] = useState<boolean>(expanded); + + return ( + <HeadingButtonComponent + expanded={isExpanded} + setExpanded={setIsExpanded} + {...args} + /> + ); +}; + +/** + * Heading Button Stories - Expanded + */ +export const Expanded = Template.bind({}); +Expanded.args = { + expanded: true, + level: 2, + title: 'Your title', +}; + +/** + * Heading Button Stories - Collapsed + */ +export const Collapsed = Template.bind({}); +Collapsed.args = { + expanded: false, + level: 2, + title: 'Your title', +}; diff --git a/src/components/molecules/buttons/heading-button.test.tsx b/src/components/molecules/buttons/heading-button.test.tsx new file mode 100644 index 0000000..be3865a --- /dev/null +++ b/src/components/molecules/buttons/heading-button.test.tsx @@ -0,0 +1,32 @@ +import { render, screen } from '@test-utils'; +import HeadingButton from './heading-button'; + +describe('HeadingButton', () => { + it('renders a button to collapse.', () => { + render( + <HeadingButton + level={2} + title="The accordion title" + expanded={true} + setExpanded={() => null} + /> + ); + expect( + screen.getByRole('button', { name: 'Collapse The accordion title' }) + ).toBeInTheDocument(); + }); + + it('renders a button to expand.', () => { + render( + <HeadingButton + level={2} + title="The accordion title" + expanded={false} + setExpanded={() => null} + /> + ); + expect( + screen.getByRole('button', { name: 'Expand The accordion title' }) + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/molecules/buttons/heading-button.tsx b/src/components/molecules/buttons/heading-button.tsx new file mode 100644 index 0000000..0ed9a76 --- /dev/null +++ b/src/components/molecules/buttons/heading-button.tsx @@ -0,0 +1,67 @@ +import Heading, { type HeadingProps } from '@components/atoms/headings/heading'; +import PlusMinus from '@components/atoms/icons/plus-minus'; +import { FC, SetStateAction } from 'react'; +import { useIntl } from 'react-intl'; +import styles from './heading-button.module.scss'; + +export type HeadingButtonProps = Pick<HeadingProps, 'level'> & { + /** + * Set additional classnames to the button. + */ + className?: string; + /** + * Accordion state. + */ + expanded: boolean; + /** + * Callback function to set accordion state on click. + */ + setExpanded: (value: SetStateAction<boolean>) => void; + /** + * Accordion title. + */ + title: string; +}; + +/** + * HeadingButton component + * + * Render a button as accordion title to toggle body. + */ +const HeadingButton: FC<HeadingButtonProps> = ({ + className = '', + expanded, + level, + setExpanded, + title, +}) => { + const intl = useIntl(); + const iconState = expanded ? 'minus' : 'plus'; + const titlePrefix = expanded + ? intl.formatMessage({ + defaultMessage: 'Collapse', + description: 'HeadingButton: title prefix (expanded state)', + id: 'UX9Bu8', + }) + : intl.formatMessage({ + defaultMessage: 'Expand', + description: 'HeadingButton: title prefix (collapsed state)', + id: 'bcyOgC', + }); + + return ( + <button + type="button" + className={`${styles.wrapper} ${className}`} + onClick={() => setExpanded(!expanded)} + > + <Heading level={level} withMargin={false} className={styles.heading}> + <span className="screen-reader-text">{titlePrefix} </span> + {title} + </Heading> + <PlusMinus state={iconState} className={styles.icon} /> + </button> + ); +}; + +export default HeadingButton; diff --git a/src/components/molecules/buttons/help-button.module.scss b/src/components/molecules/buttons/help-button.module.scss new file mode 100644 index 0000000..42d49f6 --- /dev/null +++ b/src/components/molecules/buttons/help-button.module.scss @@ -0,0 +1,21 @@ +@use "@styles/abstracts/functions" as fun; +@use "@styles/abstracts/mixins" as mix; + +.btn { + padding: var(--spacing-xs); + + &:not(:disabled) { + &:focus { + text-decoration: none; + } + } + + @include mix.pointer("fine") { + padding: var(--spacing-2xs); + } +} + +.icon { + color: var(--color-primary-dark); + font-weight: 600; +} diff --git a/src/components/molecules/buttons/help-button.stories.tsx b/src/components/molecules/buttons/help-button.stories.tsx new file mode 100644 index 0000000..4968b27 --- /dev/null +++ b/src/components/molecules/buttons/help-button.stories.tsx @@ -0,0 +1,47 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import HelpButtonComponent from './help-button'; + +/** + * HelpButton - Storybook Meta + */ +export default { + title: 'Molecules/Buttons', + component: HelpButtonComponent, + argTypes: { + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the button wrapper.', + table: { + category: 'Options', + }, + type: { + name: 'string', + required: false, + }, + }, + onClick: { + control: { + type: null, + }, + description: 'A callback function to handle click on button.', + table: { + category: 'Events', + }, + type: { + name: 'function', + required: false, + }, + }, + }, +} as ComponentMeta<typeof HelpButtonComponent>; + +const Template: ComponentStory<typeof HelpButtonComponent> = (args) => ( + <HelpButtonComponent {...args} /> +); + +/** + * Help Button Stories - Level 1 + */ +export const HelpButton = Template.bind({}); diff --git a/src/components/molecules/buttons/help-button.test.tsx b/src/components/molecules/buttons/help-button.test.tsx new file mode 100644 index 0000000..78987ef --- /dev/null +++ b/src/components/molecules/buttons/help-button.test.tsx @@ -0,0 +1,9 @@ +import { render, screen } from '@test-utils'; +import HelpButton from './help-button'; + +describe('Help', () => { + it('renders a help button', () => { + render(<HelpButton />); + expect(screen.getByRole('button', { name: 'Help ?' })).toBeInTheDocument(); + }); +}); diff --git a/src/components/molecules/buttons/help-button.tsx b/src/components/molecules/buttons/help-button.tsx new file mode 100644 index 0000000..ccf1ebd --- /dev/null +++ b/src/components/molecules/buttons/help-button.tsx @@ -0,0 +1,37 @@ +import Button, { type ButtonProps } from '@components/atoms/buttons/button'; +import { forwardRef, ForwardRefRenderFunction } from 'react'; +import { useIntl } from 'react-intl'; +import styles from './help-button.module.scss'; + +export type HelpButtonProps = Pick<ButtonProps, 'className' | 'onClick'>; + +/** + * HelpButton component + * + * Render a button with an interrogation mark icon. + */ +const HelpButton: ForwardRefRenderFunction< + HTMLButtonElement, + HelpButtonProps +> = ({ className = '', ...props }, ref) => { + const intl = useIntl(); + const text = intl.formatMessage({ + defaultMessage: 'Help', + id: 'i+/ckF', + description: 'HelpButton: screen reader text', + }); + + return ( + <Button + className={`${styles.btn} ${className}`} + ref={ref} + shape="circle" + {...props} + > + <span className="screen-reader-text">{text}</span> + <span className={styles.icon}>?</span> + </Button> + ); +}; + +export default forwardRef(HelpButton); |
