diff --git a/cypress/e2e/design-system-page.cy.ts b/cypress/e2e/design-system-page.cy.ts new file mode 100644 index 000000000..4b37f18cf --- /dev/null +++ b/cypress/e2e/design-system-page.cy.ts @@ -0,0 +1,72 @@ +import { BASE_COMMANDS, SELECT_COMMANDS } from '../utils/commands'; +import { gherkin } from '../utils/gherkin'; + +describe(`Design System Components`, () => { + const buttonVariantsContainerId = `button-variants-container`; + + const given = gherkin({ + ...BASE_COMMANDS, + ...SELECT_COMMANDS, + 'I navigate to design system page': () => { + cy.visit(`/design-system`); + }, + 'I take screenshot of button variants': () => { + cy.get(`[data-testid="${buttonVariantsContainerId}"]`).scrollIntoView(); + BASE_COMMANDS[`System takes element picture`]( + `[data-testid="${buttonVariantsContainerId}"]`, + `button-variants-screenshot`, + ); + }, + 'I take screenshot of individual button': (label: string) => { + cy.contains(label).scrollIntoView(); + cy.contains(label) + .parent() + .find(`button`) + .should(`be.visible`) + .then(($button) => { + const buttonId = `button-${label.replace(/[^a-zA-Z0-9]/g, `-`).toLowerCase()}`; + cy.wrap($button).invoke(`attr`, `data-test-button-id`, buttonId); + BASE_COMMANDS[`System takes element picture`]( + `[data-test-button-id="${buttonId}"]`, + `button-variant-${label.replace(/[^a-zA-Z0-9]/g, `-`).toLowerCase()}`, + ); + cy.wrap($button).invoke(`removeAttr`, `data-test-button-id`); + }); + }, + }); + + it(`captures screenshots of all button variants`, () => { + given(`System has accepted cookies`) + .and(`I set white theme`) + .when(`I navigate to design system page`) + .then(`I take screenshot of button variants`); + + const buttonLabels = [ + `s=1, i=1`, + `s=1, i=2`, + `s=2, i=1`, + `s=2, i=2`, + `auto s=1, i=1`, + `auto s=1, i=2`, + `auto s=2, i=1`, + `auto s=2, i=2`, + `pill s=1, i=2`, + `pill s=2, i=2`, + `disabled`, + ]; + + buttonLabels.forEach((label) => { + given(`I take screenshot of individual button`, label); + }); + }); + + it(`verifies select component behavior`, () => { + given(`System has accepted cookies`) + .and(`I set white theme`) + .when(`I navigate to design system page`) + .and(`I select from`, `combobox`, `Banana`) + .then(`I verify select value`, `combobox`, `Banana`) + .when(`I select from`, `combobox`, `Apple`) + .then(`I verify select value`, `combobox`, `Apple`); + }); +}); diff --git a/cypress/snapshots/design-system-button.cy.ts/button-variant-auto-s-1--i-1.snap.png b/cypress/snapshots/design-system-button.cy.ts/button-variant-auto-s-1--i-1.snap.png new file mode 100644 index 000000000..6c07a6e20 Binary files /dev/null and b/cypress/snapshots/design-system-button.cy.ts/button-variant-auto-s-1--i-1.snap.png differ diff --git a/cypress/snapshots/design-system-button.cy.ts/button-variant-auto-s-1--i-2.snap.png b/cypress/snapshots/design-system-button.cy.ts/button-variant-auto-s-1--i-2.snap.png new file mode 100644 index 000000000..079480a13 Binary files /dev/null and b/cypress/snapshots/design-system-button.cy.ts/button-variant-auto-s-1--i-2.snap.png differ diff --git a/cypress/snapshots/design-system-button.cy.ts/button-variant-auto-s-2--i-1.snap.png b/cypress/snapshots/design-system-button.cy.ts/button-variant-auto-s-2--i-1.snap.png new file mode 100644 index 000000000..dc6e1d176 Binary files /dev/null and b/cypress/snapshots/design-system-button.cy.ts/button-variant-auto-s-2--i-1.snap.png differ diff --git a/cypress/snapshots/design-system-button.cy.ts/button-variant-auto-s-2--i-2.snap.png b/cypress/snapshots/design-system-button.cy.ts/button-variant-auto-s-2--i-2.snap.png new file mode 100644 index 000000000..68b01b0ad Binary files /dev/null and b/cypress/snapshots/design-system-button.cy.ts/button-variant-auto-s-2--i-2.snap.png differ diff --git a/cypress/snapshots/design-system-button.cy.ts/button-variant-disabled.snap.png b/cypress/snapshots/design-system-button.cy.ts/button-variant-disabled.snap.png new file mode 100644 index 000000000..2ad1eb779 Binary files /dev/null and b/cypress/snapshots/design-system-button.cy.ts/button-variant-disabled.snap.png differ diff --git a/cypress/snapshots/design-system-button.cy.ts/button-variant-pill-s-1--i-2.snap.png b/cypress/snapshots/design-system-button.cy.ts/button-variant-pill-s-1--i-2.snap.png new file mode 100644 index 000000000..c35083d38 Binary files /dev/null and b/cypress/snapshots/design-system-button.cy.ts/button-variant-pill-s-1--i-2.snap.png differ diff --git a/cypress/snapshots/design-system-button.cy.ts/button-variant-pill-s-2--i-2.snap.png b/cypress/snapshots/design-system-button.cy.ts/button-variant-pill-s-2--i-2.snap.png new file mode 100644 index 000000000..a21dcb291 Binary files /dev/null and b/cypress/snapshots/design-system-button.cy.ts/button-variant-pill-s-2--i-2.snap.png differ diff --git a/cypress/snapshots/design-system-button.cy.ts/button-variant-s-1--i-1.snap.png b/cypress/snapshots/design-system-button.cy.ts/button-variant-s-1--i-1.snap.png new file mode 100644 index 000000000..55a6909a9 Binary files /dev/null and b/cypress/snapshots/design-system-button.cy.ts/button-variant-s-1--i-1.snap.png differ diff --git a/cypress/snapshots/design-system-button.cy.ts/button-variant-s-1--i-2.snap.png b/cypress/snapshots/design-system-button.cy.ts/button-variant-s-1--i-2.snap.png new file mode 100644 index 000000000..24591e43e Binary files /dev/null and b/cypress/snapshots/design-system-button.cy.ts/button-variant-s-1--i-2.snap.png differ diff --git a/cypress/snapshots/design-system-button.cy.ts/button-variant-s-2--i-1.snap.png b/cypress/snapshots/design-system-button.cy.ts/button-variant-s-2--i-1.snap.png new file mode 100644 index 000000000..2b3ef7ee8 Binary files /dev/null and b/cypress/snapshots/design-system-button.cy.ts/button-variant-s-2--i-1.snap.png differ diff --git a/cypress/snapshots/design-system-button.cy.ts/button-variant-s-2--i-2.snap.png b/cypress/snapshots/design-system-button.cy.ts/button-variant-s-2--i-2.snap.png new file mode 100644 index 000000000..2fe6172f7 Binary files /dev/null and b/cypress/snapshots/design-system-button.cy.ts/button-variant-s-2--i-2.snap.png differ diff --git a/cypress/snapshots/design-system-button.cy.ts/button-variants-screenshot.snap.png b/cypress/snapshots/design-system-button.cy.ts/button-variants-screenshot.snap.png new file mode 100644 index 000000000..8b4dd9931 Binary files /dev/null and b/cypress/snapshots/design-system-button.cy.ts/button-variants-screenshot.snap.png differ diff --git a/cypress/snapshots/design-system-page.cy.ts/button-variant-auto-s-1--i-1.snap.png b/cypress/snapshots/design-system-page.cy.ts/button-variant-auto-s-1--i-1.snap.png new file mode 100644 index 000000000..6c07a6e20 Binary files /dev/null and b/cypress/snapshots/design-system-page.cy.ts/button-variant-auto-s-1--i-1.snap.png differ diff --git a/cypress/snapshots/design-system-page.cy.ts/button-variant-auto-s-1--i-2.snap.png b/cypress/snapshots/design-system-page.cy.ts/button-variant-auto-s-1--i-2.snap.png new file mode 100644 index 000000000..a4b385f84 Binary files /dev/null and b/cypress/snapshots/design-system-page.cy.ts/button-variant-auto-s-1--i-2.snap.png differ diff --git a/cypress/snapshots/design-system-page.cy.ts/button-variant-auto-s-2--i-1.snap.png b/cypress/snapshots/design-system-page.cy.ts/button-variant-auto-s-2--i-1.snap.png new file mode 100644 index 000000000..727b52460 Binary files /dev/null and b/cypress/snapshots/design-system-page.cy.ts/button-variant-auto-s-2--i-1.snap.png differ diff --git a/cypress/snapshots/design-system-page.cy.ts/button-variant-auto-s-2--i-2.snap.png b/cypress/snapshots/design-system-page.cy.ts/button-variant-auto-s-2--i-2.snap.png new file mode 100644 index 000000000..68b01b0ad Binary files /dev/null and b/cypress/snapshots/design-system-page.cy.ts/button-variant-auto-s-2--i-2.snap.png differ diff --git a/cypress/snapshots/design-system-page.cy.ts/button-variant-disabled.snap.png b/cypress/snapshots/design-system-page.cy.ts/button-variant-disabled.snap.png new file mode 100644 index 000000000..2ad1eb779 Binary files /dev/null and b/cypress/snapshots/design-system-page.cy.ts/button-variant-disabled.snap.png differ diff --git a/cypress/snapshots/design-system-page.cy.ts/button-variant-pill-s-1--i-2.snap.png b/cypress/snapshots/design-system-page.cy.ts/button-variant-pill-s-1--i-2.snap.png new file mode 100644 index 000000000..dc32b2b3a Binary files /dev/null and b/cypress/snapshots/design-system-page.cy.ts/button-variant-pill-s-1--i-2.snap.png differ diff --git a/cypress/snapshots/design-system-page.cy.ts/button-variant-pill-s-2--i-2.snap.png b/cypress/snapshots/design-system-page.cy.ts/button-variant-pill-s-2--i-2.snap.png new file mode 100644 index 000000000..cdbcebb14 Binary files /dev/null and b/cypress/snapshots/design-system-page.cy.ts/button-variant-pill-s-2--i-2.snap.png differ diff --git a/cypress/snapshots/design-system-page.cy.ts/button-variant-s-1--i-1.snap.png b/cypress/snapshots/design-system-page.cy.ts/button-variant-s-1--i-1.snap.png new file mode 100644 index 000000000..3e0253e34 Binary files /dev/null and b/cypress/snapshots/design-system-page.cy.ts/button-variant-s-1--i-1.snap.png differ diff --git a/cypress/snapshots/design-system-page.cy.ts/button-variant-s-1--i-2.snap.png b/cypress/snapshots/design-system-page.cy.ts/button-variant-s-1--i-2.snap.png new file mode 100644 index 000000000..24591e43e Binary files /dev/null and b/cypress/snapshots/design-system-page.cy.ts/button-variant-s-1--i-2.snap.png differ diff --git a/cypress/snapshots/design-system-page.cy.ts/button-variant-s-2--i-1.snap.png b/cypress/snapshots/design-system-page.cy.ts/button-variant-s-2--i-1.snap.png new file mode 100644 index 000000000..c4d704331 Binary files /dev/null and b/cypress/snapshots/design-system-page.cy.ts/button-variant-s-2--i-1.snap.png differ diff --git a/cypress/snapshots/design-system-page.cy.ts/button-variant-s-2--i-2.snap.png b/cypress/snapshots/design-system-page.cy.ts/button-variant-s-2--i-2.snap.png new file mode 100644 index 000000000..9f26e0218 Binary files /dev/null and b/cypress/snapshots/design-system-page.cy.ts/button-variant-s-2--i-2.snap.png differ diff --git a/cypress/snapshots/design-system-page.cy.ts/button-variants-screenshot.snap.png b/cypress/snapshots/design-system-page.cy.ts/button-variants-screenshot.snap.png new file mode 100644 index 000000000..5b7c4f216 Binary files /dev/null and b/cypress/snapshots/design-system-page.cy.ts/button-variants-screenshot.snap.png differ diff --git a/cypress/utils/commands.ts b/cypress/utils/commands.ts index 4f74a494e..8ac373542 100644 --- a/cypress/utils/commands.ts +++ b/cypress/utils/commands.ts @@ -249,5 +249,24 @@ const BASE_COMMANDS = { }, } as const; +export const SELECT_COMMANDS = { + 'I select from': (selectRole: string, optionLabel: string) => { + cy.get(`[role="${selectRole}"]`).first().click(); + cy.get(`[role="listbox"]`).contains(optionLabel).click(); + }, + 'I verify select value': (selectRole: string, value: string) => { + cy.get(`[role="${selectRole}"]`).contains(value).should(`be.visible`); + }, + 'I press key on': (selectRole: string, key: string) => { + cy.get(`[role="${selectRole}"]`).type(key); + }, + 'I verify select option': (option: string) => { + cy.get(`[role="listbox"]`).contains(option).should(`be.visible`); + }, + 'I verify disabled select': () => { + cy.get(`[role="combobox"][aria-disabled="true"]`).should(`exist`); + }, +} as const; + export type { Element, ClickableControls }; export { BASE_COMMANDS }; diff --git a/meta.ts b/meta.ts index 6b09134ff..ef8a24421 100644 --- a/meta.ts +++ b/meta.ts @@ -39,5 +39,6 @@ export const meta = { }, notFound: `/404/`, privacyPolicy: `/privacy-policy/`, + designSystem: `/design-system/`, }, } as const; diff --git a/src/containers/app-footer.container.tsx b/src/containers/app-footer.container.tsx index 4b2a92314..7ad1c110f 100644 --- a/src/containers/app-footer.container.tsx +++ b/src/containers/app-footer.container.tsx @@ -136,6 +136,13 @@ const AppFooterContainer = () => { > Education Zone + + Design System + { + return ( + + + Design System + + ); +}; + +export { DesignSystemLinkContainer }; diff --git a/src/design-system/select.tsx b/src/design-system/select.tsx new file mode 100644 index 000000000..0b3c0d0df --- /dev/null +++ b/src/design-system/select.tsx @@ -0,0 +1,255 @@ +'use client'; + +import React, { + type HTMLAttributes, + type DetailedHTMLProps, + type ReactNode, +} from 'react'; +import c from 'classnames'; +import { BiChevronDown, BiChevronUp, BiCheck } from 'react-icons/bi'; +import { useSimpleFeature } from '@greenonsoftware/react-kit'; + +export interface SelectOption { + value: string; + label: string; +} + +interface SelectProps + extends Omit< + DetailedHTMLProps, HTMLDivElement>, + 'onChange' | 'value' + > { + options: SelectOption[]; + value?: string; + onChange?: (value: string) => void; + placeholder?: string; + disabled?: boolean; +} + +const Select = ({ + className, + options, + value, + onChange, + placeholder = `Select an option`, + disabled, + ...props +}: SelectProps) => { + const dropdownMenu = useSimpleFeature(); + const [highlightedIndex, setHighlightedIndex] = React.useState(0); + const containerRef = React.useRef(null); + const listboxRef = React.useRef(null); + + const selectedOption = options.find((option) => option.value === value); + + const toggleOpen = React.useCallback( + (_: React.MouseEvent): void => { + if (!disabled) { + dropdownMenu.toggle(); + } + }, + [disabled, dropdownMenu], + ); + + const handleOptionClick = React.useCallback( + (option: SelectOption) => { + onChange?.(option.value); + dropdownMenu.off(); + }, + [onChange, dropdownMenu], + ); + + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent): void => { + if (disabled) return; + + switch (e.key) { + case `Enter`: + case ` `: + if (!dropdownMenu.isOn) { + dropdownMenu.on(); + } else { + const option = options[highlightedIndex]; + if (option) { + handleOptionClick(option); + } + } + e.preventDefault(); + break; + case `ArrowDown`: + case `ArrowRight`: + if (dropdownMenu.isOn) { + setHighlightedIndex((prev) => + prev < options.length - 1 ? prev + 1 : prev, + ); + } else { + dropdownMenu.on(); + } + e.preventDefault(); + break; + case `ArrowUp`: + case `ArrowLeft`: + if (dropdownMenu.isOn) { + setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : prev)); + } else { + dropdownMenu.on(); + } + e.preventDefault(); + break; + case `Escape`: + dropdownMenu.off(); + e.preventDefault(); + break; + case `Tab`: + if (dropdownMenu.isOn) { + dropdownMenu.off(); + } + break; + } + }, + [options, highlightedIndex, disabled, handleOptionClick, dropdownMenu], + ); + + React.useEffect(() => { + if (dropdownMenu.isOn && listboxRef.current) { + const highlightedElement = listboxRef.current.children[ + highlightedIndex + ] as HTMLElement; + if (highlightedElement) { + highlightedElement.scrollIntoView({ block: `nearest` }); + } + } + }, [dropdownMenu.isOn, highlightedIndex]); + + // Close dropdown when clicking outside + React.useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + dropdownMenu.off(); + } + }; + + if (dropdownMenu.isOn) { + document.addEventListener(`mousedown`, handleClickOutside); + } + + return () => { + document.removeEventListener(`mousedown`, handleClickOutside); + }; + }, [dropdownMenu.isOn, dropdownMenu]); + + React.useEffect(() => { + if (dropdownMenu.isOn) { + const selectedIndex = options.findIndex( + (option) => option.value === value, + ); + setHighlightedIndex(selectedIndex >= 0 ? selectedIndex : 0); + } + }, [dropdownMenu.isOn, options, value]); + + return ( + + + + {selectedOption ? selectedOption.label : placeholder} + + + {dropdownMenu.isOn ? ( + + ) : ( + + )} + + + + {dropdownMenu.isOn && ( + + {options.map((option, index) => ( + handleOptionClick(option)} + role="option" + aria-selected={option.value === value} + > + + {option.label} + {option.value === value && } + + + ))} + + )} + + ); +}; + +export const SelectField = ({ + className, + label, + hint, + ...props +}: { + className?: string; + label?: ReactNode; + hint?: ReactNode; +} & SelectProps) => { + return ( + + {label && ( + + {label} + + )} + + {hint} + + ); +}; + +export { Select }; diff --git a/src/pages/design-system.tsx b/src/pages/design-system.tsx new file mode 100644 index 000000000..badf0d3a5 --- /dev/null +++ b/src/pages/design-system.tsx @@ -0,0 +1,152 @@ +import type { HeadFC } from 'gatsby'; +import React from 'react'; +import { meta } from '../../meta'; +import Meta from 'components/meta'; +import LogoThumbnail from 'images/logo-thumbnail.png'; +import { Button, type ButtonProps } from 'design-system/button'; +import { AppNavigation } from 'components/app-navigation'; +import { AppFooterContainer } from 'containers/app-footer.container'; +import { CreationLinkContainer } from 'containers/creation-link.container'; +import { EducationRankLinkContainer } from 'containers/education-rank-link.container'; +import { EducationZoneLinkContainer } from 'containers/education-zone-link.container'; +import { DesignSystemLinkContainer } from 'containers/design-system-link.container'; +import { Select, SelectField, type SelectOption } from 'design-system/select'; +import { Field } from 'design-system/field'; + +// TODO: Add more button variants, like icon buttons, loading buttons, etc. +// TODO: Think about the labels +const buttonVariants: Array = [ + { s: 1, i: 1, label: `s=1, i=1` }, + { s: 1, i: 2, label: `s=1, i=2` }, + { s: 2, i: 1, label: `s=2, i=1` }, + { s: 2, i: 2, label: `s=2, i=2` }, + + { s: 1, i: 1, auto: true, label: `auto s=1, i=1` }, + { s: 1, i: 2, auto: true, label: `auto s=1, i=2` }, + { s: 2, i: 1, auto: true, label: `auto s=2, i=1` }, + { s: 2, i: 2, auto: true, label: `auto s=2, i=2` }, + + { s: 1, i: 2, rounded: true, label: `pill s=1, i=2` }, + { s: 2, i: 2, rounded: true, label: `pill s=2, i=2` }, + + { s: 2, i: 2, disabled: true, label: `disabled` }, +]; + +type ButtonVariantsProps = { + buttonVariants: Array; +}; + +const DesignSystemButtonPreview = ({ buttonVariants }: ButtonVariantsProps) => { + return ( + + + + Button Variants + + + + {buttonVariants.map(({ label, ...props }) => ( + + {props.auto ? `Button Text` : `A`} + + {label} + + + ))} + + + + ); +}; + +const DesignSystemSelectPreview = () => { + const [value, setValue] = React.useState(``); + + const options: SelectOption[] = [ + { value: `apple`, label: `Apple` }, + { value: `banana`, label: `Banana` }, + { value: `orange`, label: `Orange` }, + { value: `grape`, label: `Grape` }, + { value: `strawberry`, label: `Strawberry` }, + { value: `blueberry`, label: `Blueberry` }, + { value: `raspberry`, label: `Raspberry` }, + { value: `blackberry`, label: `Blackberry` }, + { value: `kiwi`, label: `Kiwi` }, + { value: `mango`, label: `Mango` }, + ]; + + return ( + + Select Component Demo + + + + Choose your favorite fruit + + } + /> + + + + + + + + Selected value: {value || `None`} + Try using keyboard navigation: + + Tab to focus the select + Space/Enter to open dropdown + Arrow keys to navigate options + Enter to select an option + Escape to close dropdown + + + + ); +}; + +const DesignSystemPage = () => { + return ( + <> + + + + + + + + + + > + ); +}; +export default DesignSystemPage; + +export const Head: HeadFC = () => { + return ( + + ); +};
Selected value: {value || `None`}
Try using keyboard navigation: