From 3df98a3d21f246c7e56b2a6f8d280baba2b2e5c3 Mon Sep 17 00:00:00 2001 From: Andrew Holloway Date: Wed, 18 Dec 2024 15:48:01 -0600 Subject: [PATCH] feat(VisualPageIndicator): introduce 1.0 component (#2118) - add in new token eds-theme-color-background-visual-page-indicator-current - add in new token eds-theme-color-background-visual-page-indicator - add in component VisualPageIndicator with tests/snapshots - add in dynamic usage assertions - add in reduce motion support --- .storybook/data/tokens.json | 2 + .../VisualPageIndicator.module.css | 34 ++++++++++ .../VisualPageIndicator.stories.ts | 37 ++++++++++ .../VisualPageIndicator.test.tsx | 55 +++++++++++++++ .../VisualPageIndicator.tsx | 68 +++++++++++++++++++ .../VisualPageIndicator.test.tsx.snap | 61 +++++++++++++++++ src/components/VisualPageIndicator/index.ts | 1 + src/design-tokens/themes.json | 10 +++ src/index.ts | 1 + src/tokens-dist/css/variables.css | 2 + src/tokens-dist/json/variables-nested.json | 4 ++ src/tokens-dist/ts/colors.ts | 2 + 12 files changed, 277 insertions(+) create mode 100644 src/components/VisualPageIndicator/VisualPageIndicator.module.css create mode 100644 src/components/VisualPageIndicator/VisualPageIndicator.stories.ts create mode 100644 src/components/VisualPageIndicator/VisualPageIndicator.test.tsx create mode 100644 src/components/VisualPageIndicator/VisualPageIndicator.tsx create mode 100644 src/components/VisualPageIndicator/__snapshots__/VisualPageIndicator.test.tsx.snap create mode 100644 src/components/VisualPageIndicator/index.ts diff --git a/.storybook/data/tokens.json b/.storybook/data/tokens.json index 034fba7b8..00d285c08 100644 --- a/.storybook/data/tokens.json +++ b/.storybook/data/tokens.json @@ -194,6 +194,8 @@ "eds-theme-color-background-table-row-stripe-1": "#F5FAFF", "eds-theme-color-background-table-row-stripe-2": "rgb(var(--eds-color-white) / 1)", "eds-theme-color-background-table-row-selected": "#CEE6FF", + "eds-theme-color-background-visual-page-indicator": "#CFC9C7", + "eds-theme-color-background-visual-page-indicator-current": "#DB458D", "eds-theme-color-background-utility-base-1": "rgb(var(--eds-color-white) / 1)", "eds-theme-color-background-utility-base-2": "#FDF9F8", "eds-theme-color-background-utility-container": "rgb(var(--eds-color-white) / 1)", diff --git a/src/components/VisualPageIndicator/VisualPageIndicator.module.css b/src/components/VisualPageIndicator/VisualPageIndicator.module.css new file mode 100644 index 000000000..5c2cc63da --- /dev/null +++ b/src/components/VisualPageIndicator/VisualPageIndicator.module.css @@ -0,0 +1,34 @@ +/*------------------------------------*\ + # VISUAL PAGE INDICATOR +\*------------------------------------*/ + +/** + * VisualPageIndicator + */ +.visual-page-indicator { + display: flex; + justify-content: center; + gap: calc(var(--eds-size-1-and-half) / 16 * 1rem); +} + +.visual-page-indicator__item { + --visual-page-indicator-bg: var(--eds-theme-color-background-visual-page-indicator); + + height: calc(var(--eds-size-1-and-half) / 16 * 1rem); + width: calc(var(--eds-size-1-and-half) / 16 * 1rem); + border-radius: calc(var(--eds-border-radius-full) * 1px); + + transition: background-color ease calc(var(--eds-anim-move-medium) * 1s); + + background-color: var(--visual-page-indicator-bg); +} + +.visual-page-indicator--active { + --visual-page-indicator-bg: var(--eds-theme-color-background-visual-page-indicator-current); +} + +@media screen and (prefers-reduced-motion: reduce) { + .visual-page-indicator__item { + transition: none; + } +} \ No newline at end of file diff --git a/src/components/VisualPageIndicator/VisualPageIndicator.stories.ts b/src/components/VisualPageIndicator/VisualPageIndicator.stories.ts new file mode 100644 index 000000000..ffa9d064a --- /dev/null +++ b/src/components/VisualPageIndicator/VisualPageIndicator.stories.ts @@ -0,0 +1,37 @@ +import type { StoryObj, Meta } from '@storybook/react'; +import type React from 'react'; + +import { VisualPageIndicator } from './VisualPageIndicator'; + +export default { + title: 'Components/VisualPageIndicator', + component: VisualPageIndicator, + parameters: { + badges: ['api-1.0', 'theme-2.0'], + }, +} as Meta; + +type Args = React.ComponentProps; + +export const Default: StoryObj = { + args: { + activePage: 0, + totalPageCount: 6, + }, +}; + +export const MinimumPages: StoryObj = { + args: { + activePage: 1, + totalPageCount: 2, + }, +}; + +export const FivePages: StoryObj = { + args: { + activePage: 2, + totalPageCount: 5, + }, +}; + +// TODO: add implementation example showing usage of state with a label for a11y handling diff --git a/src/components/VisualPageIndicator/VisualPageIndicator.test.tsx b/src/components/VisualPageIndicator/VisualPageIndicator.test.tsx new file mode 100644 index 000000000..89a4fe7a1 --- /dev/null +++ b/src/components/VisualPageIndicator/VisualPageIndicator.test.tsx @@ -0,0 +1,55 @@ +import { generateSnapshots } from '@chanzuckerberg/story-utils'; +import { render } from '@testing-library/react'; + +import React from 'react'; +import { VisualPageIndicator } from './VisualPageIndicator'; + +import * as stories from './VisualPageIndicator.stories'; +import type { StoryFile } from '../../util/utility-types'; + +describe('', () => { + beforeEach(() => { + // Add in mocks for the calls that can occur in implementation to suppress logging in tests + const consoleMock = jest.spyOn(console, 'error'); + const consoleWarnMock = jest.spyOn(console, 'warn'); + consoleMock.mockImplementation(); + consoleWarnMock.mockImplementation(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + generateSnapshots(stories as StoryFile); + + describe('emits messages when misused', () => { + let consoleErrorMock: jest.SpyInstance, consoleWarnMock: jest.SpyInstance; + beforeEach(() => { + consoleWarnMock = jest.spyOn(console, 'warn'); + consoleErrorMock = jest.spyOn(console, 'error'); + consoleWarnMock.mockImplementation(); + consoleErrorMock.mockImplementation(); + }); + + it('errors when active page is above range', () => { + render(); + + expect(consoleWarnMock).toHaveBeenCalledTimes(0); + expect(consoleErrorMock).toHaveBeenCalledTimes(1); + }); + + it('errors when active page is below range', () => { + render(); + + expect(consoleWarnMock).toHaveBeenCalledTimes(0); + expect(consoleErrorMock).toHaveBeenCalledTimes(1); + }); + + it('warns when total page count is too small', () => { + render(); + + expect(consoleWarnMock).toHaveBeenCalledTimes(1); + expect(consoleErrorMock).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/src/components/VisualPageIndicator/VisualPageIndicator.tsx b/src/components/VisualPageIndicator/VisualPageIndicator.tsx new file mode 100644 index 000000000..ea0bb76bf --- /dev/null +++ b/src/components/VisualPageIndicator/VisualPageIndicator.tsx @@ -0,0 +1,68 @@ +import clsx from 'clsx'; +import React from 'react'; +import { assertEdsUsage } from '../../util/logging'; + +import styles from './VisualPageIndicator.module.css'; + +export type VisualPageIndicatorProps = { + // Component API + /** + * CSS class names that can be appended to the component. + */ + className?: string; + // Design API + /** + * Index of the active page in the indicator (0-based). + */ + activePage: number; + /** + * Total number of pages available in this experience + */ + totalPageCount: number; +}; + +/** + * `import {VisualPageIndicator} from "@chanzuckerberg/eds";` + * + * Static visual cue to help users understand their current position within a series of content or pages. + */ +export const VisualPageIndicator = ({ + className, + activePage, + totalPageCount, + ...other +}: VisualPageIndicatorProps) => { + const componentClassName = clsx(styles['visual-page-indicator'], className); + + assertEdsUsage( + [totalPageCount < 2], + 'The minimum allowed count of indicators is 2', + ); + + assertEdsUsage( + [activePage < 0, activePage > totalPageCount - 1], + `The position in the indicator is out of range: [0, ${totalPageCount - 1}]`, + 'error', + ); + + return ( +
    + {Array(totalPageCount) + .fill(0) + .map((_, index) => { + return `Page ${index}`; + }) + .map((name, index) => { + return ( +
  • + ); + })} +
+ ); +}; diff --git a/src/components/VisualPageIndicator/__snapshots__/VisualPageIndicator.test.tsx.snap b/src/components/VisualPageIndicator/__snapshots__/VisualPageIndicator.test.tsx.snap new file mode 100644 index 000000000..87badf226 --- /dev/null +++ b/src/components/VisualPageIndicator/__snapshots__/VisualPageIndicator.test.tsx.snap @@ -0,0 +1,61 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` Default story renders snapshot 1`] = ` +
    +
  • +
  • +
  • +
  • +
  • +
  • +
+`; + +exports[` FivePages story renders snapshot 1`] = ` +
    +
  • +
  • +
  • +
  • +
  • +
+`; + +exports[` MinimumPages story renders snapshot 1`] = ` +
    +
  • +
  • +
+`; diff --git a/src/components/VisualPageIndicator/index.ts b/src/components/VisualPageIndicator/index.ts new file mode 100644 index 000000000..fcf19fad2 --- /dev/null +++ b/src/components/VisualPageIndicator/index.ts @@ -0,0 +1 @@ +export { VisualPageIndicator as default } from './VisualPageIndicator'; diff --git a/src/design-tokens/themes.json b/src/design-tokens/themes.json index 429b9859d..791c1ff66 100644 --- a/src/design-tokens/themes.json +++ b/src/design-tokens/themes.json @@ -235,6 +235,16 @@ "value": "{eds.color.blue.100}" } }, + "visualPageIndicator": { + "@": { + "value": "{eds.color.neutral.200}", + "group": "color" + }, + "current": { + "value": "{eds.theme.color.background.brand.pink}", + "group": "color" + } + }, "utility": { "base": { "1": { diff --git a/src/index.ts b/src/index.ts index f0bb233e6..d305e50b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -78,3 +78,4 @@ export type { AppNotificationProps as AppNotificationV2Props } from './component */ // https://headlessui.com/v1/react/transition export { Transition } from '@headlessui/react'; +export { default as VisualPageIndicator } from './components/VisualPageIndicator'; diff --git a/src/tokens-dist/css/variables.css b/src/tokens-dist/css/variables.css index 229db7bf8..ca731351d 100644 --- a/src/tokens-dist/css/variables.css +++ b/src/tokens-dist/css/variables.css @@ -756,6 +756,7 @@ --eds-theme-color-background-utility-container-active: var(--eds-color-neutral-100); --eds-theme-color-background-utility-container-hover: var(--eds-color-neutral-050); --eds-theme-color-background-utility-base-2: var(--eds-color-neutral-025); + --eds-theme-color-background-visual-page-indicator: var(--eds-color-neutral-200); --eds-theme-color-background-table-row-selected: var(--eds-color-blue-100); --eds-theme-color-background-table-row-stripe-2: var(--eds-theme-color-background-utility-base-1); --eds-theme-color-background-table-row-stripe-1: var(--eds-color-blue-025); @@ -826,4 +827,5 @@ --eds-theme-color-icon-utility-default-primary-active: var(--eds-theme-color-text-utility-default-primary-active); --eds-theme-color-icon-utility-default-primary-hover: var(--eds-theme-color-text-utility-default-primary-hover); --eds-theme-color-icon-utility-default-primary: var(--eds-theme-color-text-utility-default-primary); + --eds-theme-color-background-visual-page-indicator-current: var(--eds-theme-color-background-brand-pink); } diff --git a/src/tokens-dist/json/variables-nested.json b/src/tokens-dist/json/variables-nested.json index ed6350f8b..9e52f6565 100644 --- a/src/tokens-dist/json/variables-nested.json +++ b/src/tokens-dist/json/variables-nested.json @@ -402,6 +402,10 @@ }, "selected": "#CEE6FF" }, + "visualPageIndicator": { + "@": "#CFC9C7", + "current": "#DB458D" + }, "utility": { "base": { "1": "rgb(var(--eds-color-white) / 1)", diff --git a/src/tokens-dist/ts/colors.ts b/src/tokens-dist/ts/colors.ts index 72b71bbf4..790fcfa14 100644 --- a/src/tokens-dist/ts/colors.ts +++ b/src/tokens-dist/ts/colors.ts @@ -30,6 +30,8 @@ export const EdsThemeColorBackgroundTableRowStripe1 = '#F5FAFF'; export const EdsThemeColorBackgroundTableRowStripe2 = 'rgb(var(--eds-color-white) / 1)'; export const EdsThemeColorBackgroundTableRowSelected = '#CEE6FF'; +export const EdsThemeColorBackgroundVisualPageIndicator = '#CFC9C7'; +export const EdsThemeColorBackgroundVisualPageIndicatorCurrent = '#DB458D'; export const EdsThemeColorBackgroundUtilityBase1 = 'rgb(var(--eds-color-white) / 1)'; export const EdsThemeColorBackgroundUtilityBase2 = '#FDF9F8';