diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 052a6a33..cf89c904 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -13,6 +13,7 @@ import { addons } from '@storybook/preview-api'; import { DARK_MODE_EVENT_NAME } from 'storybook-dark-mode'; import { DocsContainer, Title, Subtitle, Description, Primary, Controls, Stories } from '@storybook/blocks'; +import { NuqsAdapter } from 'nuqs/adapters/react'; import { BaklavaProvider } from '../src/context/BaklavaProvider.tsx'; @@ -27,7 +28,13 @@ channel.on(DARK_MODE_EVENT_NAME, isDark => { isDarkInitial = isDark; }); const preview = { decorators: [ - Story => , + Story => ( + + + + + + ), ], parameters: { @@ -141,11 +148,6 @@ const preview = { 'TextAreaField', ], ], - 'navigations', - [ - 'Tabs', - 'Stepper', - ], 'tables', [ 'DataTableEager', @@ -154,6 +156,12 @@ const preview = { 'SearchInput', 'MultiSearch', ], + 'navigation', + [ + 'Tabs', + 'Stepper', + 'Breadcrumbs', + ], ], 'layouts', [ @@ -162,11 +170,9 @@ const preview = { 'PageLayout', 'AppLayout', [ - 'Logo', 'Header', 'Nav', 'Sidebar', - 'Breadcrumbs', ], 'PublicLayout', ], diff --git a/app/Demo.tsx b/app/Demo.tsx index cfe50690..77735cc9 100644 --- a/app/Demo.tsx +++ b/app/Demo.tsx @@ -5,15 +5,15 @@ import { Link } from '../src/components/actions/Link/Link.tsx'; import { Icon } from '../src/components/graphics/Icon/Icon.tsx'; import { Panel } from '../src/components/containers/Panel/Panel.tsx'; - +import { Breadcrumbs } from '../src/components/navigation/Breadcrumbs/Breadcrumbs.tsx'; import { FortanixLogo } from '../src/fortanix/FortanixLogo/FortanixLogo.tsx'; + import { UserMenu } from '../src/layouts/AppLayout/Header/UserMenu.tsx'; import { AccountSelector } from '../src/layouts/AppLayout/Header/AccountSelector.tsx'; import { SolutionSelector } from '../src/layouts/AppLayout/Header/SolutionSelector.tsx'; import { Header } from '../src/layouts/AppLayout/Header/Header.tsx'; import { Sidebar } from '../src/layouts/AppLayout/Sidebar/Sidebar.tsx'; import { Nav } from '../src/layouts/AppLayout/Nav/Nav.tsx'; -import { Breadcrumbs } from '../src/layouts/AppLayout/Breadcrumbs/Breadcrumbs.tsx'; import { AppLayout } from '../src/layouts/AppLayout/AppLayout.tsx'; diff --git a/app/lib.ts b/app/lib.ts index 81b58f28..51b587db 100644 --- a/app/lib.ts +++ b/app/lib.ts @@ -80,9 +80,11 @@ export { Tag } from '../src/components/text/Tag/Tag.tsx'; // Lists export { PropertyList } from '../src/components/lists/PropertyList/PropertyList.tsx'; -// Navigations -export { Stepper } from '../src/components/navigations/Stepper/Stepper.tsx'; -export { Tab, Tabs } from '../src/components/navigations/Tabs/Tabs.tsx'; +// Navigation +export { Stepper } from '../src/components/navigation/Stepper/Stepper.tsx'; +export { Tabs } from '../src/components/navigation/Tabs/Tabs.tsx'; +export { Tab } from '../src/components/navigation/Tabs/Tabs.tsx'; // Deprecated, please use `` instead +export { Breadcrumbs } from '../src/components/navigation/Breadcrumbs/Breadcrumbs.tsx'; // Overlays export { SpinnerModal } from '../src/components/overlays/SpinnerModal/SpinnerModal.tsx'; @@ -117,7 +119,6 @@ export { FormLayout } from '../src/layouts/FormLayout/FormLayout.tsx'; export { PublicLayout } from '../src/layouts/PublicLayout/PublicLayout.tsx'; -export { Breadcrumbs } from '../src/layouts/AppLayout/Breadcrumbs/Breadcrumbs.tsx'; export { Header } from '../src/layouts/AppLayout/Header/Header.tsx'; export { AccountSelector } from '../src/layouts/AppLayout/Header/AccountSelector.tsx'; export { SolutionSelector } from '../src/layouts/AppLayout/Header/SolutionSelector.tsx'; diff --git a/package-lock.json b/package-lock.json index 320265a5..daaef50d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "date-fns": "^4.1.0", "effect": "^3.15.3", "message-tag": "^0.10.0", + "nuqs": "^2.4.3", "optics-ts": "^2.4.1", "react": "^19.0.0", "react-datepicker": "^8.0.0", @@ -13435,6 +13436,12 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, "node_modules/mixin-deep": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz", @@ -13839,6 +13846,39 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/nuqs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/nuqs/-/nuqs-2.4.3.tgz", + "integrity": "sha512-BgtlYpvRwLYiJuWzxt34q2bXu/AIS66sLU1QePIMr2LWkb+XH0vKXdbLSgn9t6p7QKzwI7f38rX3Wl9llTXQ8Q==", + "license": "MIT", + "dependencies": { + "mitt": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/franky47" + }, + "peerDependencies": { + "@remix-run/react": ">=2", + "next": ">=14.2.0", + "react": ">=18.2.0 || ^19.0.0-0", + "react-router": "^6 || ^7", + "react-router-dom": "^6 || ^7" + }, + "peerDependenciesMeta": { + "@remix-run/react": { + "optional": true + }, + "next": { + "optional": true + }, + "react-router": { + "optional": true + }, + "react-router-dom": { + "optional": true + } + } + }, "node_modules/nyc": { "version": "15.1.0", "resolved": "https://registry.npmjs.org/nyc/-/nyc-15.1.0.tgz", diff --git a/package.json b/package.json index dd6fa950..e3187226 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,7 @@ "react-error-boundary": "^6.0.0", "classnames": "^2.5.1", "zustand": "^5.0.5", + "nuqs": "^2.4.3", "@floating-ui/react": "^0.27.12", "react-table": "^7.8.0", "react-datepicker": "^8.0.0", diff --git a/package.json.js b/package.json.js index 9f31b395..071487ef 100644 --- a/package.json.js +++ b/package.json.js @@ -176,6 +176,7 @@ const packageConfig = { 'react-error-boundary': '^6.0.0', 'classnames': '^2.5.1', 'zustand': '^5.0.5', + 'nuqs': '^2.4.3', '@floating-ui/react': '^0.27.12', 'react-table': '^7.8.0', diff --git a/src/layouts/AppLayout/Breadcrumbs/Breadcrumbs.module.scss b/src/components/navigation/Breadcrumbs/Breadcrumbs.module.scss similarity index 100% rename from src/layouts/AppLayout/Breadcrumbs/Breadcrumbs.module.scss rename to src/components/navigation/Breadcrumbs/Breadcrumbs.module.scss diff --git a/src/layouts/AppLayout/Breadcrumbs/Breadcrumbs.stories.tsx b/src/components/navigation/Breadcrumbs/Breadcrumbs.stories.tsx similarity index 100% rename from src/layouts/AppLayout/Breadcrumbs/Breadcrumbs.stories.tsx rename to src/components/navigation/Breadcrumbs/Breadcrumbs.stories.tsx diff --git a/src/layouts/AppLayout/Breadcrumbs/Breadcrumbs.tsx b/src/components/navigation/Breadcrumbs/Breadcrumbs.tsx similarity index 96% rename from src/layouts/AppLayout/Breadcrumbs/Breadcrumbs.tsx rename to src/components/navigation/Breadcrumbs/Breadcrumbs.tsx index ded5b974..099f8e1d 100644 --- a/src/layouts/AppLayout/Breadcrumbs/Breadcrumbs.tsx +++ b/src/components/navigation/Breadcrumbs/Breadcrumbs.tsx @@ -114,7 +114,7 @@ type BreadcrumbsProps = React.PropsWithChildren & { }>; /** - * An ordered set of links forming the path of the current page. + * Breadcrumbs component, displaying an ordered set of links forming the path of the current location of the user. */ export const Breadcrumbs = Object.assign( (props: BreadcrumbsProps) => { diff --git a/src/components/navigation/Stepper/Stepper.module.scss b/src/components/navigation/Stepper/Stepper.module.scss new file mode 100644 index 00000000..fc3f50f0 --- /dev/null +++ b/src/components/navigation/Stepper/Stepper.module.scss @@ -0,0 +1,182 @@ +/* Copyright (c) Fortanix, Inc. +|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of +|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +@use '../../../styling/defs.scss' as bk; + +@layer baklava.components { + .bk-stepper { + @include bk.component-base(bk-stepper); + + --bk-stepper-indicator-size: #{bk.rem-from-px(28)}; + + > ol { + display: contents; + } + + display: grid; + + &.bk-stepper--vertical { + grid-auto-flow: row; + row-gap: bk.$spacing-9; + } + &.bk-stepper--horizontal { + grid-auto-flow: column; + column-gap: bk.$spacing-9; + } + + .bk-stepper__step { + cursor: pointer; + + display: list-item; // Needed for `counter(list-item)` + list-style: none; // Disable the default `::marker` + color: bk.$theme-stepper-text-disabled; + + // Note: we need this container element, since we cannot make the `li` a flex container without also losing the + // `list-item` counter (including things like `
  • ` which is hard to replicate in CSS). + .bk-stepper__step__action { + // display: grid; + // place-content: center; + + display: flex; + align-items: center; + gap: bk.$spacing-3; + + .bk-stepper__step__indicator { + // Render as circle + flex-shrink: 0; + aspect-ratio: 1; + inline-size: var(--bk-stepper-indicator-size); + border: bk.$size-2 solid #{bk.$theme-stepper-border-disabled}; + border-radius: 50%; + + font-weight: bk.$font-weight-bold; + font-size: bk.$font-size-m; + + display: grid; + place-content: center; + + &:empty::before { + content: counter(list-item); + } + + .bk-stepper__step__indicator__icon { --keep: ; } + } + + .bk-stepper__step__label { --keep: ; } + } + + // The currently active step + &[aria-current="step"] { + .bk-stepper__step__action { + color: bk.$theme-stepper-text-selected; + + .bk-stepper__step__indicator { + border-color: bk.$theme-stepper-border-default; + background-color: bk.$theme-stepper-background-default; + color: bk.$theme-stepper-text-selected-number; + } + } + } + } + + + + + /* + --bk-stepper-indicator-size: #{bk.rem-from-px(28)}; + display: flex; + + > ol { + display: contents; + } + + &.bk-stepper--horizontal { + flex-direction: row; + column-gap: bk.$spacing-9; + } + + &.bk-stepper--vertical { + flex-direction: column; + row-gap: bk.$spacing-9; + + // Draw a line between subsequent items + li + li .bk-stepper__step__indicator { + &::before { + content: ''; + position: absolute; + inset-block-start: calc(-1 * bk.$spacing-9 - bk.$size-2); + inset-inline-start: calc(50% - bk.$size-2 / 2); + inline-size: 0; + block-size: bk.$spacing-9; + border-inline-start: bk.$size-2 solid bk.$theme-stepper-border-disabled; + } + } + } + + .bk-stepper__step { + cursor: pointer; + + display: flex; + align-items: center; + color: bk.$theme-stepper-text-disabled; + + .bk-stepper__step__indicator { + position: relative; // Needed for the vertical line `position: absolute` + flex-shrink: 0; + + margin-inline-end: bk.$spacing-3; + aspect-ratio: 1; + inline-size: var(--bk-stepper-indicator-size); + + border: bk.$size-2 solid #{bk.$theme-stepper-border-disabled}; + border-radius: 50%; + font-weight: bk.$font-weight-bold; + font-size: bk.$font-size-m; + + display: grid; + place-content: center; + + .bk-stepper__step__indicator__icon { + font-size: bk.$font-size-xs; + } + } + + .bk-stepper__step__title { + font-size: bk.$font-size-m; + } + + .bk-stepper__step__optional { + margin-inline-start: bk.$spacing-2; + font-size: bk.$font-size-xs; + } + } + + // Any steps we've already visited + .bk-stepper__step--checked { + color: bk.$theme-stepper-text-selected; + + .bk-stepper__step__indicator { + border-color: bk.$theme-stepper-border-default; + } + } + + // The currently active step + [aria-current="true"] { + .bk-stepper__step { + color: bk.$theme-stepper-text-selected; + + .bk-stepper__step__indicator { + border-color: bk.$theme-stepper-border-default; + background-color: bk.$theme-stepper-background-default; + color: bk.$theme-stepper-text-selected-number; + } + } + } + + .bk-stepper__step--disabled { + cursor: not-allowed; + } + */ + } +} diff --git a/src/components/navigations/Stepper/Stepper.stories.tsx b/src/components/navigation/Stepper/Stepper.stories.tsx similarity index 68% rename from src/components/navigations/Stepper/Stepper.stories.tsx rename to src/components/navigation/Stepper/Stepper.stories.tsx index 51970c97..b0b3dcc9 100644 --- a/src/components/navigations/Stepper/Stepper.stories.tsx +++ b/src/components/navigation/Stepper/Stepper.stories.tsx @@ -2,11 +2,11 @@ |* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of |* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import type { Meta, StoryObj } from '@storybook/react'; - import * as React from 'react'; -import { type Step, Stepper } from './Stepper.tsx'; +import type { Meta, StoryObj } from '@storybook/react'; + +import { Stepper } from './Stepper.tsx'; type StepperArgs = React.ComponentProps; @@ -15,15 +15,43 @@ type Story = StoryObj; export default { component: Stepper, parameters: { - layout: 'padded', + layout: 'centered', }, tags: ['autodocs'], argTypes: { }, - args: {}, + args: { + stepperKey: `test-stepper`, + label: 'Test stepper', + children: ( + <> + {Array.from({ length: 3 }, (_, i) => i).map(index => + + )} + + ), + }, render: (args) => , } satisfies Meta; + +export const StepperStandard: Story = {}; + +export const StepperWithCustomCounts: Story = { + args: { + stepperKey: `test-stepper-with-custom-counts`, + start: 5, // Start at 5 (instead of 1) + children: ( + <> + + {/* Override count */} + {/* Subsequent steps continue from the previous count */} + + ), + }, +}; + +/* const defaultSteps: Array = [1,2,3,4].map(index => { return { stepKey: `${index}`, @@ -56,7 +84,7 @@ export const StepperStandard: Story = { args: { ...BaseStory.args }, }; -/** A step may be disabled. In this case, it will not be clickable. */ +/** A step may be disabled. In this case, it will not be clickable. * / export const StepperWithDisabledStep: Story = { ...BaseStory, args: { @@ -77,3 +105,4 @@ export const StepperHorizontal: Story = { direction: 'horizontal', }, }; +*/ diff --git a/src/components/navigation/Stepper/Stepper.tsx b/src/components/navigation/Stepper/Stepper.tsx new file mode 100644 index 00000000..95bbbebd --- /dev/null +++ b/src/components/navigation/Stepper/Stepper.tsx @@ -0,0 +1,278 @@ +/* Copyright (c) Fortanix, Inc. +|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of +|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import * as React from 'react'; +import { type ComponentProps, classNames as cx } from '../../../util/componentUtil.ts'; +import { + type ParserMap, + type UseQueryStateReturn, + useQueryState, + createSerializer, + parseAsString, +} from 'nuqs'; + +import { Icon } from '../../graphics/Icon/Icon.tsx'; +import { Link } from '../../actions/Link/Link.tsx'; + +import cl from './Stepper.module.scss'; + + +/* +References: +- [WAI-multi-page] https://www.w3.org/WAI/tutorials/forms/multi-page/#using-step-by-step-indicator +- [MDN-aria-current] https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-current +- [SO-1] https://stackoverflow.com/questions/52932018/making-a-step-progress-indicator-accessible-for-screen-readers +- https://www.aditus.io/aria/aria-current +- https://www.telerik.com/design-system/docs/components/stepper/accessibility +- https://cauldron.dequelabs.com/components/Stepper + +Accessibility notes: +- Should be structured as an `
      ` with a list of links. +- Should be wrapped inside a `