From c3da6a83f6892b3b348f6f649d6f18846357026f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Fri, 28 Nov 2025 15:45:15 +0100 Subject: [PATCH 01/43] WIP FormSteps --- packages/react/src/content/content.tsx | 13 +- .../src/form-steps/form-steps.stories.tsx | 323 ++++++++++++++++++ packages/react/src/form-steps/form-steps.tsx | 76 +++++ packages/react/src/form-steps/index.ts | 1 + packages/react/src/link/link.tsx | 7 +- packages/react/src/translations.ts | 10 + packages/tailwind/tailwind-base.css | 105 ++++++ 7 files changed, 532 insertions(+), 3 deletions(-) create mode 100644 packages/react/src/form-steps/form-steps.stories.tsx create mode 100644 packages/react/src/form-steps/form-steps.tsx create mode 100644 packages/react/src/form-steps/index.ts diff --git a/packages/react/src/content/content.tsx b/packages/react/src/content/content.tsx index 6a52feddd..f47a73b45 100644 --- a/packages/react/src/content/content.tsx +++ b/packages/react/src/content/content.tsx @@ -1,6 +1,11 @@ import { cva, cx, type VariantProps } from 'cva'; import { createContext, type HTMLProps, type Ref } from 'react'; -import { type ContextValue, useContextProps } from 'react-aria-components'; +import { + type ContextValue, + Text as RACText, + type TextProps as RACTextProps, + useContextProps, +} from 'react-aria-components'; type HeadingProps = Omit, 'size'> & VariantProps & { @@ -150,6 +155,10 @@ const Caption = ({ className, ...restProps }: CaptionProps) => ( const Footer = (props: FooterProps) =>
; +type TextProps = RACTextProps; + +const Text = (props: TextProps) => ; + export { Caption, Content, @@ -159,9 +168,11 @@ export { HeadingContext, Media, MediaContext, + Text, type CaptionProps, type ContentProps, type FooterProps, type HeadingProps, type MediaProps, + type TextProps, }; diff --git a/packages/react/src/form-steps/form-steps.stories.tsx b/packages/react/src/form-steps/form-steps.stories.tsx new file mode 100644 index 000000000..479a57ef9 --- /dev/null +++ b/packages/react/src/form-steps/form-steps.stories.tsx @@ -0,0 +1,323 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Text } from '../content'; +import { UNSAFE_Link as Link } from '../link'; +import { UNSAFE_ProgressBar as ProgressBar } from '../progress-bar'; +import { UNSAFE_FormStep as FormStep, UNSAFE_FormSteps as FormSteps } from './'; + +const meta: Meta = { + title: 'FormSteps', + component: FormSteps, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + + Personalia + + + + Kontaktinformasjon + + + + Fakturainformasjon + + + + Samtykke + + + + Betalingsinformasjon + + + + Leveringsadresse + + + + Bekrefelse + + + + Oppsummering + + + ), +}; + +export const TwoCompleted: Story = { + render: () => ( + + + Personalia + + + + Kontaktinformasjon + + + + Fakturainformasjon + + + + Samtykke + + + + Betalingsinformasjon + + + + Leveringsadresse + + + + Bekrefelse + + + + Oppsummering + + + ), +}; + +export const ThreeCompleted: Story = { + render: () => ( + + + Personalia + + + + Kontaktinformasjon + + + + Fakturainformasjon + + + + Samtykke + + + + Betalingsinformasjon + + + + Leveringsadresse + + + + Bekrefelse + + + + Oppsummering + + + ), +}; + +export const FourCompleted: Story = { + render: () => ( + + + Personalia + + + + Kontaktinformasjon + + + + Fakturainformasjon + + + + Samtykke + + + + Betalingsinformasjon + + + + Leveringsadresse + + + + Bekrefelse + + + + Oppsummering + + + ), +}; + +export const FiveCompleted: Story = { + render: () => ( + + + Personalia + + + + Kontaktinformasjon + + + + Fakturainformasjon + + + + Samtykke + + + + Betalingsinformasjon + + + + Leveringsadresse + + + + Bekrefelse + + + + Oppsummering + + + ), +}; + +export const SixCompleted: Story = { + render: () => ( + + + Personalia + + + + Kontaktinformasjon + + + + Fakturainformasjon + + + + Samtykke + + + + Betalingsinformasjon + + + + Leveringsadresse + + + + Bekrefelse + + + + Oppsummering + + + ), +}; + +export const SevenCompleted: Story = { + render: () => ( + + + Personalia + + + + Kontaktinformasjon + + + + Fakturainformasjon + + + + Samtykke + + + + Betalingsinformasjon + + + + Leveringsadresse + + + + Bekrefelse + + + + Oppsummering + + + + ), +}; + +export const AllCompleted: Story = { + render: () => ( + + + Personalia + + + + Kontaktinformasjon + + + + Fakturainformasjon + + + + Samtykke + + + + Betalingsinformasjon + + + + Leveringsadresse + + + + Bekrefelse + + + + Oppsummering + + + + ), +}; diff --git a/packages/react/src/form-steps/form-steps.tsx b/packages/react/src/form-steps/form-steps.tsx new file mode 100644 index 000000000..11abac975 --- /dev/null +++ b/packages/react/src/form-steps/form-steps.tsx @@ -0,0 +1,76 @@ +import { Check } from '@obosbbl/grunnmuren-icons-react'; +import { type HTMLAttributes, type HTMLProps, type JSX, useId } from 'react'; +import { ProgressBarContext, Provider } from 'react-aria-components'; +import { _LinkContext } from '../link'; +import { translations } from '../translations'; +import { useLocale } from '../use-locale'; + +type FormStepProps = HTMLProps & { + /** + * Indicates whether the step is completed, current or pending. + * @default 'pending' + */ + state?: 'completed' | 'current' | 'pending'; +}; + +const FormStep = ({ + state = 'pending', + children, + ...restProps +}: FormStepProps) => { + const locale = useLocale(); + const id = useId(); + return ( +
  • + + {state === 'completed' && ( + + )} + {children} + +
  • + ); +}; + +type FormStepsProps = HTMLAttributes & { + /** 3-8 children */ + children: [ + JSX.Element, + JSX.Element, + JSX.Element, + JSX.Element?, + JSX.Element?, + JSX.Element?, + JSX.Element?, + JSX.Element?, + ]; +}; + +const FormSteps = (props: FormStepsProps) => { + const locale = useLocale(); + const ariaLabel = props['aria-label'] || translations.formSteps[locale]; + return
      ; +}; + +export { + FormStep as UNSAFE_FormStep, + FormSteps as UNSAFE_FormSteps, + type FormStepProps as UNSAFE_FormStepProps, + type FormStepsProps as UNSAFE_FormStepsProps, +}; diff --git a/packages/react/src/form-steps/index.ts b/packages/react/src/form-steps/index.ts new file mode 100644 index 000000000..93133290c --- /dev/null +++ b/packages/react/src/form-steps/index.ts @@ -0,0 +1 @@ +export * from './form-steps'; diff --git a/packages/react/src/link/link.tsx b/packages/react/src/link/link.tsx index b71b32c28..aa9d29896 100644 --- a/packages/react/src/link/link.tsx +++ b/packages/react/src/link/link.tsx @@ -1,5 +1,5 @@ import { cx } from 'cva'; -import { createContext } from 'react'; +import { createContext, type HTMLProps } from 'react'; import { Link as _Link, type LinkProps as _LinkProps, @@ -16,7 +16,10 @@ type LinkProps = _LinkProps & }; const _LinkContext = createContext< - ContextValue, HTMLAnchorElement> + ContextValue< + Partial>, + HTMLAnchorElement + > >({}); const Link = ({ ref: _ref, ..._props }: LinkProps) => { diff --git a/packages/react/src/translations.ts b/packages/react/src/translations.ts index cf0c3ea83..11454470e 100644 --- a/packages/react/src/translations.ts +++ b/packages/react/src/translations.ts @@ -44,6 +44,16 @@ const translations: Translations = { sv: '(extern länk)', en: '(external link)', }, + formSteps: { + nb: 'Stegindikator', + sv: 'Stegindikator', + en: 'Form steps', + }, + completed: { + nb: 'Fullført', + sv: 'Slutförd', + en: 'Completed', + }, }; export { translations, type Translation, type Translations }; diff --git a/packages/tailwind/tailwind-base.css b/packages/tailwind/tailwind-base.css index dc3aa6aab..5f5e4179e 100644 --- a/packages/tailwind/tailwind-base.css +++ b/packages/tailwind/tailwind-base.css @@ -336,4 +336,109 @@ @apply motion-safe:-translate-y-0.5 motion-safe:translate-x-0.5; } } + + .form-steps, + [data-slot="form-steps"] { + counter-reset: form-step-counter; + @apply flex gap-x-3 lg:flex-col lg:gap-y-14 max-w-full min-w-0 max-lg:pt-9; + + &:has([data-slot="form-step"]:nth-child(5)) { + [data-state="pending"] { + &:nth-child(n + 4) { + &:not(:nth-child(4), :nth-child(8)) { + @apply max-sm:-ml-9; + } + &:nth-child(8) { + @apply max-sm:-ml-5.5; + } + [data-slot="progress-bar"] { + @apply max-sm:sr-only; + } + } + &:nth-child(4)::before { + @apply max-sm:z-4; + } + &:nth-child(5)::before { + @apply max-sm:z-3; + } + &:nth-child(6)::before { + @apply max-sm:z-2; + } + &:nth-child(7)::before { + @apply max-sm:z-1; + } + } + } + + [data-slot="form-step"] { + counter-increment: form-step-counter; + @apply flex items-center gap-x-3 relative; + + &::before { + content: counter(form-step-counter); + @apply shrink-0 font-semibold size-8 border-2 rounded-full border-blue-dark grid place-content-center; + } + + /** Place the focus styles on the ::before element instead of the link, since the link text is not visible on smaller screens */ + &:has([data-slot="link"]:focus-visible) { + &::before { + @apply outline-focus-offset; + } + } + + [data-slot="link"] { + @apply focus-visible:outline-none; + @apply after:absolute after:top-0 after:left-0 after:bottom-0 after:right-0; + } + + &[data-state="current"], + &[data-state="completed"] { + &::before { + @apply bg-blue-dark text-white; + } + } + + &[data-state="completed"] { + svg { + @apply text-white absolute scale-60 border-2 border-white bg-blue-dark rounded-full left-4 -top-2; + } + } + + &[data-state="pending"]::before { + @apply bg-white text-blue-dark; + } + + &:not(:last-child) { + [data-slot="progress-bar"] { + @apply w-4 max-lg:grow lg:w-14 lg:rotate-90 lg:absolute lg:-left-3 lg:px-1.5 lg:-bottom-[calc(100%-2px)]; + } + + &[data-state="pending"] { + @apply max-lg:shrink; + [data-slot="progress-bar"] { + @apply max-lg:shrink; + } + } + } + + &:not([data-state="current"]) + > *:is([data-slot="text"], [data-slot="link"]) { + @apply max-lg:opacity-0; + } + + & > *:is([data-slot="text"], [data-slot="link"]) { + @apply max-lg:absolute; + } + + &[data-state="current"] { + @apply max-lg:justify-center; + & > *:is([data-slot="text"], [data-slot="link"]) { + @apply max-lg:-top-full; + &::after { + @apply max-lg:-bottom-[200%] max-lg:w-9 max-lg:ml-9.5; + } + } + } + } + } } From ccb25ae432ca9caffa546f026d31c31d428127e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Fri, 28 Nov 2025 15:45:27 +0100 Subject: [PATCH 02/43] Add links --- packages/react/src/form-steps/form-steps.stories.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/react/src/form-steps/form-steps.stories.tsx b/packages/react/src/form-steps/form-steps.stories.tsx index 479a57ef9..70ee1900d 100644 --- a/packages/react/src/form-steps/form-steps.stories.tsx +++ b/packages/react/src/form-steps/form-steps.stories.tsx @@ -66,7 +66,7 @@ export const TwoCompleted: Story = { - Fakturainformasjon + Fakturainformasjon @@ -108,7 +108,7 @@ export const ThreeCompleted: Story = { - Samtykke + Samtykke @@ -150,7 +150,7 @@ export const FourCompleted: Story = { - Betalingsinformasjon + Betalingsinformasjon @@ -192,7 +192,7 @@ export const FiveCompleted: Story = { - Leveringsadresse + Leveringsadresse @@ -234,7 +234,7 @@ export const SixCompleted: Story = { - Bekrefelse + Bekrefelse @@ -276,7 +276,7 @@ export const SevenCompleted: Story = { - Oppsummering + Oppsummering From 36a601057146143ea641f14d45171396bb20e4c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Tue, 2 Dec 2025 09:58:37 +0100 Subject: [PATCH 03/43] WIP: Disclosure in list --- packages/react/src/disclosure/disclosure.tsx | 7 +- .../src/form-steps/form-steps.stories.tsx | 40 ------ packages/react/src/form-steps/form-steps.tsx | 74 ++++++++++- .../react/src/progress-bar/progress-bar.tsx | 2 +- packages/tailwind/tailwind-base.css | 121 +++++++++++++----- 5 files changed, 163 insertions(+), 81 deletions(-) diff --git a/packages/react/src/disclosure/disclosure.tsx b/packages/react/src/disclosure/disclosure.tsx index 071e391e8..9248d106a 100644 --- a/packages/react/src/disclosure/disclosure.tsx +++ b/packages/react/src/disclosure/disclosure.tsx @@ -104,15 +104,16 @@ export const DisclosureStateContext = createContext( null, ); -const Disclosure = ({ ref: _ref, children, ..._props }: DisclosureProps) => { +const Disclosure = ({ ref: _ref, ..._props }: DisclosureProps) => { const [props, ref] = useContextProps( _props, _ref as ForwardedRef, DisclosureContext, ); + const groupState = useContext(DisclosureGroupStateContext); - let { id, ...otherProps } = props; + let { id, children, ...otherProps } = props; const defaultId = useId(); id ||= defaultId; const isExpanded = groupState @@ -176,6 +177,7 @@ const Disclosure = ({ ref: _ref, children, ..._props }: DisclosureProps) => { data-focus-visible-within={isFocusVisibleWithin || undefined} data-expanded={state.isExpanded || undefined} data-disabled={isDisabled || undefined} + data-slot="disclosure" > {typeof children === 'function' ? children({ @@ -229,6 +231,7 @@ const DisclosurePanel = ({ ref, children, ...props }: DisclosurePanelProps) => { : 'grid-rows-[0fr]', )} data-expanded={disclosureContext?.isExpanded || undefined} + data-slot="disclosure-panel" >
      {/* biome-ignore lint/a11y/useAriaPropsSupportedByRole: TODO: fix this */} diff --git a/packages/react/src/form-steps/form-steps.stories.tsx b/packages/react/src/form-steps/form-steps.stories.tsx index 70ee1900d..c542d854d 100644 --- a/packages/react/src/form-steps/form-steps.stories.tsx +++ b/packages/react/src/form-steps/form-steps.stories.tsx @@ -277,46 +277,6 @@ export const SevenCompleted: Story = { Oppsummering - - - - ), -}; - -export const AllCompleted: Story = { - render: () => ( - - - Personalia - - - - Kontaktinformasjon - - - - Fakturainformasjon - - - - Samtykke - - - - Betalingsinformasjon - - - - Leveringsadresse - - - - Bekrefelse - - - - Oppsummering - ), diff --git a/packages/react/src/form-steps/form-steps.tsx b/packages/react/src/form-steps/form-steps.tsx index 11abac975..643f342a5 100644 --- a/packages/react/src/form-steps/form-steps.tsx +++ b/packages/react/src/form-steps/form-steps.tsx @@ -1,6 +1,20 @@ import { Check } from '@obosbbl/grunnmuren-icons-react'; -import { type HTMLAttributes, type HTMLProps, type JSX, useId } from 'react'; -import { ProgressBarContext, Provider } from 'react-aria-components'; +import { + Children, + cloneElement, + type HTMLAttributes, + type HTMLProps, + isValidElement, + type JSX, + useId, +} from 'react'; +import { + Button, + DisclosureContext, + ProgressBarContext, + Provider, +} from 'react-aria-components'; +import { Disclosure, DisclosureButton, DisclosurePanel } from '../disclosure'; import { _LinkContext } from '../link'; import { translations } from '../translations'; import { useLocale } from '../use-locale'; @@ -42,6 +56,7 @@ const FormStep = ({ {state === 'completed' && ( )} + {restProps['data-n'] === 2 && } {children} @@ -62,10 +77,59 @@ type FormStepsProps = HTMLAttributes & { ]; }; -const FormSteps = (props: FormStepsProps) => { +const FormSteps = ({ children, ...restProps }: FormStepsProps) => { const locale = useLocale(); - const ariaLabel = props['aria-label'] || translations.formSteps[locale]; - return
        ; + const ariaLabel = restProps['aria-label'] || translations.formSteps[locale]; + const childrenArray = Children.toArray(children); + return ( +
          + + + +
            + {childrenArray.slice(1).map( + (child) => + isValidElement(child) && ( +
          1. + {(child.props as FormStepProps).children} +
          2. + ), + )} +
          +
          + + ), + }, + ], + ]} + > + {childrenArray.map( + (child, index) => + (childrenArray.length > 5 && + child && + typeof child === 'object' && + 'props' in child + ? { + ...child, + props: { ...(child.props ?? {}), 'data-n': index + 1 }, + } + : child) as JSX.Element, + )} +
          +
        + ); }; export { diff --git a/packages/react/src/progress-bar/progress-bar.tsx b/packages/react/src/progress-bar/progress-bar.tsx index e3c37ed5c..d6b3cbd28 100644 --- a/packages/react/src/progress-bar/progress-bar.tsx +++ b/packages/react/src/progress-bar/progress-bar.tsx @@ -52,7 +52,7 @@ const ProgressBar = ({ {typeof children === 'function' ? children({ percentage, valueText, ...restArgs }) : children} -
        +
        *:is([data-slot="text"], [data-slot="link"]) { @apply max-lg:-top-full; &::after { - @apply max-lg:-bottom-[200%] max-lg:w-9 max-lg:ml-9.5; + @apply max-lg:-bottom-[200%] max-lg:ml-9.5; + } + } + } + } + + [data-slot="disclosure"] { + @apply sm:hidden; + [data-slot="progress-bar"] { + @apply hidden; + } + + [data-slot="disclosure-panel"] { + @apply absolute left-0 right-0 pt-2 transition-none!; + [data-slot="form-steps-collapsable"] { + @apply grid gap-y-3 rounded-lg border p-4; + } + } + + [slot="trigger"] { + @apply focus-visible:outline-focus-offset; + } + } + + &:has([data-slot="form-step"]:nth-child(5)) { + &:has( + [data-slot="form-step"]:nth-child(5):is( + :not([data-state="completed"]) + ) + ) { + [data-state="pending"] { + &:nth-child(n + 4) { + &:not([data-state="current"] + *) { + @apply max-sm:-ml-9; + } + [data-slot="progress-bar"] { + @apply max-sm:sr-only; + } + } + &:nth-child(4)::before { + @apply max-sm:z-4; + } + &:nth-child(5)::before { + @apply max-sm:z-3; + } + &:nth-child(6)::before { + @apply max-sm:z-2; + } + &:nth-child(7)::before { + @apply max-sm:z-1; + } + } + } + + &:has([data-slot="form-step"]:nth-child(3):is([data-state="completed"])) { + [data-slot="form-step"] { + &:nth-child(2) { + @apply max-sm:static; + &::before, + & > *:not([data-slot="disclosure"], [data-slot="progress-bar"]) { + @apply max-sm:hidden!; + } + [data-slot="form-steps-collapsable"] { + [data-state="pending"] { + @apply hidden; + } + } + } + &:nth-child(3), + &:nth-child(4), + &:nth-child(5), + &:nth-child(6) { + &[data-state="completed"] { + @apply max-sm:invisible max-sm:absolute; + } } } } From c3010bc33cc5d699c96578cae5b0426f22ceadf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Tue, 2 Dec 2025 10:03:33 +0100 Subject: [PATCH 04/43] Remove added data-slot attributes --- packages/react/src/disclosure/disclosure.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/react/src/disclosure/disclosure.tsx b/packages/react/src/disclosure/disclosure.tsx index 9248d106a..11ad240ea 100644 --- a/packages/react/src/disclosure/disclosure.tsx +++ b/packages/react/src/disclosure/disclosure.tsx @@ -177,7 +177,6 @@ const Disclosure = ({ ref: _ref, ..._props }: DisclosureProps) => { data-focus-visible-within={isFocusVisibleWithin || undefined} data-expanded={state.isExpanded || undefined} data-disabled={isDisabled || undefined} - data-slot="disclosure" > {typeof children === 'function' ? children({ @@ -231,7 +230,6 @@ const DisclosurePanel = ({ ref, children, ...props }: DisclosurePanelProps) => { : 'grid-rows-[0fr]', )} data-expanded={disclosureContext?.isExpanded || undefined} - data-slot="disclosure-panel" >
        {/* biome-ignore lint/a11y/useAriaPropsSupportedByRole: TODO: fix this */} From 6175472c071ef7fe7838c399cf769e5232a8a849 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Tue, 2 Dec 2025 20:32:07 +0100 Subject: [PATCH 05/43] WIP --- packages/react/src/form-steps/form-steps.tsx | 105 ++++++++---------- packages/tailwind/tailwind-base.css | 110 ++++++++++++------- 2 files changed, 120 insertions(+), 95 deletions(-) diff --git a/packages/react/src/form-steps/form-steps.tsx b/packages/react/src/form-steps/form-steps.tsx index 643f342a5..965fd6e7d 100644 --- a/packages/react/src/form-steps/form-steps.tsx +++ b/packages/react/src/form-steps/form-steps.tsx @@ -1,20 +1,15 @@ import { Check } from '@obosbbl/grunnmuren-icons-react'; import { Children, - cloneElement, + createContext, type HTMLAttributes, type HTMLProps, - isValidElement, type JSX, + use, useId, + useState, } from 'react'; -import { - Button, - DisclosureContext, - ProgressBarContext, - Provider, -} from 'react-aria-components'; -import { Disclosure, DisclosureButton, DisclosurePanel } from '../disclosure'; +import { ProgressBarContext, Provider } from 'react-aria-components'; import { _LinkContext } from '../link'; import { translations } from '../translations'; import { useLocale } from '../use-locale'; @@ -27,6 +22,12 @@ type FormStepProps = HTMLProps & { state?: 'completed' | 'current' | 'pending'; }; +const _FormStepContext = createContext<{ isTogglableOnSmallScreens: boolean }>({ + isTogglableOnSmallScreens: false, +}); + +const _FormStepProvider = _FormStepContext.Provider; + const FormStep = ({ state = 'pending', children, @@ -34,8 +35,26 @@ const FormStep = ({ }: FormStepProps) => { const locale = useLocale(); const id = useId(); + const { isTogglableOnSmallScreens } = use(_FormStepContext); + + const [_isExpanded, _setIsExpanded] = useState( + isTogglableOnSmallScreens ? false : undefined, + ); + return ( -
      1. + // biome-ignore lint/a11y/useKeyWithClickEvents: The collapsed content is accessible through keyboard focus +
      2. { + if (isTogglableOnSmallScreens) { + _setIsExpanded((prevState) => !prevState); + } + }} + data-expanded={_isExpanded} + > )} - {restProps['data-n'] === 2 && } + {isTogglableOnSmallScreens && } {children}
      3. @@ -79,55 +98,25 @@ type FormStepsProps = HTMLAttributes & { const FormSteps = ({ children, ...restProps }: FormStepsProps) => { const locale = useLocale(); - const ariaLabel = restProps['aria-label'] || translations.formSteps[locale]; const childrenArray = Children.toArray(children); return ( -
          - - - -
            - {childrenArray.slice(1).map( - (child) => - isValidElement(child) && ( -
          1. - {(child.props as FormStepProps).children} -
          2. - ), - )} -
          -
          - - ), - }, - ], - ]} - > - {childrenArray.map( - (child, index) => - (childrenArray.length > 5 && - child && - typeof child === 'object' && - 'props' in child - ? { - ...child, - props: { ...(child.props ?? {}), 'data-n': index + 1 }, - } - : child) as JSX.Element, - )} -
          +
            + {childrenArray.map((child, index) => + index === 1 && childrenArray.length >= 5 ? ( + <_FormStepProvider + key={(child as JSX.Element).props.key} + value={{ isTogglableOnSmallScreens: true }} + > + {child} + + ) : ( + child + ), + )}
          ); }; diff --git a/packages/tailwind/tailwind-base.css b/packages/tailwind/tailwind-base.css index 81a2c777e..ebf4a95d2 100644 --- a/packages/tailwind/tailwind-base.css +++ b/packages/tailwind/tailwind-base.css @@ -346,12 +346,13 @@ counter-increment: form-step-counter; @apply flex items-center gap-x-3 relative; - &::before { - content: counter(form-step-counter, decimal); + &::before, + &[data-expanded="true"]::after { + content: counter(form-step-counter); } - [data-slot="disclosure"] [slot="trigger"], - &::before { + &::before, + &[data-expanded="true"]::after { @apply shrink-0 font-semibold size-8 border-2 rounded-full border-blue-dark grid place-content-center; } @@ -362,14 +363,15 @@ } } - [data-slot="link"]:not(:is(& [data-slot="disclosure"] *)) { + [data-slot="link"] { @apply focus-visible:outline-none; @apply after:absolute after:h-11 max-lg:after:w-11; } &[data-state="current"], &[data-state="completed"] { - &::before { + &::before, + &[data-expanded="true"]::after { @apply bg-blue-dark text-white; } } @@ -422,24 +424,6 @@ } } - [data-slot="disclosure"] { - @apply sm:hidden; - [data-slot="progress-bar"] { - @apply hidden; - } - - [data-slot="disclosure-panel"] { - @apply absolute left-0 right-0 pt-2 transition-none!; - [data-slot="form-steps-collapsable"] { - @apply grid gap-y-3 rounded-lg border p-4; - } - } - - [slot="trigger"] { - @apply focus-visible:outline-focus-offset; - } - } - &:has([data-slot="form-step"]:nth-child(5)) { &:has( [data-slot="form-step"]:nth-child(5):is( @@ -473,23 +457,75 @@ &:has([data-slot="form-step"]:nth-child(3):is([data-state="completed"])) { [data-slot="form-step"] { &:nth-child(2) { - @apply max-sm:static; - &::before, - & > *:not([data-slot="disclosure"], [data-slot="progress-bar"]) { - @apply max-sm:hidden!; + &::before { + /** Different content (alt text) for screen readers here, since we want to hide this from screen readers **/ + @apply max-sm:content-['...'_/_'']; } - [data-slot="form-steps-collapsable"] { - [data-state="pending"] { - @apply hidden; + } + } + + &:not( + :has( + [data-state="completed"]:not(:nth-child(1)):not( + :nth-last-child(2) + ):focus-within + ), + :has([data-slot="form-step"][data-expanded="true"]) + ) { + [data-slot="form-step"] { + &:nth-child(3), + &:nth-child(4), + &:nth-last-child(4), + &:nth-last-child(3) { + &[data-state="completed"] { + @apply max-sm:sr-only; } } } - &:nth-child(3), - &:nth-child(4), - &:nth-child(5), - &:nth-child(6) { - &[data-state="completed"] { - @apply max-sm:invisible max-sm:absolute; + } + + &:has( + [data-state="completed"]:not(:nth-child(1)):not( + :nth-last-child(2) + ):focus-within + ), + &:has([data-slot="form-step"][data-expanded="true"]) { + [data-slot="form-step"] { + &:nth-child(2), + &:nth-child(3), + &:nth-child(4), + &:nth-last-child(4), + &:nth-last-child(3) { + &[data-state="completed"] { + &:nth-child(2) { + &::after, + [data-slot="link"], + [data-slot="toggle-check-icon"] { + @apply max-sm:absolute max-sm:top-[125%]; + } + } + &:not(:nth-child(2)) { + @apply max-sm:absolute max-sm:left-4; + [data-slot="progress-bar"] { + @apply max-sm:sr-only; + } + } + [data-slot="link"] { + @apply max-sm:opacity-100 max-sm:static; + } + &:nth-child(3) { + @apply max-sm:top-[225%]; + } + &:nth-child(4) { + @apply max-sm:top-[325%]; + } + &:nth-child(5) { + @apply max-sm:top-[425%]; + } + &:nth-child(6) { + @apply max-sm:top-[525%]; + } + } } } } From a63c7fadfd50ffa67e14cf8ffa4c1c244daf083a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Wed, 3 Dec 2025 08:54:14 +0100 Subject: [PATCH 06/43] Toggle overflow list items on small screens with CSS only --- packages/react/src/form-steps/form-steps.tsx | 38 +++++++++-------- packages/tailwind/tailwind-base.css | 45 +++++++++++++++----- 2 files changed, 55 insertions(+), 28 deletions(-) diff --git a/packages/react/src/form-steps/form-steps.tsx b/packages/react/src/form-steps/form-steps.tsx index 965fd6e7d..956a59a9a 100644 --- a/packages/react/src/form-steps/form-steps.tsx +++ b/packages/react/src/form-steps/form-steps.tsx @@ -100,24 +100,26 @@ const FormSteps = ({ children, ...restProps }: FormStepsProps) => { const locale = useLocale(); const childrenArray = Children.toArray(children); return ( -
            - {childrenArray.map((child, index) => - index === 1 && childrenArray.length >= 5 ? ( - <_FormStepProvider - key={(child as JSX.Element).props.key} - value={{ isTogglableOnSmallScreens: true }} - > - {child} - - ) : ( - child - ), - )} -
          +
          +
            + {childrenArray.map((child, index) => + index === 1 && childrenArray.length >= 5 ? ( + <_FormStepProvider + key={(child as JSX.Element).props.key} + value={{ isTogglableOnSmallScreens: true }} + > + {child} + + ) : ( + child + ), + )} +
          +
          ); }; diff --git a/packages/tailwind/tailwind-base.css b/packages/tailwind/tailwind-base.css index ebf4a95d2..b14843022 100644 --- a/packages/tailwind/tailwind-base.css +++ b/packages/tailwind/tailwind-base.css @@ -337,22 +337,26 @@ } } + .form-steps-container, + [data-slot="form-steps-container"] { + @apply max-lg:pt-9; + } + .form-steps, [data-slot="form-steps"] { counter-reset: form-step-counter; - @apply flex gap-x-3 lg:flex-col lg:gap-y-14 max-w-full min-w-0 max-lg:pt-9 relative; + @apply flex gap-x-3 lg:flex-col lg:gap-y-14 max-w-full min-w-0 relative; [data-slot="form-step"] { counter-increment: form-step-counter; @apply flex items-center gap-x-3 relative; - &::before, - &[data-expanded="true"]::after { + &::before { content: counter(form-step-counter); } &::before, - &[data-expanded="true"]::after { + &[data-expanded]::after { @apply shrink-0 font-semibold size-8 border-2 rounded-full border-blue-dark grid place-content-center; } @@ -371,7 +375,7 @@ &[data-state="current"], &[data-state="completed"] { &::before, - &[data-expanded="true"]::after { + &[data-expanded]::after { @apply bg-blue-dark text-white; } } @@ -501,9 +505,30 @@ &::after, [data-slot="link"], [data-slot="toggle-check-icon"] { - @apply max-sm:absolute max-sm:top-[125%]; + @apply max-sm:absolute; + } + &::after { + @apply max-sm:-left-13.5 max-sm:top-15; + /** Makes this pseudo element visible, while also reflecting the order in the list */ + content: counter(form-step-counter); + } + /** Place the focus styles on the ::after element instead of the link, to match the rest of the list items */ + &:has([data-slot="link"]:focus-visible) { + &::after { + @apply outline-focus-offset; + } + } + &::before { + @apply outline-none; + } + [data-slot="link"] { + @apply max-sm:-left-3 max-sm:top-16; + } + [data-slot="toggle-check-icon"] { + @apply max-sm:-left-10 max-sm:z-1 max-sm:top-12.5; } } + &:not(:nth-child(2)) { @apply max-sm:absolute max-sm:left-4; [data-slot="progress-bar"] { @@ -514,16 +539,16 @@ @apply max-sm:opacity-100 max-sm:static; } &:nth-child(3) { - @apply max-sm:top-[225%]; + @apply max-sm:top-30; } &:nth-child(4) { - @apply max-sm:top-[325%]; + @apply max-sm:top-45; } &:nth-child(5) { - @apply max-sm:top-[425%]; + @apply max-sm:top-60; } &:nth-child(6) { - @apply max-sm:top-[525%]; + @apply max-sm:top-75; } } } From b0b55f0481e8b6066e7e85f6fbf7b35dfe49d368 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Wed, 3 Dec 2025 10:09:03 +0100 Subject: [PATCH 07/43] Fix list item count alt text --- packages/tailwind/tailwind-base.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/tailwind/tailwind-base.css b/packages/tailwind/tailwind-base.css index b14843022..a1e7c844f 100644 --- a/packages/tailwind/tailwind-base.css +++ b/packages/tailwind/tailwind-base.css @@ -462,8 +462,8 @@ [data-slot="form-step"] { &:nth-child(2) { &::before { - /** Different content (alt text) for screen readers here, since we want to hide this from screen readers **/ - @apply max-sm:content-['...'_/_'']; + /** Different content (alt text) for screen readers here, since we want to render this a regular step 2 for screen readers **/ + @apply max-sm:content-['...'_/_'2']; } } } @@ -509,8 +509,8 @@ } &::after { @apply max-sm:-left-13.5 max-sm:top-15; - /** Makes this pseudo element visible, while also reflecting the order in the list */ - content: counter(form-step-counter); + /** Makes this pseudo element visible, while also reflecting the order in the list, while hiding it for screen reader users. This is because the list item counter is reflected in the ::before element for screen readers */ + @apply max-sm:content-[counter(form-step-counter)_/_'']; } /** Place the focus styles on the ::after element instead of the link, to match the rest of the list items */ &:has([data-slot="link"]:focus-visible) { From 9eb2443b7b236667c4d326dce7170ea78232f7d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Wed, 3 Dec 2025 12:27:04 +0100 Subject: [PATCH 08/43] Fix form steps popover styles --- packages/tailwind/tailwind-base.css | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/tailwind/tailwind-base.css b/packages/tailwind/tailwind-base.css index a1e7c844f..8e14c090f 100644 --- a/packages/tailwind/tailwind-base.css +++ b/packages/tailwind/tailwind-base.css @@ -508,7 +508,7 @@ @apply max-sm:absolute; } &::after { - @apply max-sm:-left-13.5 max-sm:top-15; + @apply max-sm:-left-14 max-sm:top-15 max-sm:z-1; /** Makes this pseudo element visible, while also reflecting the order in the list, while hiding it for screen reader users. This is because the list item counter is reflected in the ::before element for screen readers */ @apply max-sm:content-[counter(form-step-counter)_/_'']; } @@ -522,15 +522,23 @@ @apply outline-none; } [data-slot="link"] { - @apply max-sm:-left-3 max-sm:top-16; + @apply max-sm:-left-3 max-sm:top-16 max-sm:z-1; } [data-slot="toggle-check-icon"] { - @apply max-sm:-left-10 max-sm:z-1 max-sm:top-12.5; + @apply max-sm:-left-10 max-sm:z-2 max-sm:top-12.5; } } &:not(:nth-child(2)) { - @apply max-sm:absolute max-sm:left-4; + @apply max-sm:absolute max-sm:left-4 max-sm:w-full; + & > *:is([data-slot="text"], [data-slot="link"]) { + @apply max-sm:relative; + } + & > *:is([data-slot="text"], [data-slot="link"], svg), + &::before { + @apply max-sm:z-1; + } + @apply max-sm:after:absolute max-sm:after:-left-4 max-sm:after:right-0 max-sm:after:-top-[250%] max-sm:after:-bottom-[100%] max-sm:after:bg-white max-sm:after:border-x max-sm:after:border-b max-sm:after:rounded-b-lg; [data-slot="progress-bar"] { @apply max-sm:sr-only; } @@ -551,6 +559,9 @@ @apply max-sm:top-75; } } + &:nth-child(3) { + @apply max-sm:after:border-t max-sm:after:rounded-t-lg; + } } } } From 12ec8de0d4c87c496e9c72b6897bc7579839f1ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Wed, 3 Dec 2025 12:29:19 +0100 Subject: [PATCH 09/43] Hide text for current step on small screens --- packages/tailwind/tailwind-base.css | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/tailwind/tailwind-base.css b/packages/tailwind/tailwind-base.css index 8e14c090f..6c71b865d 100644 --- a/packages/tailwind/tailwind-base.css +++ b/packages/tailwind/tailwind-base.css @@ -408,8 +408,7 @@ } } - &:not([data-state="current"]) - > *:is([data-slot="text"], [data-slot="link"]) { + & > *:is([data-slot="text"], [data-slot="link"]) { @apply max-lg:opacity-0; } From e3ff75876b1089e61e065a3c18267b34a75b3301 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Wed, 3 Dec 2025 20:13:50 +0100 Subject: [PATCH 10/43] Responsive layout bug fixes --- .../src/form-steps/form-steps.stories.tsx | 44 +++++++++++++++++-- packages/tailwind/tailwind-base.css | 10 +++-- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/packages/react/src/form-steps/form-steps.stories.tsx b/packages/react/src/form-steps/form-steps.stories.tsx index c542d854d..f520bfa96 100644 --- a/packages/react/src/form-steps/form-steps.stories.tsx +++ b/packages/react/src/form-steps/form-steps.stories.tsx @@ -17,6 +17,44 @@ export default meta; type Story = StoryObj; export const Default: Story = { + render: () => ( + + + Personalia + + + + Kontaktinformasjon + + + + Fakturainformasjon + + + + Samtykke + + + + Betalingsinformasjon + + + + Leveringsadresse + + + + Bekrefelse + + + + Oppsummering + + + ), +}; + +export const OneCompleted: Story = { render: () => ( @@ -25,11 +63,11 @@ export const Default: Story = { Kontaktinformasjon - + - Fakturainformasjon - + Fakturainformasjon + Samtykke diff --git a/packages/tailwind/tailwind-base.css b/packages/tailwind/tailwind-base.css index 6c71b865d..b35e0c70c 100644 --- a/packages/tailwind/tailwind-base.css +++ b/packages/tailwind/tailwind-base.css @@ -427,15 +427,19 @@ } } + [data-slot="toggle-check-icon"] { + @apply hidden; + } + &:has([data-slot="form-step"]:nth-child(5)) { &:has( - [data-slot="form-step"]:nth-child(5):is( + [data-slot="form-step"]:nth-child(6):is( :not([data-state="completed"]) ) ) { [data-state="pending"] { &:nth-child(n + 4) { - &:not([data-state="current"] + *) { + &:not([data-state="current"] + *, :nth-child(4)) { @apply max-sm:-ml-9; } [data-slot="progress-bar"] { @@ -524,7 +528,7 @@ @apply max-sm:-left-3 max-sm:top-16 max-sm:z-1; } [data-slot="toggle-check-icon"] { - @apply max-sm:-left-10 max-sm:z-2 max-sm:top-12.5; + @apply max-sm:-left-10 max-sm:z-2 max-sm:top-12.5 max-sm:block; } } From 27ab5f791fb449eecd22d86c31780f4294d88f81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Wed, 3 Dec 2025 20:47:59 +0100 Subject: [PATCH 11/43] Adjust "padding" on the form steps tooltip --- packages/tailwind/tailwind-base.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tailwind/tailwind-base.css b/packages/tailwind/tailwind-base.css index b35e0c70c..47573d65e 100644 --- a/packages/tailwind/tailwind-base.css +++ b/packages/tailwind/tailwind-base.css @@ -541,7 +541,7 @@ &::before { @apply max-sm:z-1; } - @apply max-sm:after:absolute max-sm:after:-left-4 max-sm:after:right-0 max-sm:after:-top-[250%] max-sm:after:-bottom-[100%] max-sm:after:bg-white max-sm:after:border-x max-sm:after:border-b max-sm:after:rounded-b-lg; + @apply max-sm:after:absolute max-sm:after:-left-4 max-sm:after:right-0 max-sm:after:-top-[calc(200%+var(--spacing)*4)] max-sm:after:-bottom-4 max-sm:after:bg-white max-sm:after:border-x max-sm:after:border-b max-sm:after:rounded-b-lg; [data-slot="progress-bar"] { @apply max-sm:sr-only; } From 1933fb92d236e96e8a6fbafe7320ec5da77816c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Wed, 3 Dec 2025 20:50:06 +0100 Subject: [PATCH 12/43] Adjust "tooltip" witdth on form steps --- packages/tailwind/tailwind-base.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tailwind/tailwind-base.css b/packages/tailwind/tailwind-base.css index 47573d65e..7a1744ee9 100644 --- a/packages/tailwind/tailwind-base.css +++ b/packages/tailwind/tailwind-base.css @@ -541,7 +541,7 @@ &::before { @apply max-sm:z-1; } - @apply max-sm:after:absolute max-sm:after:-left-4 max-sm:after:right-0 max-sm:after:-top-[calc(200%+var(--spacing)*4)] max-sm:after:-bottom-4 max-sm:after:bg-white max-sm:after:border-x max-sm:after:border-b max-sm:after:rounded-b-lg; + @apply max-sm:after:absolute max-sm:after:-left-4 max-sm:after:right-4 max-sm:after:-top-[calc(200%+var(--spacing)*4)] max-sm:after:-bottom-4 max-sm:after:bg-white max-sm:after:border-x max-sm:after:border-b max-sm:after:rounded-b-lg; [data-slot="progress-bar"] { @apply max-sm:sr-only; } From ab9c99e90355601e4c03468b88859d0e7d81c9c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Wed, 3 Dec 2025 20:53:02 +0100 Subject: [PATCH 13/43] Highlight current form step --- packages/tailwind/tailwind-base.css | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/tailwind/tailwind-base.css b/packages/tailwind/tailwind-base.css index 7a1744ee9..ebea21386 100644 --- a/packages/tailwind/tailwind-base.css +++ b/packages/tailwind/tailwind-base.css @@ -372,7 +372,12 @@ @apply after:absolute after:h-11 max-lg:after:w-11; } - &[data-state="current"], + &[data-state="current"] { + &::before { + @apply bg-sky-light; + } + } + &[data-state="completed"] { &::before, &[data-expanded]::after { From 017975e7da8ce9e64350be7a3f5bf3620e779aab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Thu, 4 Dec 2025 08:20:00 +0100 Subject: [PATCH 14/43] Fix hidden form step when 5 items --- packages/tailwind/tailwind-base.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/tailwind/tailwind-base.css b/packages/tailwind/tailwind-base.css index ebea21386..fb46ed263 100644 --- a/packages/tailwind/tailwind-base.css +++ b/packages/tailwind/tailwind-base.css @@ -487,7 +487,9 @@ [data-slot="form-step"] { &:nth-child(3), &:nth-child(4), - &:nth-last-child(4), + /** Impotant to exclude if the second child is the same as the 4th from the back, i.e. 5 items */ + /** This is because the second child is used as a disclosure toggle on smaller screens.*/ + &:nth-last-child(4):not(:nth-child(2)), &:nth-last-child(3) { &[data-state="completed"] { @apply max-sm:sr-only; From d4e474adb8918ea95a0cdada334076840757dec1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Thu, 4 Dec 2025 08:32:49 +0100 Subject: [PATCH 15/43] Fix responsive bug for 5 form steps --- packages/tailwind/tailwind-base.css | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/tailwind/tailwind-base.css b/packages/tailwind/tailwind-base.css index fb46ed263..ba3cfe528 100644 --- a/packages/tailwind/tailwind-base.css +++ b/packages/tailwind/tailwind-base.css @@ -486,8 +486,9 @@ ) { [data-slot="form-step"] { &:nth-child(3), - &:nth-child(4), - /** Impotant to exclude if the second child is the same as the 4th from the back, i.e. 5 items */ + /** Impotant to exclude if the fourth child is the same as the second from the back, i.e. 5 items */ + &:nth-child(4):not(:nth-last-child(2)), + /** Impotant to exclude if the second child is the same as the fourth from the back, i.e. 5 items */ /** This is because the second child is used as a disclosure toggle on smaller screens.*/ &:nth-last-child(4):not(:nth-child(2)), &:nth-last-child(3) { From d8e0abda3e430612ae9f821f38b2e7d3a9128c9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Thu, 4 Dec 2025 08:35:34 +0100 Subject: [PATCH 16/43] Fix condition for overflow form steps --- packages/tailwind/tailwind-base.css | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/tailwind/tailwind-base.css b/packages/tailwind/tailwind-base.css index ba3cfe528..d7977156c 100644 --- a/packages/tailwind/tailwind-base.css +++ b/packages/tailwind/tailwind-base.css @@ -438,9 +438,7 @@ &:has([data-slot="form-step"]:nth-child(5)) { &:has( - [data-slot="form-step"]:nth-child(6):is( - :not([data-state="completed"]) - ) + [data-slot="form-step"]:nth-last-child(2):is([data-state="pending"]) ) { [data-state="pending"] { &:nth-child(n + 4) { From 577937bfc2db5072a683cb0b471a608b232e7a79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Thu, 4 Dec 2025 08:46:44 +0100 Subject: [PATCH 17/43] Fix edge case layout bugs on small screens --- packages/tailwind/tailwind-base.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/tailwind/tailwind-base.css b/packages/tailwind/tailwind-base.css index d7977156c..a994f29a7 100644 --- a/packages/tailwind/tailwind-base.css +++ b/packages/tailwind/tailwind-base.css @@ -506,7 +506,8 @@ [data-slot="form-step"] { &:nth-child(2), &:nth-child(3), - &:nth-child(4), + /** Impotant to exclude if the fourth child is the same as the second from the back, i.e. 5 items */ + &:nth-child(4):not(:nth-last-child(2)), &:nth-last-child(4), &:nth-last-child(3) { &[data-state="completed"] { From db4c59fb4da5498da78de1cb32eb8700c3d8d9f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Thu, 4 Dec 2025 09:23:47 +0100 Subject: [PATCH 18/43] Fix click area and current step color --- packages/tailwind/tailwind-base.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/tailwind/tailwind-base.css b/packages/tailwind/tailwind-base.css index a994f29a7..491c7d32f 100644 --- a/packages/tailwind/tailwind-base.css +++ b/packages/tailwind/tailwind-base.css @@ -374,7 +374,7 @@ &[data-state="current"] { &::before { - @apply bg-sky-light; + @apply bg-sky-light outline-2 outline-blue-dark -outline-offset-4; } } @@ -554,7 +554,7 @@ } } [data-slot="link"] { - @apply max-sm:opacity-100 max-sm:static; + @apply max-sm:opacity-100 max-sm:static max-sm:after:-left-11; } &:nth-child(3) { @apply max-sm:top-30; From 341a4f8ef73feb8216583363b0ab716bd62ab420 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Thu, 4 Dec 2025 09:24:22 +0100 Subject: [PATCH 19/43] Add new hooks for clicking outside refs --- packages/react/src/hooks/index.ts | 2 + .../react/src/hooks/useClickOutsideRef.ts | 45 +++++++++++++++++++ .../react/src/hooks/useComponentDidMount.ts | 5 +++ 3 files changed, 52 insertions(+) create mode 100644 packages/react/src/hooks/index.ts create mode 100644 packages/react/src/hooks/useClickOutsideRef.ts create mode 100644 packages/react/src/hooks/useComponentDidMount.ts diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts new file mode 100644 index 000000000..0ed934e68 --- /dev/null +++ b/packages/react/src/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './useClickOutsideRef'; +export * from './useComponentDidMount'; diff --git a/packages/react/src/hooks/useClickOutsideRef.ts b/packages/react/src/hooks/useClickOutsideRef.ts new file mode 100644 index 000000000..1b383f99e --- /dev/null +++ b/packages/react/src/hooks/useClickOutsideRef.ts @@ -0,0 +1,45 @@ +import { useRef } from 'react'; +import { useComponentDidMount } from './useComponentDidMount'; + +/** + * Helper function to create the event listener and add it it the document + * @param element The element to check if the click was outside of + * @param onClickOutsideCallback The callback to run if the click was outside of the element + * @returns The event handler function that was added to the document + */ +function createClickOutsideEventListener( + element: HTMLElement | SVGSVGElement | null, + onClickOutsideCallback: () => void, +) { + const clickOutsideHandler = ({ target }: MouseEvent) => { + if (!element?.contains(target as Node | null)) { + onClickOutsideCallback(); + } + }; + document.addEventListener('click', clickOutsideHandler); + + return clickOutsideHandler; +} + +/** + * Adds an event listener to the document that checks if a click was outside of the element when the component mounts. + * This hook is useful for dropdowns and other components that should close when the user clicks outside of them. + * This hook does not react to changes in the `onClickOutside` callback or component state. + * If you need to react to changes in the callback or component state, use the `useClickOutsideRefEffect` hook instead. + * @param onClickOutside The callback to run if the click was outside of the element + * @returns A ref that must be passed to the JSX element you wish to detect click events outside of + */ +export function useClickOutsideRef( + onClickOutside: () => void, +) { + const htmlElementRef = useRef(null); + + useComponentDidMount(() => { + const clickOutsideHandler = createClickOutsideEventListener( + htmlElementRef.current, + onClickOutside, + ); + return () => document.removeEventListener('click', clickOutsideHandler); + }); + return htmlElementRef; +} diff --git a/packages/react/src/hooks/useComponentDidMount.ts b/packages/react/src/hooks/useComponentDidMount.ts new file mode 100644 index 000000000..77b8efcb6 --- /dev/null +++ b/packages/react/src/hooks/useComponentDidMount.ts @@ -0,0 +1,5 @@ +import { type EffectCallback, useEffect } from 'react'; + +export function useComponentDidMount(callback: EffectCallback) { + useEffect(callback, []); +} From 473ff7c92851189d2eb35e329171c8e5cf8d5074 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Thu, 4 Dec 2025 09:24:44 +0100 Subject: [PATCH 20/43] Handle click outside form steps --- packages/react/src/form-steps/form-steps.tsx | 45 +++++++++++++------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/packages/react/src/form-steps/form-steps.tsx b/packages/react/src/form-steps/form-steps.tsx index 956a59a9a..e6fad4eee 100644 --- a/packages/react/src/form-steps/form-steps.tsx +++ b/packages/react/src/form-steps/form-steps.tsx @@ -10,6 +10,7 @@ import { useState, } from 'react'; import { ProgressBarContext, Provider } from 'react-aria-components'; +import { useClickOutsideRef } from '../hooks'; import { _LinkContext } from '../link'; import { translations } from '../translations'; import { useLocale } from '../use-locale'; @@ -22,8 +23,12 @@ type FormStepProps = HTMLProps & { state?: 'completed' | 'current' | 'pending'; }; -const _FormStepContext = createContext<{ isTogglableOnSmallScreens: boolean }>({ - isTogglableOnSmallScreens: false, +const _FormStepContext = createContext<{ + onToggle?: () => void; + isExpanded?: boolean; +}>({ + onToggle: undefined, + isExpanded: undefined, }); const _FormStepProvider = _FormStepContext.Provider; @@ -35,11 +40,7 @@ const FormStep = ({ }: FormStepProps) => { const locale = useLocale(); const id = useId(); - const { isTogglableOnSmallScreens } = use(_FormStepContext); - - const [_isExpanded, _setIsExpanded] = useState( - isTogglableOnSmallScreens ? false : undefined, - ); + const { onToggle, isExpanded } = use(_FormStepContext); return ( // biome-ignore lint/a11y/useKeyWithClickEvents: The collapsed content is accessible through keyboard focus @@ -48,12 +49,8 @@ const FormStep = ({ data-slot="form-step" data-state={state} id={id} - onClick={() => { - if (isTogglableOnSmallScreens) { - _setIsExpanded((prevState) => !prevState); - } - }} - data-expanded={_isExpanded} + onClick={onToggle} + data-expanded={isExpanded} > )} - {isTogglableOnSmallScreens && } + {onToggle && } {children} @@ -99,18 +96,34 @@ type FormStepsProps = HTMLAttributes & { const FormSteps = ({ children, ...restProps }: FormStepsProps) => { const locale = useLocale(); const childrenArray = Children.toArray(children); + + const isTogglableOnSmallScreens = childrenArray.length >= 5; + + const [isExpanded, setIsExpanded] = useState( + isTogglableOnSmallScreens ? false : undefined, + ); + + const onToggle = () => { + if (isTogglableOnSmallScreens) { + setIsExpanded((prevState) => !prevState); + } + }; + + const ref = useClickOutsideRef(onToggle); + return (
            {childrenArray.map((child, index) => - index === 1 && childrenArray.length >= 5 ? ( + isTogglableOnSmallScreens && index === 1 ? ( <_FormStepProvider key={(child as JSX.Element).props.key} - value={{ isTogglableOnSmallScreens: true }} + value={{ onToggle, isExpanded: isExpanded }} > {child} From d070ef3a55458d8b9e402e896aba99d435090673 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Thu, 4 Dec 2025 09:24:49 +0100 Subject: [PATCH 21/43] Tests --- .../src/form-steps/form-steps.stories.tsx | 695 ++++++++++++++++++ 1 file changed, 695 insertions(+) diff --git a/packages/react/src/form-steps/form-steps.stories.tsx b/packages/react/src/form-steps/form-steps.stories.tsx index f520bfa96..7a86941a0 100644 --- a/packages/react/src/form-steps/form-steps.stories.tsx +++ b/packages/react/src/form-steps/form-steps.stories.tsx @@ -319,3 +319,698 @@ export const SevenCompleted: Story = { ), }; + +// 3 Steps variants +export const ThreeSteps: Story = { + render: () => ( + + + Personalia + + + + Kontaktinformasjon + + + + Oppsummering + + + ), +}; + +export const ThreeStepsOneCompleted: Story = { + render: () => ( + + + Personalia + + + + Kontaktinformasjon + + + + Oppsummering + + + ), +}; + +export const ThreeStepsAllCompleted: Story = { + render: () => ( + + + Personalia + + + + Kontaktinformasjon + + + + Oppsummering + + + ), +}; + +// 4 Steps variants +export const FourSteps: Story = { + render: () => ( + + + Personalia + + + + Kontaktinformasjon + + + + Fakturainformasjon + + + + Oppsummering + + + ), +}; + +export const FourStepsOneCompleted: Story = { + render: () => ( + + + Personalia + + + + Kontaktinformasjon + + + + Fakturainformasjon + + + + Oppsummering + + + ), +}; + +export const FourStepsTwoCompleted: Story = { + render: () => ( + + + Personalia + + + + Kontaktinformasjon + + + + Fakturainformasjon + + + + Oppsummering + + + ), +}; + +export const FourStepsAllCompleted: Story = { + render: () => ( + + + Personalia + + + + Kontaktinformasjon + + + + Fakturainformasjon + + + + Oppsummering + + + ), +}; + +// 5 Steps variants +export const FiveSteps: Story = { + render: () => ( + + + Personalia + + + + Kontaktinformasjon + + + + Fakturainformasjon + + + + Samtykke + + + + Oppsummering + + + ), +}; + +export const FiveStepsOneCompleted: Story = { + render: () => ( + + + Personalia + + + + Kontaktinformasjon + + + + Fakturainformasjon + + + + Samtykke + + + + Oppsummering + + + ), +}; + +export const FiveStepsTwoCompleted: Story = { + render: () => ( + + + Personalia + + + + Kontaktinformasjon + + + + Fakturainformasjon + + + + Samtykke + + + + Oppsummering + + + ), +}; + +export const FiveStepsThreeCompleted: Story = { + render: () => ( + + + Personalia + + + + Kontaktinformasjon + + + + Fakturainformasjon + + + + Samtykke + + + + Oppsummering + + + ), +}; + +export const FiveStepsAllCompleted: Story = { + render: () => ( + + + Personalia + + + + Kontaktinformasjon + + + + Fakturainformasjon + + + + Samtykke + + + + Oppsummering + + + ), +}; + +// 6 Steps variants +export const SixSteps: Story = { + render: () => ( + + + Personalia + + + + Kontaktinformasjon + + + + Fakturainformasjon + + + + Samtykke + + + + Betalingsinformasjon + + + + Oppsummering + + + ), +}; + +export const SixStepsOneCompleted: Story = { + render: () => ( + + + Personalia + + + + Kontaktinformasjon + + + + Fakturainformasjon + + + + Samtykke + + + + Betalingsinformasjon + + + + Oppsummering + + + ), +}; + +export const SixStepsTwoCompleted: Story = { + render: () => ( + + + Personalia + + + + Kontaktinformasjon + + + + Fakturainformasjon + + + + Samtykke + + + + Betalingsinformasjon + + + + Oppsummering + + + ), +}; + +export const SixStepsThreeCompleted: Story = { + render: () => ( + + + Personalia + + + + Kontaktinformasjon + + + + Fakturainformasjon + + + + Samtykke + + + + Betalingsinformasjon + + + + Oppsummering + + + ), +}; + +export const SixStepsFourCompleted: Story = { + render: () => ( + + + Personalia + + + + Kontaktinformasjon + + + + Fakturainformasjon + + + + Samtykke + + + + Betalingsinformasjon + + + + Oppsummering + + + ), +}; + +export const SixStepsAllCompleted: Story = { + render: () => ( + + + Personalia + + + + Kontaktinformasjon + + + + Fakturainformasjon + + + + Samtykke + + + + Betalingsinformasjon + + + + Oppsummering + + + ), +}; + +// 7 Steps variants +export const SevenSteps: Story = { + render: () => ( + + + Personalia + + + + Kontaktinformasjon + + + + Fakturainformasjon + + + + Samtykke + + + + Betalingsinformasjon + + + + Leveringsadresse + + + + Oppsummering + + + ), +}; + +export const SevenStepsOneCompleted: Story = { + render: () => ( + + + Personalia + + + + Kontaktinformasjon + + + + Fakturainformasjon + + + + Samtykke + + + + Betalingsinformasjon + + + + Leveringsadresse + + + + Oppsummering + + + ), +}; + +export const SevenStepsTwoCompleted: Story = { + render: () => ( + + + Personalia + + + + Kontaktinformasjon + + + + Fakturainformasjon + + + + Samtykke + + + + Betalingsinformasjon + + + + Leveringsadresse + + + + Oppsummering + + + ), +}; + +export const SevenStepsThreeCompleted: Story = { + render: () => ( + + + Personalia + + + + Kontaktinformasjon + + + + Fakturainformasjon + + + + Samtykke + + + + Betalingsinformasjon + + + + Leveringsadresse + + + + Oppsummering + + + ), +}; + +export const SevenStepsFourCompleted: Story = { + render: () => ( + + + Personalia + + + + Kontaktinformasjon + + + + Fakturainformasjon + + + + Samtykke + + + + Betalingsinformasjon + + + + Leveringsadresse + + + + Oppsummering + + + ), +}; + +export const SevenStepsFiveCompleted: Story = { + render: () => ( + + + Personalia + + + + Kontaktinformasjon + + + + Fakturainformasjon + + + + Samtykke + + + + Betalingsinformasjon + + + + Leveringsadresse + + + + Oppsummering + + + ), +}; + +export const SevenStepsAllCompleted: Story = { + render: () => ( + + + Personalia + + + + Kontaktinformasjon + + + + Fakturainformasjon + + + + Samtykke + + + + Betalingsinformasjon + + + + Leveringsadresse + + + + Oppsummering + + + ), +}; From 5f7fd443f6bc8b68e6c4d0e72b2b67a56c305ace Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Thu, 4 Dec 2025 09:27:17 +0100 Subject: [PATCH 22/43] Fix file name casing --- packages/react/src/hooks/index.ts | 4 ++-- .../hooks/{useClickOutsideRef.ts => use-click-outside-ref.ts} | 2 +- .../{useComponentDidMount.ts => use-component-did-mount.ts} | 0 3 files changed, 3 insertions(+), 3 deletions(-) rename packages/react/src/hooks/{useClickOutsideRef.ts => use-click-outside-ref.ts} (96%) rename packages/react/src/hooks/{useComponentDidMount.ts => use-component-did-mount.ts} (100%) diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index 0ed934e68..ac94face4 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -1,2 +1,2 @@ -export * from './useClickOutsideRef'; -export * from './useComponentDidMount'; +export * from './use-click-outside-ref'; +export * from './use-component-did-mount'; diff --git a/packages/react/src/hooks/useClickOutsideRef.ts b/packages/react/src/hooks/use-click-outside-ref.ts similarity index 96% rename from packages/react/src/hooks/useClickOutsideRef.ts rename to packages/react/src/hooks/use-click-outside-ref.ts index 1b383f99e..608c713e8 100644 --- a/packages/react/src/hooks/useClickOutsideRef.ts +++ b/packages/react/src/hooks/use-click-outside-ref.ts @@ -1,5 +1,5 @@ import { useRef } from 'react'; -import { useComponentDidMount } from './useComponentDidMount'; +import { useComponentDidMount } from './use-component-did-mount'; /** * Helper function to create the event listener and add it it the document diff --git a/packages/react/src/hooks/useComponentDidMount.ts b/packages/react/src/hooks/use-component-did-mount.ts similarity index 100% rename from packages/react/src/hooks/useComponentDidMount.ts rename to packages/react/src/hooks/use-component-did-mount.ts From e4bb7034b342b3183d8860ee60f0bc28385f53bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Thu, 4 Dec 2025 10:49:23 +0100 Subject: [PATCH 23/43] Add comments --- packages/tailwind/tailwind-base.css | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/tailwind/tailwind-base.css b/packages/tailwind/tailwind-base.css index 491c7d32f..f9d804973 100644 --- a/packages/tailwind/tailwind-base.css +++ b/packages/tailwind/tailwind-base.css @@ -436,7 +436,9 @@ @apply hidden; } + /** If there are 5 or more form steps: **/ &:has([data-slot="form-step"]:nth-child(5)) { + /** Where the second last step is still pending: We stack all the pending steps on top of each other on small screens. **/ &:has( [data-slot="form-step"]:nth-last-child(2):is([data-state="pending"]) ) { @@ -464,16 +466,20 @@ } } + /** Where the third step is completed: We hide all the completed steps in a "popover" **/ &:has([data-slot="form-step"]:nth-child(3):is([data-state="completed"])) { + /** Styles the "popover" trigger used for touch/mouse interactions to display the popover **/ [data-slot="form-step"] { &:nth-child(2) { &::before { - /** Different content (alt text) for screen readers here, since we want to render this a regular step 2 for screen readers **/ + /** Announce as "2" for screen readers (since this is rendered in step 2) but display as '...' visually **/ @apply max-sm:content-['...'_/_'2']; } } } + /** Hides all the completed steps between step 2 and the current **/ + /** For both keyboard users and touch/mouse users (screen reader users will not notice this) **/ &:not( :has( [data-state="completed"]:not(:nth-child(1)):not( @@ -497,6 +503,8 @@ } } + /** Displays all the completed steps between step 2 and the current in a "popover" **/ + /** For both keyboard users and touch/mouse users (screen reader users will not notice this) **/ &:has( [data-state="completed"]:not(:nth-child(1)):not( :nth-last-child(2) @@ -510,7 +518,9 @@ &:nth-child(4):not(:nth-last-child(2)), &:nth-last-child(4), &:nth-last-child(3) { + /** All the completed steps needs to be absolutely positioned, with an offset that makes them stack vertically. **/ &[data-state="completed"] { + /** For the step 2, the ::before element is now acting as the popover trigger. So we need to use the ::after element as a visual step number indicator instead **/ &:nth-child(2) { &::after, [data-slot="link"], @@ -529,6 +539,7 @@ } } &::before { + /** Hide the focus outline on the element acting as the "popover" trigger **/ @apply outline-none; } [data-slot="link"] { @@ -541,6 +552,12 @@ &:not(:nth-child(2)) { @apply max-sm:absolute max-sm:left-4 max-sm:w-full; + /** Use stacked pseudo elements to create the illusion of a popover container **/ + @apply max-sm:after:absolute max-sm:after:-left-4 max-sm:after:right-4; + @apply max-sm:after:-top-[calc(200%+var(--spacing)*4)] max-sm:after:-bottom-4; + /** Paints the "popover container" **/ + @apply max-sm:after:bg-white max-sm:after:border-x max-sm:after:border-b max-sm:after:rounded-b-lg; + & > *:is([data-slot="text"], [data-slot="link"]) { @apply max-sm:relative; } @@ -548,14 +565,16 @@ &::before { @apply max-sm:z-1; } - @apply max-sm:after:absolute max-sm:after:-left-4 max-sm:after:right-4 max-sm:after:-top-[calc(200%+var(--spacing)*4)] max-sm:after:-bottom-4 max-sm:after:bg-white max-sm:after:border-x max-sm:after:border-b max-sm:after:rounded-b-lg; [data-slot="progress-bar"] { @apply max-sm:sr-only; } } [data-slot="link"] { + /** Make the link appear visually and position the link pseudo element acting as click area for the step number **/ @apply max-sm:opacity-100 max-sm:static max-sm:after:-left-11; } + + /** Position each step correctly inside the "popover" (with an even spacing) **/ &:nth-child(3) { @apply max-sm:top-30; } @@ -570,6 +589,7 @@ } } &:nth-child(3) { + /** Paints the top part of the "popover container" **/ @apply max-sm:after:border-t max-sm:after:rounded-t-lg; } } From cc1ebfcc3717e7cae24d2d1b19948f27def162bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Thu, 4 Dec 2025 10:50:21 +0100 Subject: [PATCH 24/43] Fix click outside bug --- packages/react/src/form-steps/form-steps.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/react/src/form-steps/form-steps.tsx b/packages/react/src/form-steps/form-steps.tsx index e6fad4eee..fd9b4b98c 100644 --- a/packages/react/src/form-steps/form-steps.tsx +++ b/packages/react/src/form-steps/form-steps.tsx @@ -109,7 +109,11 @@ const FormSteps = ({ children, ...restProps }: FormStepsProps) => { } }; - const ref = useClickOutsideRef(onToggle); + const ref = useClickOutsideRef(() => { + if (isTogglableOnSmallScreens) { + setIsExpanded(false); + } + }); return (
            From 793990078e4bb34e9ae2dc8e6eb6c2f31f82e796 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Thu, 4 Dec 2025 11:15:16 +0100 Subject: [PATCH 25/43] Fix children mapping --- packages/react/src/form-steps/form-steps.tsx | 32 +++++++++++--------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/packages/react/src/form-steps/form-steps.tsx b/packages/react/src/form-steps/form-steps.tsx index fd9b4b98c..89be4706f 100644 --- a/packages/react/src/form-steps/form-steps.tsx +++ b/packages/react/src/form-steps/form-steps.tsx @@ -1,9 +1,11 @@ import { Check } from '@obosbbl/grunnmuren-icons-react'; import { Children, + cloneElement, createContext, type HTMLAttributes, type HTMLProps, + isValidElement, type JSX, use, useId, @@ -95,9 +97,9 @@ type FormStepsProps = HTMLAttributes & { const FormSteps = ({ children, ...restProps }: FormStepsProps) => { const locale = useLocale(); - const childrenArray = Children.toArray(children); + const childCount = Children.count(children); - const isTogglableOnSmallScreens = childrenArray.length >= 5; + const isTogglableOnSmallScreens = childCount >= 5; const [isExpanded, setIsExpanded] = useState( isTogglableOnSmallScreens ? false : undefined, @@ -123,18 +125,20 @@ const FormSteps = ({ children, ...restProps }: FormStepsProps) => { data-slot="form-steps" ref={ref} > - {childrenArray.map((child, index) => - isTogglableOnSmallScreens && index === 1 ? ( - <_FormStepProvider - key={(child as JSX.Element).props.key} - value={{ onToggle, isExpanded: isExpanded }} - > - {child} - - ) : ( - child - ), - )} + {Children.map(children, (child, index) => { + if ( + isTogglableOnSmallScreens && + index === 1 && + isValidElement(child) + ) { + return ( + <_FormStepProvider value={{ onToggle, isExpanded }}> + {cloneElement(child)} + + ); + } + return child; + })}
          ); From 0eedcffffd63b53d5f8fff32af9251a4e7b41dbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Thu, 4 Dec 2025 12:08:24 +0100 Subject: [PATCH 26/43] Fix current step styles --- packages/tailwind/tailwind-base.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tailwind/tailwind-base.css b/packages/tailwind/tailwind-base.css index f9d804973..2c0a3f188 100644 --- a/packages/tailwind/tailwind-base.css +++ b/packages/tailwind/tailwind-base.css @@ -374,7 +374,7 @@ &[data-state="current"] { &::before { - @apply bg-sky-light outline-2 outline-blue-dark -outline-offset-4; + @apply bg-sky-light border-4; } } From eb7ef3baa5bbfe9f5b74a4719508d4ccc3533193 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Thu, 4 Dec 2025 12:15:57 +0100 Subject: [PATCH 27/43] Fix click area for expanded second FormStep --- packages/tailwind/tailwind-base.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/tailwind/tailwind-base.css b/packages/tailwind/tailwind-base.css index 2c0a3f188..3f6bb3302 100644 --- a/packages/tailwind/tailwind-base.css +++ b/packages/tailwind/tailwind-base.css @@ -542,8 +542,9 @@ /** Hide the focus outline on the element acting as the "popover" trigger **/ @apply outline-none; } + /** Position the link and pseudo element acting as click area for the step number **/ [data-slot="link"] { - @apply max-sm:-left-3 max-sm:top-16 max-sm:z-1; + @apply max-sm:-left-3 max-sm:top-16 max-sm:z-2 max-sm:after:-left-11; } [data-slot="toggle-check-icon"] { @apply max-sm:-left-10 max-sm:z-2 max-sm:top-12.5 max-sm:block; From 8b78d63fccee03aa4d3f09c89f5828c13fa0e10d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Thu, 4 Dec 2025 12:34:12 +0100 Subject: [PATCH 28/43] Fix current form-step color --- packages/tailwind/tailwind-base.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tailwind/tailwind-base.css b/packages/tailwind/tailwind-base.css index 3f6bb3302..ff3cff9d3 100644 --- a/packages/tailwind/tailwind-base.css +++ b/packages/tailwind/tailwind-base.css @@ -374,7 +374,7 @@ &[data-state="current"] { &::before { - @apply bg-sky-light border-4; + @apply bg-sky-light border-4 text-blue-dark; } } From f432d34b8669795137587d2d862453f282f3d883 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Thu, 4 Dec 2025 13:06:28 +0100 Subject: [PATCH 29/43] Close popover on focus outisde it --- packages/react/src/form-steps/form-steps.tsx | 33 +++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/react/src/form-steps/form-steps.tsx b/packages/react/src/form-steps/form-steps.tsx index 89be4706f..251c41ae4 100644 --- a/packages/react/src/form-steps/form-steps.tsx +++ b/packages/react/src/form-steps/form-steps.tsx @@ -12,7 +12,7 @@ import { useState, } from 'react'; import { ProgressBarContext, Provider } from 'react-aria-components'; -import { useClickOutsideRef } from '../hooks'; +import { useClickOutsideRef, useComponentDidMount } from '../hooks'; import { _LinkContext } from '../link'; import { translations } from '../translations'; import { useLocale } from '../use-locale'; @@ -74,6 +74,10 @@ const FormStep = ({ {state === 'completed' && ( )} + {/* + * Render an extra checkmark in the list item acting as the toggle for the collapsible steps (popover) on small screens. + * This indicates (visually) that all collapsed steps are completed. Screen reader users will already be informed about completed steps through the individual step items. + */} {onToggle && } {children} @@ -101,22 +105,48 @@ const FormSteps = ({ children, ...restProps }: FormStepsProps) => { const isTogglableOnSmallScreens = childCount >= 5; + // State to track whether the collapsible steps (popover) is expanded or collapsed const [isExpanded, setIsExpanded] = useState( isTogglableOnSmallScreens ? false : undefined, ); + // Handles toggling of the collapsible steps (popover) on small screens const onToggle = () => { if (isTogglableOnSmallScreens) { setIsExpanded((prevState) => !prevState); } }; + // Closes the popover (if visible) when clicking outside the component const ref = useClickOutsideRef(() => { if (isTogglableOnSmallScreens) { setIsExpanded(false); } }); + // Closes the popover (if visible) when focusing outside the popover itself + useComponentDidMount(() => { + const focusOutsideHandler = () => { + if (isTogglableOnSmallScreens) { + const focusedElement = document.activeElement; + if ( + focusedElement && + // If any of the completed steps (from 2nd step and onwards) does NOT contain the focused element, close the popover + !Array.from(ref.current?.children || []) + .slice(1) // Skip first li child + .filter((li) => li.getAttribute('data-state') === 'completed') // Only completed steps, counting from 2nd step can be collapsable + .some((li) => li.contains(focusedElement)) + ) { + setIsExpanded(false); + } + } + }; + + document.addEventListener('focus', focusOutsideHandler, true); + return () => + document.removeEventListener('focus', focusOutsideHandler, true); + }); + return (
            { isValidElement(child) ) { return ( + // The second list item acts as the toggle for the collapsible steps (popover) on small screens <_FormStepProvider value={{ onToggle, isExpanded }}> {cloneElement(child)} From c11e39a882ef1756558ccd092abf0e98d0ac4fd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Thu, 4 Dec 2025 13:07:14 +0100 Subject: [PATCH 30/43] Add comment --- packages/react/src/form-steps/form-steps.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react/src/form-steps/form-steps.tsx b/packages/react/src/form-steps/form-steps.tsx index 251c41ae4..4dd011e94 100644 --- a/packages/react/src/form-steps/form-steps.tsx +++ b/packages/react/src/form-steps/form-steps.tsx @@ -124,7 +124,8 @@ const FormSteps = ({ children, ...restProps }: FormStepsProps) => { } }); - // Closes the popover (if visible) when focusing outside the popover itself + // Closes the popover (if visible) when focusing outside the popover itself. + // We need these incase the user combines both mouse/touch and keyboard navigation. useComponentDidMount(() => { const focusOutsideHandler = () => { if (isTogglableOnSmallScreens) { From ebc08dc4d9845173c3a096196a24833c2262be77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Thu, 4 Dec 2025 13:13:51 +0100 Subject: [PATCH 31/43] Fix responsive classes --- packages/tailwind/tailwind-base.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/tailwind/tailwind-base.css b/packages/tailwind/tailwind-base.css index ff3cff9d3..309273b2c 100644 --- a/packages/tailwind/tailwind-base.css +++ b/packages/tailwind/tailwind-base.css @@ -535,12 +535,12 @@ /** Place the focus styles on the ::after element instead of the link, to match the rest of the list items */ &:has([data-slot="link"]:focus-visible) { &::after { - @apply outline-focus-offset; + @apply max-sm:outline-focus-offset; } } &::before { /** Hide the focus outline on the element acting as the "popover" trigger **/ - @apply outline-none; + @apply max-sm:outline-none; } /** Position the link and pseudo element acting as click area for the step number **/ [data-slot="link"] { From f908a51f73c4730e651aa28f16408f081db2bfef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Thu, 4 Dec 2025 13:41:59 +0100 Subject: [PATCH 32/43] Truncate too long step texts --- packages/tailwind/tailwind-base.css | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/tailwind/tailwind-base.css b/packages/tailwind/tailwind-base.css index 309273b2c..4099a2cbf 100644 --- a/packages/tailwind/tailwind-base.css +++ b/packages/tailwind/tailwind-base.css @@ -337,11 +337,7 @@ } } - .form-steps-container, - [data-slot="form-steps-container"] { - @apply max-lg:pt-9; - } - + /*** FormSteps component styles ***/ .form-steps, [data-slot="form-steps"] { counter-reset: form-step-counter; @@ -573,6 +569,9 @@ [data-slot="link"] { /** Make the link appear visually and position the link pseudo element acting as click area for the step number **/ @apply max-sm:opacity-100 max-sm:static max-sm:after:-left-11; + /** Truncate texts that does not fit the width of the popover (the inline-flex of the link must be overridden as block) **/ + /** We need to do this because we can't wrap over multiple lines, due to the absolute positioning of each step in the popover **/ + @apply max-sm:max-w-42.5 max-sm:text-ellipsis max-sm:overflow-hidden max-sm:whitespace-nowrap max-sm:block!; } /** Position each step correctly inside the "popover" (with an even spacing) **/ From 82173e805b6f7ab7b89e4962c1fb1b001dfd87e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Thu, 4 Dec 2025 15:19:50 +0100 Subject: [PATCH 33/43] Use Text instead of Link for current step --- .../src/form-steps/form-steps.stories.tsx | 68 +++++++++---------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/packages/react/src/form-steps/form-steps.stories.tsx b/packages/react/src/form-steps/form-steps.stories.tsx index 7a86941a0..1128336c9 100644 --- a/packages/react/src/form-steps/form-steps.stories.tsx +++ b/packages/react/src/form-steps/form-steps.stories.tsx @@ -62,11 +62,11 @@ export const OneCompleted: Story = { - Kontaktinformasjon - + Kontaktinformasjon + - Fakturainformasjon + Fakturainformasjon @@ -104,7 +104,7 @@ export const TwoCompleted: Story = { - Fakturainformasjon + Fakturainformasjon @@ -146,7 +146,7 @@ export const ThreeCompleted: Story = { - Samtykke + Samtykke @@ -188,7 +188,7 @@ export const FourCompleted: Story = { - Betalingsinformasjon + Betalingsinformasjon @@ -230,7 +230,7 @@ export const FiveCompleted: Story = { - Leveringsadresse + Leveringsadresse @@ -272,7 +272,7 @@ export const SixCompleted: Story = { - Bekrefelse + Bekrefelse @@ -314,7 +314,7 @@ export const SevenCompleted: Story = { - Oppsummering + Oppsummering ), @@ -325,7 +325,7 @@ export const ThreeSteps: Story = { render: () => ( - Personalia + Personalia @@ -347,7 +347,7 @@ export const ThreeStepsOneCompleted: Story = { - Kontaktinformasjon + Kontaktinformasjon @@ -369,7 +369,7 @@ export const ThreeStepsAllCompleted: Story = { - Oppsummering + Oppsummering ), @@ -380,7 +380,7 @@ export const FourSteps: Story = { render: () => ( - Personalia + Personalia @@ -406,7 +406,7 @@ export const FourStepsOneCompleted: Story = { - Kontaktinformasjon + Kontaktinformasjon @@ -432,7 +432,7 @@ export const FourStepsTwoCompleted: Story = { - Fakturainformasjon + Fakturainformasjon @@ -458,7 +458,7 @@ export const FourStepsAllCompleted: Story = { - Oppsummering + Oppsummering ), @@ -469,7 +469,7 @@ export const FiveSteps: Story = { render: () => ( - Personalia + Personalia @@ -499,7 +499,7 @@ export const FiveStepsOneCompleted: Story = { - Kontaktinformasjon + Kontaktinformasjon @@ -529,7 +529,7 @@ export const FiveStepsTwoCompleted: Story = { - Fakturainformasjon + Fakturainformasjon @@ -559,7 +559,7 @@ export const FiveStepsThreeCompleted: Story = { - Samtykke + Samtykke @@ -589,7 +589,7 @@ export const FiveStepsAllCompleted: Story = { - Oppsummering + Oppsummering ), @@ -600,7 +600,7 @@ export const SixSteps: Story = { render: () => ( - Personalia + Personalia @@ -634,7 +634,7 @@ export const SixStepsOneCompleted: Story = { - Kontaktinformasjon + Kontaktinformasjon @@ -668,7 +668,7 @@ export const SixStepsTwoCompleted: Story = { - Fakturainformasjon + Fakturainformasjon @@ -702,7 +702,7 @@ export const SixStepsThreeCompleted: Story = { - Samtykke + Samtykke @@ -736,7 +736,7 @@ export const SixStepsFourCompleted: Story = { - Betalingsinformasjon + Betalingsinformasjon @@ -770,7 +770,7 @@ export const SixStepsAllCompleted: Story = { - Oppsummering + Oppsummering ), @@ -781,7 +781,7 @@ export const SevenSteps: Story = { render: () => ( - Personalia + Personalia @@ -819,7 +819,7 @@ export const SevenStepsOneCompleted: Story = { - Kontaktinformasjon + Kontaktinformasjon @@ -857,7 +857,7 @@ export const SevenStepsTwoCompleted: Story = { - Fakturainformasjon + Fakturainformasjon @@ -895,7 +895,7 @@ export const SevenStepsThreeCompleted: Story = { - Samtykke + Samtykke @@ -933,7 +933,7 @@ export const SevenStepsFourCompleted: Story = { - Betalingsinformasjon + Betalingsinformasjon @@ -971,7 +971,7 @@ export const SevenStepsFiveCompleted: Story = { - Leveringsadresse + Leveringsadresse @@ -1009,7 +1009,7 @@ export const SevenStepsAllCompleted: Story = { - Oppsummering + Oppsummering ), From eec2d5eec70e044f0252e095e9984f7d1bb09789 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Fri, 5 Dec 2025 08:20:26 +0100 Subject: [PATCH 34/43] Fix clickable link area in form steps --- packages/tailwind/tailwind-base.css | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/tailwind/tailwind-base.css b/packages/tailwind/tailwind-base.css index 4099a2cbf..f61b92ea7 100644 --- a/packages/tailwind/tailwind-base.css +++ b/packages/tailwind/tailwind-base.css @@ -365,7 +365,8 @@ [data-slot="link"] { @apply focus-visible:outline-none; - @apply after:absolute after:h-11 max-lg:after:w-11; + /** Create a 44x44 px clickable area, where the step number next to each link will also be covered **/ + @apply py-2.5 pl-11 -ml-11 -my-2.5; } &[data-state="current"] { @@ -402,10 +403,6 @@ [data-slot="progress-bar"] { @apply max-lg:shrink; } - - [data-slot="link"] { - @apply after:-top-1.75 after:left-0 after:right-0; - } } } @@ -538,9 +535,9 @@ /** Hide the focus outline on the element acting as the "popover" trigger **/ @apply max-sm:outline-none; } - /** Position the link and pseudo element acting as click area for the step number **/ + [data-slot="link"] { - @apply max-sm:-left-3 max-sm:top-16 max-sm:z-2 max-sm:after:-left-11; + @apply max-sm:-left-3 max-sm:top-16 max-sm:z-2; } [data-slot="toggle-check-icon"] { @apply max-sm:-left-10 max-sm:z-2 max-sm:top-12.5 max-sm:block; @@ -571,7 +568,7 @@ @apply max-sm:opacity-100 max-sm:static max-sm:after:-left-11; /** Truncate texts that does not fit the width of the popover (the inline-flex of the link must be overridden as block) **/ /** We need to do this because we can't wrap over multiple lines, due to the absolute positioning of each step in the popover **/ - @apply max-sm:max-w-42.5 max-sm:text-ellipsis max-sm:overflow-hidden max-sm:whitespace-nowrap max-sm:block!; + @apply max-sm:max-w-53.5 max-sm:text-ellipsis max-sm:overflow-hidden max-sm:whitespace-nowrap max-sm:block!; } /** Position each step correctly inside the "popover" (with an even spacing) **/ From ab80662fa900bdfb0ac4a0c23d69fd36014addfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Fri, 5 Dec 2025 09:12:09 +0100 Subject: [PATCH 35/43] Refactor form steps API --- .../src/form-steps/form-steps.stories.tsx | 298 +++++++++--------- packages/react/src/form-steps/form-steps.tsx | 29 +- packages/tailwind/tailwind-base.css | 22 +- 3 files changed, 177 insertions(+), 172 deletions(-) diff --git a/packages/react/src/form-steps/form-steps.stories.tsx b/packages/react/src/form-steps/form-steps.stories.tsx index 1128336c9..532fe32ac 100644 --- a/packages/react/src/form-steps/form-steps.stories.tsx +++ b/packages/react/src/form-steps/form-steps.stories.tsx @@ -18,8 +18,8 @@ type Story = StoryObj; export const Default: Story = { render: () => ( - - + + Personalia @@ -56,12 +56,12 @@ export const Default: Story = { export const OneCompleted: Story = { render: () => ( - - + + Personalia - + Kontaktinformasjon @@ -94,16 +94,16 @@ export const OneCompleted: Story = { export const TwoCompleted: Story = { render: () => ( - - + + Personalia - + Kontaktinformasjon - + Fakturainformasjon @@ -132,20 +132,20 @@ export const TwoCompleted: Story = { export const ThreeCompleted: Story = { render: () => ( - - + + Personalia - + Kontaktinformasjon - + Fakturainformasjon - + Samtykke @@ -170,24 +170,24 @@ export const ThreeCompleted: Story = { export const FourCompleted: Story = { render: () => ( - - + + Personalia - + Kontaktinformasjon - + Fakturainformasjon - + Samtykke - + Betalingsinformasjon @@ -208,28 +208,28 @@ export const FourCompleted: Story = { export const FiveCompleted: Story = { render: () => ( - - + + Personalia - + Kontaktinformasjon - + Fakturainformasjon - + Samtykke - + Betalingsinformasjon - + Leveringsadresse @@ -246,32 +246,32 @@ export const FiveCompleted: Story = { export const SixCompleted: Story = { render: () => ( - - + + Personalia - + Kontaktinformasjon - + Fakturainformasjon - + Samtykke - + Betalingsinformasjon - + Leveringsadresse - + Bekrefelse @@ -284,36 +284,36 @@ export const SixCompleted: Story = { export const SevenCompleted: Story = { render: () => ( - - + + Personalia - + Kontaktinformasjon - + Fakturainformasjon - + Samtykke - + Betalingsinformasjon - + Leveringsadresse - + Bekrefelse - + Oppsummering @@ -323,8 +323,8 @@ export const SevenCompleted: Story = { // 3 Steps variants export const ThreeSteps: Story = { render: () => ( - - + + Personalia @@ -341,12 +341,12 @@ export const ThreeSteps: Story = { export const ThreeStepsOneCompleted: Story = { render: () => ( - - + + Personalia - + Kontaktinformasjon @@ -359,16 +359,16 @@ export const ThreeStepsOneCompleted: Story = { export const ThreeStepsAllCompleted: Story = { render: () => ( - - + + Personalia - + Kontaktinformasjon - + Oppsummering @@ -378,8 +378,8 @@ export const ThreeStepsAllCompleted: Story = { // 4 Steps variants export const FourSteps: Story = { render: () => ( - - + + Personalia @@ -400,12 +400,12 @@ export const FourSteps: Story = { export const FourStepsOneCompleted: Story = { render: () => ( - - + + Personalia - + Kontaktinformasjon @@ -422,16 +422,16 @@ export const FourStepsOneCompleted: Story = { export const FourStepsTwoCompleted: Story = { render: () => ( - - + + Personalia - + Kontaktinformasjon - + Fakturainformasjon @@ -444,20 +444,20 @@ export const FourStepsTwoCompleted: Story = { export const FourStepsAllCompleted: Story = { render: () => ( - - + + Personalia - + Kontaktinformasjon - + Fakturainformasjon - + Oppsummering @@ -467,8 +467,8 @@ export const FourStepsAllCompleted: Story = { // 5 Steps variants export const FiveSteps: Story = { render: () => ( - - + + Personalia @@ -493,12 +493,12 @@ export const FiveSteps: Story = { export const FiveStepsOneCompleted: Story = { render: () => ( - - + + Personalia - + Kontaktinformasjon @@ -519,16 +519,16 @@ export const FiveStepsOneCompleted: Story = { export const FiveStepsTwoCompleted: Story = { render: () => ( - - + + Personalia - + Kontaktinformasjon - + Fakturainformasjon @@ -545,20 +545,20 @@ export const FiveStepsTwoCompleted: Story = { export const FiveStepsThreeCompleted: Story = { render: () => ( - - + + Personalia - + Kontaktinformasjon - + Fakturainformasjon - + Samtykke @@ -571,24 +571,24 @@ export const FiveStepsThreeCompleted: Story = { export const FiveStepsAllCompleted: Story = { render: () => ( - - + + Personalia - + Kontaktinformasjon - + Fakturainformasjon - + Samtykke - + Oppsummering @@ -598,8 +598,8 @@ export const FiveStepsAllCompleted: Story = { // 6 Steps variants export const SixSteps: Story = { render: () => ( - - + + Personalia @@ -628,12 +628,12 @@ export const SixSteps: Story = { export const SixStepsOneCompleted: Story = { render: () => ( - - + + Personalia - + Kontaktinformasjon @@ -658,16 +658,16 @@ export const SixStepsOneCompleted: Story = { export const SixStepsTwoCompleted: Story = { render: () => ( - - + + Personalia - + Kontaktinformasjon - + Fakturainformasjon @@ -688,20 +688,20 @@ export const SixStepsTwoCompleted: Story = { export const SixStepsThreeCompleted: Story = { render: () => ( - - + + Personalia - + Kontaktinformasjon - + Fakturainformasjon - + Samtykke @@ -718,24 +718,24 @@ export const SixStepsThreeCompleted: Story = { export const SixStepsFourCompleted: Story = { render: () => ( - - + + Personalia - + Kontaktinformasjon - + Fakturainformasjon - + Samtykke - + Betalingsinformasjon @@ -748,28 +748,28 @@ export const SixStepsFourCompleted: Story = { export const SixStepsAllCompleted: Story = { render: () => ( - - + + Personalia - + Kontaktinformasjon - + Fakturainformasjon - + Samtykke - + Betalingsinformasjon - + Oppsummering @@ -779,8 +779,8 @@ export const SixStepsAllCompleted: Story = { // 7 Steps variants export const SevenSteps: Story = { render: () => ( - - + + Personalia @@ -813,12 +813,12 @@ export const SevenSteps: Story = { export const SevenStepsOneCompleted: Story = { render: () => ( - - + + Personalia - + Kontaktinformasjon @@ -847,16 +847,16 @@ export const SevenStepsOneCompleted: Story = { export const SevenStepsTwoCompleted: Story = { render: () => ( - - + + Personalia - + Kontaktinformasjon - + Fakturainformasjon @@ -881,20 +881,20 @@ export const SevenStepsTwoCompleted: Story = { export const SevenStepsThreeCompleted: Story = { render: () => ( - - + + Personalia - + Kontaktinformasjon - + Fakturainformasjon - + Samtykke @@ -915,24 +915,24 @@ export const SevenStepsThreeCompleted: Story = { export const SevenStepsFourCompleted: Story = { render: () => ( - - + + Personalia - + Kontaktinformasjon - + Fakturainformasjon - + Samtykke - + Betalingsinformasjon @@ -949,28 +949,28 @@ export const SevenStepsFourCompleted: Story = { export const SevenStepsFiveCompleted: Story = { render: () => ( - - + + Personalia - + Kontaktinformasjon - + Fakturainformasjon - + Samtykke - + Betalingsinformasjon - + Leveringsadresse @@ -983,32 +983,32 @@ export const SevenStepsFiveCompleted: Story = { export const SevenStepsAllCompleted: Story = { render: () => ( - - + + Personalia - + Kontaktinformasjon - + Fakturainformasjon - + Samtykke - + Betalingsinformasjon - + Leveringsadresse - + Oppsummering diff --git a/packages/react/src/form-steps/form-steps.tsx b/packages/react/src/form-steps/form-steps.tsx index 4dd011e94..98558c769 100644 --- a/packages/react/src/form-steps/form-steps.tsx +++ b/packages/react/src/form-steps/form-steps.tsx @@ -19,30 +19,36 @@ import { useLocale } from '../use-locale'; type FormStepProps = HTMLProps & { /** - * Indicates whether the step is completed, current or pending. - * @default 'pending' + * Indicates whether the step is completed or not. + * A completed step means that the user has filled out and submitted the corresponding section of the form. + * And that all the data entered in that section is valid. + * @default false */ - state?: 'completed' | 'current' | 'pending'; + isCompleted?: boolean; }; const _FormStepContext = createContext<{ onToggle?: () => void; isExpanded?: boolean; + isCurrent: boolean; }>({ onToggle: undefined, isExpanded: undefined, + isCurrent: false, }); const _FormStepProvider = _FormStepContext.Provider; const FormStep = ({ - state = 'pending', + isCompleted = false, children, ...restProps }: FormStepProps) => { const locale = useLocale(); const id = useId(); - const { onToggle, isExpanded } = use(_FormStepContext); + const { onToggle, isExpanded, isCurrent } = use(_FormStepContext); + + const state = isCompleted ? 'completed' : 'pending'; return ( // biome-ignore lint/a11y/useKeyWithClickEvents: The collapsed content is accessible through keyboard focus @@ -50,6 +56,7 @@ const FormStep = ({ {...restProps} data-slot="form-step" data-state={state} + data-is-current={isCurrent} id={id} onClick={onToggle} data-expanded={isExpanded} @@ -59,7 +66,7 @@ const FormStep = ({ [ _LinkContext, { - 'aria-current': state === 'current' ? 'step' : undefined, + 'aria-current': isCurrent ? 'step' : undefined, role: state === 'pending' ? 'none' : undefined, }, ], @@ -97,9 +104,10 @@ type FormStepsProps = HTMLAttributes & { JSX.Element?, JSX.Element?, ]; + currentStep: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8; }; -const FormSteps = ({ children, ...restProps }: FormStepsProps) => { +const FormSteps = ({ children, currentStep, ...restProps }: FormStepsProps) => { const locale = useLocale(); const childCount = Children.count(children); @@ -157,6 +165,7 @@ const FormSteps = ({ children, ...restProps }: FormStepsProps) => { ref={ref} > {Children.map(children, (child, index) => { + const isCurrent = index + 1 === currentStep; if ( isTogglableOnSmallScreens && index === 1 && @@ -164,12 +173,14 @@ const FormSteps = ({ children, ...restProps }: FormStepsProps) => { ) { return ( // The second list item acts as the toggle for the collapsible steps (popover) on small screens - <_FormStepProvider value={{ onToggle, isExpanded }}> + <_FormStepProvider value={{ onToggle, isExpanded, isCurrent }}> {cloneElement(child)} ); } - return child; + return ( + <_FormStepProvider value={{ isCurrent }}>{child} + ); })}
          diff --git a/packages/tailwind/tailwind-base.css b/packages/tailwind/tailwind-base.css index f61b92ea7..3d09ac9ab 100644 --- a/packages/tailwind/tailwind-base.css +++ b/packages/tailwind/tailwind-base.css @@ -369,7 +369,7 @@ @apply py-2.5 pl-11 -ml-11 -my-2.5; } - &[data-state="current"] { + &[data-is-current="true"] { &::before { @apply bg-sky-light border-4 text-blue-dark; } @@ -388,7 +388,7 @@ } } - &[data-state="pending"]::before { + &[data-state="pending"]:not([data-is-current="true"])::before { @apply bg-white text-blue-dark; } @@ -396,14 +396,6 @@ [data-slot="progress-bar"] { @apply w-4 max-lg:grow lg:w-14 lg:rotate-90 lg:absolute lg:-left-3 lg:px-1.5 lg:-bottom-[calc(100%-2px)]; } - - &[data-state="pending"] { - @apply max-lg:shrink; - - [data-slot="progress-bar"] { - @apply max-lg:shrink; - } - } } & > *:is([data-slot="text"], [data-slot="link"]) { @@ -414,7 +406,7 @@ @apply max-lg:absolute; } - &[data-state="current"] { + &[data-is-current="true"] { @apply max-lg:justify-center; & > *:is([data-slot="text"], [data-slot="link"]) { @apply max-lg:-top-full; @@ -433,11 +425,13 @@ &:has([data-slot="form-step"]:nth-child(5)) { /** Where the second last step is still pending: We stack all the pending steps on top of each other on small screens. **/ &:has( - [data-slot="form-step"]:nth-last-child(2):is([data-state="pending"]) + [data-slot="form-step"]:nth-last-child(2):is( + [data-state="pending"]:not([data-is-current="true"]) + ) ) { - [data-state="pending"] { + [data-state="pending"]:not([data-is-current="true"]) { &:nth-child(n + 4) { - &:not([data-state="current"] + *, :nth-child(4)) { + &:not([data-is-current="true"] + *, :nth-child(4)) { @apply max-sm:-ml-9; } [data-slot="progress-bar"] { From 305cf9f9a7ab0c06b0ce7927234f43650052f323 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oscar=20Carlstr=C3=B6m?= Date: Mon, 8 Dec 2025 06:52:32 +0100 Subject: [PATCH 36/43] Prepare for collapsible next steps --- .../src/form-steps/form-steps.stories.tsx | 419 +++++++++++++++++- packages/react/src/form-steps/form-steps.tsx | 192 ++++++-- packages/react/src/translations.ts | 5 + packages/tailwind/tailwind-base.css | 51 ++- 4 files changed, 602 insertions(+), 65 deletions(-) diff --git a/packages/react/src/form-steps/form-steps.stories.tsx b/packages/react/src/form-steps/form-steps.stories.tsx index 532fe32ac..fa1a511a2 100644 --- a/packages/react/src/form-steps/form-steps.stories.tsx +++ b/packages/react/src/form-steps/form-steps.stories.tsx @@ -1,7 +1,11 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { Text } from '../content'; +import { useState } from 'react'; +import { Button } from '../button'; +import { Heading, Text } from '../content'; import { UNSAFE_Link as Link } from '../link'; import { UNSAFE_ProgressBar as ProgressBar } from '../progress-bar'; +import { TextArea } from '../textarea'; +import { TextField } from '../textfield'; import { UNSAFE_FormStep as FormStep, UNSAFE_FormSteps as FormSteps } from './'; const meta: Meta = { @@ -558,9 +562,9 @@ export const FiveStepsThreeCompleted: Story = { Fakturainformasjon
          - + Samtykke - + Oppsummering @@ -1014,3 +1018,412 @@ export const SevenStepsAllCompleted: Story = {
          ), }; + +// Interactive form story with 8 steps +type FormData = { + step1: { fornavn: string; etternavn: string; fodselsdato: string }; + step2: { epost: string; telefon: string }; + step3: { adresse: string; postnummer: string; poststed: string }; + step4: { samtykke: string }; + step5: { kontonummer: string; banknavn: string }; + step6: { + leveringsadresse: string; + leveringspostnummer: string; + leveringspoststed: string; + }; + step7: { kommentar: string }; + step8: Record; +}; + +const initialFormData: FormData = { + step1: { fornavn: '', etternavn: '', fodselsdato: '' }, + step2: { epost: '', telefon: '' }, + step3: { adresse: '', postnummer: '', poststed: '' }, + step4: { samtykke: '' }, + step5: { kontonummer: '', banknavn: '' }, + step6: { + leveringsadresse: '', + leveringspostnummer: '', + leveringspoststed: '', + }, + step7: { kommentar: '' }, + step8: {}, +}; + +const stepTitles: Record = { + 1: 'Personalia', + 2: 'Kontaktinformasjon', + 3: 'Fakturainformasjon', + 4: 'Samtykke', + 5: 'Betalingsinformasjon', + 6: 'Leveringsadresse', + 7: 'Bekrefelse', + 8: 'Oppsummering', +}; + +const FormWith8StepsDemo = () => { + const [currentStep, setCurrentStep] = useState(1); + const [formData, setFormData] = useState(initialFormData); + const [completedSteps, setCompletedSteps] = useState>(new Set()); + + const updateFormData = ( + step: K, + field: keyof FormData[K], + value: string, + ) => { + setFormData((prev) => ({ + ...prev, + [step]: { + ...prev[step], + [field]: value, + }, + })); + }; + + const isStepComplete = (step: number): boolean => { + switch (step) { + case 1: + return !!( + formData.step1.fornavn && + formData.step1.etternavn && + formData.step1.fodselsdato + ); + case 2: + return !!(formData.step2.epost && formData.step2.telefon); + case 3: + return !!( + formData.step3.adresse && + formData.step3.postnummer && + formData.step3.poststed + ); + case 4: + return !!formData.step4.samtykke; + case 5: + return !!(formData.step5.kontonummer && formData.step5.banknavn); + case 6: + return !!( + formData.step6.leveringsadresse && + formData.step6.leveringspostnummer && + formData.step6.leveringspoststed + ); + case 7: + return !!formData.step7.kommentar; + default: + return false; + } + }; + + const getProgressValue = (step: number): number => { + // Calculate progress based on filled fields + const calculateFieldProgress = (fields: Record): number => { + const values = Object.values(fields); + const filledCount = values.filter((v) => v.length > 0).length; + return Math.round((filledCount / values.length) * 100); + }; + + switch (step) { + case 1: + return calculateFieldProgress(formData.step1); + case 2: + return calculateFieldProgress(formData.step2); + case 3: + return calculateFieldProgress(formData.step3); + case 4: + return calculateFieldProgress(formData.step4); + case 5: + return calculateFieldProgress(formData.step5); + case 6: + return calculateFieldProgress(formData.step6); + case 7: + return calculateFieldProgress(formData.step7); + case 8: + return currentStep === 8 ? 100 : 0; + default: + return 0; + } + }; + + const handleNext = () => { + if (currentStep < 8 && isStepComplete(currentStep)) { + setCompletedSteps((prev) => new Set(prev).add(currentStep)); + setCurrentStep((prev) => prev + 1); + } + }; + + const handleGoToStep = (step: number) => () => { + if ( + step <= currentStep || + completedSteps.has(step) || + canNavigateToStep(step) + ) { + setCurrentStep(step); + // Update URL without navigation + const url = new URL(window.location.href); + url.searchParams.set('currentStep', String(step)); + window.history.pushState({}, '', url.toString()); + } + }; + + // Check if all steps before a given step are completed + const canNavigateToStep = (step: number): boolean => { + for (let i = 1; i < step; i++) { + if (!completedSteps.has(i)) { + return false; + } + } + return true; + }; + + // Check if a step should render as a Link (navigable) + const isStepNavigable = (step: number): boolean => { + // Current step is not navigable (already there) + if (step === currentStep) return false; + // Completed steps are always navigable + if (completedSteps.has(step)) return true; + // Future steps are navigable if all previous steps are completed + return canNavigateToStep(step); + }; + + const renderStepContent = () => { + switch (currentStep) { + case 1: + return ( +
          + updateFormData('step1', 'fornavn', value)} + /> + updateFormData('step1', 'etternavn', value)} + /> + + updateFormData('step1', 'fodselsdato', value) + } + /> + +
          + ); + case 2: + return ( +
          + updateFormData('step2', 'epost', value)} + /> + updateFormData('step2', 'telefon', value)} + /> + +
          + ); + case 3: + return ( +
          + updateFormData('step3', 'adresse', value)} + /> + updateFormData('step3', 'postnummer', value)} + /> + updateFormData('step3', 'poststed', value)} + /> + +
          + ); + case 4: + return ( +
          +