From 11a46ba42fc2e5174cecc63aca3af87b4399d2ee Mon Sep 17 00:00:00 2001 From: Luke Bennett Date: Thu, 23 Jul 2026 19:07:50 +1000 Subject: [PATCH] Simplify LoadingSpinner around a children loading API (#195) * Simplify LoadingSpinner around a children loading API * Rename loading prop to isLoading for consistency across components * Refactor LoadingSpinner to use slotted recipe architecture * Add VisuallyHidden component * Switch from render to elementType * Use WCAG clip technique for visually-hidden styles Replace clip-path: circle(0) with the WCAG-standard clip technique (1px clipped box, overflow hidden, white-space nowrap) in both the visuallyHidden recipe and Text's isVisuallyHidden variant. circle(0) left a full-size layout box (overflow risk) and has questionable Safari focus-ring support. * Share visually-hidden style between recipe and Text variant --- .../components/feedback/loading-spinner.mdx | 32 +-- .../docs/components/primitives/meta.json | 2 +- .../components/primitives/visually-hidden.mdx | 35 ++++ .../src/examples/loading-spinner/children.tsx | 26 +++ .../src/examples/loading-spinner/progress.tsx | 13 -- .../src/examples/visually-hidden/basic.tsx | 10 + packages/@luke-ui/react/package.json | 1 + .../react/src/button/button.stories.tsx | 2 +- .../combobox-field.visual.test.tsx | 4 +- .../react/src/loading-spinner/index.tsx | 120 ++++++----- .../loading-spinner.stories.tsx | 86 ++------ .../loading-spinner.visual.test.tsx | 23 ++- packages/@luke-ui/react/src/recipes/index.ts | 3 +- .../@luke-ui/react/src/recipes/layers.css.ts | 1 + .../recipes/loading-spinner.browser.test.ts | 10 +- .../react/src/recipes/loading-spinner.css.ts | 193 +++++++++--------- .../@luke-ui/react/src/recipes/text.css.ts | 3 +- .../react/src/recipes/visually-hidden.css.ts | 26 +++ .../react/src/visually-hidden/index.tsx | 31 +++ .../visually-hidden.stories.tsx | 51 +++++ 20 files changed, 416 insertions(+), 256 deletions(-) create mode 100644 apps/docs/content/docs/components/primitives/visually-hidden.mdx create mode 100644 apps/docs/src/examples/loading-spinner/children.tsx delete mode 100644 apps/docs/src/examples/loading-spinner/progress.tsx create mode 100644 apps/docs/src/examples/visually-hidden/basic.tsx create mode 100644 packages/@luke-ui/react/src/recipes/visually-hidden.css.ts create mode 100644 packages/@luke-ui/react/src/visually-hidden/index.tsx create mode 100644 packages/@luke-ui/react/src/visually-hidden/visually-hidden.stories.tsx diff --git a/apps/docs/content/docs/components/feedback/loading-spinner.mdx b/apps/docs/content/docs/components/feedback/loading-spinner.mdx index e0777b5b..5ee3f01c 100644 --- a/apps/docs/content/docs/components/feedback/loading-spinner.mdx +++ b/apps/docs/content/docs/components/feedback/loading-spinner.mdx @@ -3,25 +3,26 @@ title: Loading Spinner description: Animated indicator for work that is still in progress. --- -Use `LoadingSpinner` when work is in progress. Omit `value` when you cannot report completion. +Use `LoadingSpinner` to show an animated spinner while work is in progress. Wrap content in it to +show the spinner in place of that content until loading finishes. -Indeterminate spinners rotate and pulse in sync, including spinners that mount at different times. +## Loading children -## Progress - -Pass `value` when you can report progress. `minValue` and `maxValue` set the range and default to -`0` and `100`. Values outside that range are clamped before the indicator is drawn. +Pass `children` along with `isLoading` to show the spinner in place of that content. While +`isLoading` is `true`, the spinner replaces the children but preserves their dimensions, and any +interactive descendants become unavailable. When `isLoading` is `false`, the children render +normally. ## Size @@ -48,9 +49,12 @@ surrounding text colour. ## Accessibility -The spinner has `progressbar` semantics. Its accessible name defaults to `pending`. Provide an -`aria-label` that names the work, such as `Loading profile`. A determinate spinner also exposes its -current, minimum, and maximum values. +The spinner has `status` semantics, a polite live region announced by assistive technology. Its +accessible name defaults to `loading`; provide an `aria-label` that names the work, such as +`Loading profile`. That name is rendered as visually hidden text inside the live region, so it is +announced as region content rather than relying on the region's label alone. While loading, wrapped +children are hidden from assistive technology and made inert, so they cannot be focused or +activated. ## Props diff --git a/apps/docs/content/docs/components/primitives/meta.json b/apps/docs/content/docs/components/primitives/meta.json index 4c214908..b8e52716 100644 --- a/apps/docs/content/docs/components/primitives/meta.json +++ b/apps/docs/content/docs/components/primitives/meta.json @@ -1,4 +1,4 @@ { "title": "Primitives", - "pages": ["button", "field", "text-input", "combobox"] + "pages": ["button", "field", "text-input", "combobox", "visually-hidden"] } diff --git a/apps/docs/content/docs/components/primitives/visually-hidden.mdx b/apps/docs/content/docs/components/primitives/visually-hidden.mdx new file mode 100644 index 00000000..6e679664 --- /dev/null +++ b/apps/docs/content/docs/components/primitives/visually-hidden.mdx @@ -0,0 +1,35 @@ +--- +title: Visually Hidden +description: Hide content visually while keeping it available to assistive technology. +--- + +Use `VisuallyHidden` to hide content from sight while keeping it in the accessibility tree and the +document flow. Screen readers still announce it, and it can be referenced by +`aria-labelledby`/`aria-describedby`. Unlike `display: none` or the `hidden` attribute, the content +is not removed from assistive technology. + +Reach for it when meaning is conveyed visually but needs a text equivalent — a label behind an +icon-only control, extra context for a link, or a status message inside a live region. + + + +## Render a different element + +By default `VisuallyHidden` renders a `span`. Pass `elementType` to render a different element — a +semantic heading, `label`, or list item — while keeping the hidden styles. No render prop or prop +spreading required. + +```tsx +Search results +``` + +## Props + + diff --git a/apps/docs/src/examples/loading-spinner/children.tsx b/apps/docs/src/examples/loading-spinner/children.tsx new file mode 100644 index 00000000..8041b9da --- /dev/null +++ b/apps/docs/src/examples/loading-spinner/children.tsx @@ -0,0 +1,26 @@ +import { Box } from '@luke-ui/react/box'; +import { Button } from '@luke-ui/react/button'; +import { LoadingSpinner } from '@luke-ui/react/loading-spinner'; +import { useState } from 'react'; + +export default function Children() { + const [isLoading, setIsLoading] = useState(true); + + return ( + + + + + + + ); +} diff --git a/apps/docs/src/examples/loading-spinner/progress.tsx b/apps/docs/src/examples/loading-spinner/progress.tsx deleted file mode 100644 index c6cbf664..00000000 --- a/apps/docs/src/examples/loading-spinner/progress.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { Box } from '@luke-ui/react/box'; -import { LoadingSpinner } from '@luke-ui/react/loading-spinner'; - -export default function Progress() { - return ( - - - - - - - ); -} diff --git a/apps/docs/src/examples/visually-hidden/basic.tsx b/apps/docs/src/examples/visually-hidden/basic.tsx new file mode 100644 index 00000000..4f5d58b9 --- /dev/null +++ b/apps/docs/src/examples/visually-hidden/basic.tsx @@ -0,0 +1,10 @@ +import { VisuallyHidden } from '@luke-ui/react/visually-hidden'; + +export default function Basic() { + return ( +

+ + Rated 4 out of 5 stars +

+ ); +} diff --git a/packages/@luke-ui/react/package.json b/packages/@luke-ui/react/package.json index 1b36cb6b..a1bddcef 100644 --- a/packages/@luke-ui/react/package.json +++ b/packages/@luke-ui/react/package.json @@ -38,6 +38,7 @@ "./theme": "./dist/theme/index.js", "./themes": "./dist/themes/index.js", "./utils": "./dist/utils/index.js", + "./visually-hidden": "./dist/visually-hidden/index.js", "./package.json": "./package.json", "./stylesheet.css": "./dist/stylesheet.css", "./spritesheet.svg": "./dist/spritesheet.svg", diff --git a/packages/@luke-ui/react/src/button/button.stories.tsx b/packages/@luke-ui/react/src/button/button.stories.tsx index 12f7bd48..93634d3e 100644 --- a/packages/@luke-ui/react/src/button/button.stories.tsx +++ b/packages/@luke-ui/react/src/button/button.stories.tsx @@ -154,7 +154,7 @@ export const States = meta.story({ play: async ({ args, canvas, step }) => { const pending = canvas.getByRole('button', { name: 'Pending' }); const disabled = canvas.getByRole('button', { name: 'Disabled' }); - const busyCue = canvas.getByRole('progressbar', { hidden: true }); + const busyCue = canvas.getByRole('status', { hidden: true }); await step('pending remains focusable, busy, and non-interactive', async () => { await userEvent.tab(); diff --git a/packages/@luke-ui/react/src/combobox-field/combobox-field.visual.test.tsx b/packages/@luke-ui/react/src/combobox-field/combobox-field.visual.test.tsx index 147709a9..07aa9f7f 100644 --- a/packages/@luke-ui/react/src/combobox-field/combobox-field.visual.test.tsx +++ b/packages/@luke-ui/react/src/combobox-field/combobox-field.visual.test.tsx @@ -296,9 +296,7 @@ test.each(visualAppearances)('open selection states: $theme $mode', async (appea await expect .element(page.getByRole('option', { name: 'Sweden' })) .toHaveAttribute('aria-disabled', 'true'); - await expect - .element(page.getByRole('progressbar', { name: 'Loading more options...' })) - .toBeVisible(); + await expect.element(page.getByRole('status', { name: 'Loading more options...' })).toBeVisible(); await captureVisualAppearance( page.elementLocator(document.body), 'combobox-field/open-selected-disabled-loading', diff --git a/packages/@luke-ui/react/src/loading-spinner/index.tsx b/packages/@luke-ui/react/src/loading-spinner/index.tsx index f20537eb..736bd752 100644 --- a/packages/@luke-ui/react/src/loading-spinner/index.tsx +++ b/packages/@luke-ui/react/src/loading-spinner/index.tsx @@ -1,5 +1,5 @@ -import { clamp } from '@react-aria/utils'; -import type { ComponentProps } from 'react'; +import type { ComponentProps, ReactNode } from 'react'; +import { useId } from 'react'; import { useIconSizeContext } from '../icon-size-context/index.js'; import * as styles from '../recipes/loading-spinner.css.js'; import { @@ -11,7 +11,7 @@ import { import type { DistributiveOmit } from '../types/distributive-omit.js'; import type { Prettify } from '../types/prettify.js'; import { useSynchronizeAnimations } from '../use-synchronize-animations/use-synchronize-animations.js'; -import { cx } from '../utils/index.js'; +import { VisuallyHidden } from '../visually-hidden/index.js'; interface LoadingSpinnerVariantProps extends NonNullable {} @@ -22,24 +22,16 @@ interface LoadingSpinnerStyleProps { size?: LoadingSpinnerVariantProps['size']; } -type _LoadingSpinnerOmit = DistributiveOmit< - ComponentProps<'div'>, - 'aria-valuemax' | 'aria-valuemin' | 'aria-valuenow' | 'color' | 'role' ->; +type _LoadingSpinnerOmit = DistributiveOmit, 'color' | 'role'>; interface _LoadingSpinnerProps extends _LoadingSpinnerOmit, LoadingSpinnerStyleProps { + /** Content to show once loading finishes. While loading, the spinner replaces it in place. */ + children?: ReactNode; /** - * Max value for determinate mode. - * @default 100 + * Whether the spinner is shown in place of `children`. + * @default true */ - maxValue?: number; - /** - * Min value for determinate mode. - * @default 0 - */ - minValue?: number; - /** Current value. Omit for indeterminate mode. */ - value?: number; + isLoading?: boolean; } /** @@ -49,63 +41,87 @@ interface _LoadingSpinnerProps extends _LoadingSpinnerOmit, LoadingSpinnerStyleP */ export type LoadingSpinnerProps = Prettify<_LoadingSpinnerProps>; -/** Progress spinner for determinate or indeterminate loading state. */ -export function LoadingSpinner(props: LoadingSpinnerProps) { +/** Animated spinner shown while work is in progress. Wrap content in it to show the spinner in place of that content until loading finishes. */ +export function LoadingSpinner(props: LoadingSpinnerProps): ReactNode { const { - 'aria-label': ariaLabel = 'pending', + 'aria-label': ariaLabel = 'loading', + children, className, color, - maxValue = 100, - minValue = 0, + isLoading = true, size, style, - value, - ...divProps + ...spanProps } = props; const contextSize = useIconSizeContext(); const resolvedSize = size ?? contextSize ?? 'medium'; - const hasValue = value !== undefined && value !== null; - const normalizedMin = Math.min(minValue, maxValue); - const normalizedMax = Math.max(minValue, maxValue); - const clampedRange = Math.max(normalizedMax - normalizedMin, 1); - const clampedValue = hasValue ? clamp(value, normalizedMin, normalizedMax) : 0; - const progress = ((clampedValue - normalizedMin) / clampedRange) * 100; - const dashOffset = 100 - progress; - const mode = hasValue ? 'determinate' : 'indeterminate'; + if (!isLoading) return children; + + const spinnerElement = ( + + ); + + if (!children) return spinnerElement; - useSynchronizeAnimations(mode === 'indeterminate' ? styles.spinAnimationName : null); - useSynchronizeAnimations(mode === 'indeterminate' ? styles.rubberBandAnimationName : null); + const slots = styles.loadingSpinner(); return ( -
+ + {children} + + {spinnerElement} + + ); +} + +type SpinnerElementProps = DistributiveOmit; + +function SpinnerElement({ + 'aria-label': ariaLabel, + className, + color, + size, + style, + ...spanProps +}: SpinnerElementProps) { + useSynchronizeAnimations(styles.spinAnimationName); + useSynchronizeAnimations(styles.rubberBandAnimationName); + + const labelId = useId(); + const slots = styles.loadingSpinner({ color, size }); + const viewBoxCenter = ICON_VIEWBOX_SIZE / 2; + + return ( + -
+ ); } diff --git a/packages/@luke-ui/react/src/loading-spinner/loading-spinner.stories.tsx b/packages/@luke-ui/react/src/loading-spinner/loading-spinner.stories.tsx index 99d5a2d1..4f23da95 100644 --- a/packages/@luke-ui/react/src/loading-spinner/loading-spinner.stories.tsx +++ b/packages/@luke-ui/react/src/loading-spinner/loading-spinner.stories.tsx @@ -12,10 +12,6 @@ const meta = preview.meta({ title: 'Feedback/LoadingSpinner', }); -const baseArgs = { - 'aria-label': 'pending', -} as const satisfies Partial; - const flexRowStyle = { display: 'flex', gap: '1rem', @@ -29,28 +25,11 @@ const flexStackStyle = { } as const satisfies CSSProperties; /** - * Use an indeterminate spinner when completion time is unknown. + * The spinner shows an animated loading indicator. */ export const Default = meta.story({ - args: baseArgs, - play: async ({ canvas }) => { - await expect(canvas.getByRole('progressbar', { name: 'pending' })).toBeInTheDocument(); - }, -}); - -/** Use a value when progress can be measured against a known range. */ -export const Determinate = meta.story({ - args: { - 'aria-label': 'Uploading', - maxValue: 200, - minValue: 100, - value: 175, - }, play: async ({ canvas }) => { - await expect(canvas.getByRole('progressbar', { name: 'Uploading' })).toHaveAttribute( - 'aria-valuenow', - '175', - ); + await expect(canvas.getByRole('status', { name: 'loading' })).toBeInTheDocument(); }, }); @@ -60,7 +39,6 @@ const sizes: Array> = ['small', 'medium * Size adjusts the spinner footprint for compact and standard layouts. */ export const Size = meta.story({ - args: baseArgs, render: (props) => (
{sizes.map((size) => ( @@ -76,9 +54,8 @@ const colors = ['primary', 'secondary', 'accent', 'info', 'success', 'warning', * Spinner color can use semantic content roles, or inherit its parent's color when omitted. */ export const Color = meta.story({ - args: baseArgs, play: async ({ canvas }) => { - const inherited = canvas.getByRole('progressbar', { name: 'Inherited accent' }); + const inherited = canvas.getByRole('status', { name: 'Inherited accent' }); const parent = inherited.parentElement; if (!parent) throw new Error('Expected spinner parent.'); @@ -97,56 +74,33 @@ export const Color = meta.story({ }); /** - * All mounted indeterminate spinners rotate in sync, even when they mount at different times. + * Wrap content in `LoadingSpinner` to show the spinner in its place while `isLoading` is `true`. + * Interactive descendants become unavailable until loading finishes. */ -export const Synchronized = meta.story({ - args: baseArgs, - play: async ({ canvas, canvasElement }) => { - const [first] = findSpinAnimations(canvasElement); - if (!first) throw new Error('Expected a spin CSS animation.'); - // Pause so currentTime holds at 400 instead of drifting while we wait for the sync to run. - first.pause(); - first.currentTime = 400; - - await userEvent.click(canvas.getByRole('button', { name: 'Mount another spinner' })); +export const Children = meta.story({ + play: async ({ canvas }) => { + await expect(canvas.getByRole('status', { name: 'loading' })).toBeInTheDocument(); + await expect(canvas.queryByRole('button', { name: 'Save' })).toBeNull(); - const [, second] = findSpinAnimations(canvasElement); - if (!second) throw new Error('Expected a second spin CSS animation.'); - // Pause before the sync runs so its result can't drift away from 400 while we wait below, - // no matter how many frames a loaded CI runner takes to get to it. - second.pause(); + await userEvent.click(canvas.getByRole('button', { name: 'Toggle loading' })); - await new Promise(requestAnimationFrame); - await new Promise(requestAnimationFrame); - await expect(second.currentTime).toBe(400); + await expect(canvas.queryByRole('status', { name: 'loading' })).toBeNull(); + await expect(canvas.getByRole('button', { name: 'Save' })).toBeInTheDocument(); }, - render: (props) => , + render: () => , }); -function StaggeredSpinners(props: LoadingSpinnerProps) { - const [spinnerCount, setSpinnerCount] = useState(1); +function ToggleableChildren() { + const [loading, setLoading] = useState(true); return (
-
- {Array.from({ length: spinnerCount }, (_, index) => ( - - ))} -
- + +
); } - -function findSpinAnimations(root: Element): Array { - return root.getAnimations({ subtree: true }).filter((animation): animation is CSSAnimation => { - return ( - animation instanceof CSSAnimation && - animation.effect instanceof KeyframeEffect && - animation.effect.target instanceof HTMLElement && - animation.effect.target.getAttribute('role') === 'progressbar' - ); - }); -} diff --git a/packages/@luke-ui/react/src/loading-spinner/loading-spinner.visual.test.tsx b/packages/@luke-ui/react/src/loading-spinner/loading-spinner.visual.test.tsx index 12d97513..ca346258 100644 --- a/packages/@luke-ui/react/src/loading-spinner/loading-spinner.visual.test.tsx +++ b/packages/@luke-ui/react/src/loading-spinner/loading-spinner.visual.test.tsx @@ -20,7 +20,12 @@ const rowStyle = { const sizes = variantValuesFor()(['small', 'medium', 'large']); const colors = variantValuesFor()(['primary', 'info', 'danger']); -test('sizes colors and modes', async () => { +const fixedChildStyle = { + blockSize: '2.5rem', + inlineSize: '8rem', +} satisfies CSSProperties; + +test('sizes and colors', async () => { const locator = renderVisual(
@@ -34,13 +39,21 @@ test('sizes colors and modes', async () => { ))}
- - + + + + + +
, ); - await captureVisual(locator, 'loading-spinner/sizes-colors-modes'); + await captureVisual(locator, 'loading-spinner/sizes-and-colors'); }); test.each(visualAppearances)('theme matrix: $theme $mode', async (appearance) => { @@ -51,7 +64,7 @@ test.each(visualAppearances)('theme matrix: $theme $mode', async (appearance) => - +
, appearance, diff --git a/packages/@luke-ui/react/src/recipes/index.ts b/packages/@luke-ui/react/src/recipes/index.ts index 265aa1fe..996dbb97 100644 --- a/packages/@luke-ui/react/src/recipes/index.ts +++ b/packages/@luke-ui/react/src/recipes/index.ts @@ -15,7 +15,7 @@ export type { LinkVariants } from '../recipes/link.css.js'; export { link } from '../recipes/link.css.js'; export { loadingSkeleton } from '../recipes/loading-skeleton.css.js'; export type { LoadingSpinnerVariants } from '../recipes/loading-spinner.css.js'; -export { spinner as loadingSpinner } from '../recipes/loading-spinner.css.js'; +export { loadingSpinner } from '../recipes/loading-spinner.css.js'; export type { TextAlign, TextColor, @@ -31,3 +31,4 @@ export type { export { text } from '../recipes/text.css.js'; export type { TextInputVariants } from '../recipes/text-input.css.js'; export { textInput } from '../recipes/text-input.css.js'; +export { visuallyHidden } from '../recipes/visually-hidden.css.js'; diff --git a/packages/@luke-ui/react/src/recipes/layers.css.ts b/packages/@luke-ui/react/src/recipes/layers.css.ts index 96f5686d..1d5649a0 100644 --- a/packages/@luke-ui/react/src/recipes/layers.css.ts +++ b/packages/@luke-ui/react/src/recipes/layers.css.ts @@ -9,3 +9,4 @@ import './loading-skeleton.css.js'; import './loading-spinner.css.js'; import './text.css.js'; import './text-input.css.js'; +import './visually-hidden.css.js'; diff --git a/packages/@luke-ui/react/src/recipes/loading-spinner.browser.test.ts b/packages/@luke-ui/react/src/recipes/loading-spinner.browser.test.ts index 8bb135e1..406dd321 100644 --- a/packages/@luke-ui/react/src/recipes/loading-spinner.browser.test.ts +++ b/packages/@luke-ui/react/src/recipes/loading-spinner.browser.test.ts @@ -1,6 +1,6 @@ import { afterEach, expect, test } from 'vite-plus/test'; import { cdp } from 'vite-plus/test/context'; -import { indicator, spinnerState } from './loading-spinner.css.js'; +import { loadingSpinner } from './loading-spinner.css.js'; const mounted: Array = []; @@ -10,7 +10,7 @@ afterEach(async () => { await setEmulatedMedia(); }); -test('indeterminate spinner uses the original rotation and rubber-band timing', () => { +test('spinner uses the original rotation and rubber-band timing', () => { const { ring, spinner } = mountSpinner(); const spinnerStyle = getComputedStyle(spinner); const ringStyle = getComputedStyle(ring); @@ -25,7 +25,7 @@ for (const [name, value] of [ ['forced-colors', 'active'], ['prefers-reduced-motion', 'reduce'], ] as const) { - test(`${name} renders an indeterminate spinner as a static partial ring`, async () => { + test(`${name} renders the spinner as a static partial ring`, async () => { await setEmulatedMedia(name, value); const { ring, spinner } = mountSpinner(); @@ -38,10 +38,10 @@ for (const [name, value] of [ function mountSpinner() { const spinner = document.body.appendChild(document.createElement('div')); - spinner.className = spinnerState({ mode: 'indeterminate' }); + spinner.className = loadingSpinner().root(); const svg = spinner.appendChild(document.createElementNS('http://www.w3.org/2000/svg', 'svg')); const ring = svg.appendChild(document.createElementNS('http://www.w3.org/2000/svg', 'circle')); - ring.setAttribute('class', indicator({ mode: 'indeterminate' })); + ring.setAttribute('class', loadingSpinner().indicator()); mounted.push(spinner); return { ring, spinner }; } diff --git a/packages/@luke-ui/react/src/recipes/loading-spinner.css.ts b/packages/@luke-ui/react/src/recipes/loading-spinner.css.ts index d204748d..816cd8f4 100644 --- a/packages/@luke-ui/react/src/recipes/loading-spinner.css.ts +++ b/packages/@luke-ui/react/src/recipes/loading-spinner.css.ts @@ -1,119 +1,124 @@ import { keyframes } from '@vanilla-extract/css'; -import { styleInLayer } from '../styles/layered-style.css.js'; import { vars } from '../theme/contract.css.js'; import { iconSizeVariants } from './icon.css.js'; -import type { RecipeSelection } from './recipe.js'; +import type { RecipeSelection, SlottedConfigInput } from './recipe.js'; import { recipe } from './recipe.js'; const rotationDuration = '1.2s'; const rubberBandDuration = '2s'; const rubberBandEasing = 'cubic-bezier(0.42, 0, 0.58, 1)'; -const colorVariants = { - accent: { color: vars.color.intent.accent.text }, - danger: { color: vars.color.intent.danger.text }, - info: { color: vars.color.intent.info.text }, - primary: { color: vars.color.text.primary }, - secondary: { color: vars.color.text.secondary }, - success: { color: vars.color.intent.success.text }, - warning: { color: vars.color.intent.warning.text }, -} as const; - -const base = styleInLayer('recipes', { - color: 'currentColor', - display: 'inline-flex', - flexShrink: 0, -}); - -/** Vanilla-extract recipe for the `LoadingSpinner` primitive's styles. */ -export const spinner = recipe({ - base, - defaultVariants: { - size: 'medium', - }, - variants: { - color: colorVariants, - size: iconSizeVariants, - }, -}); - -/** Variant type for the `LoadingSpinner` recipe. */ -export type LoadingSpinnerVariants = RecipeSelection; - -/** @internal */ +/** + * @internal + */ export const spinAnimationName = keyframes({ to: { transform: 'rotate(360deg)' }, }); -export const spinnerState = recipe({ - defaultVariants: { - mode: 'determinate', - }, - variants: { - mode: { - determinate: {}, - indeterminate: { - '@media': { - '(forced-colors: active)': { animationName: 'none' }, - '(prefers-reduced-motion: reduce)': { animationName: 'none' }, - }, - animationDuration: rotationDuration, - animationIterationCount: 'infinite', - animationName: spinAnimationName, - animationTimingFunction: 'linear', - }, - }, - }, -}); - -export const svg = recipe({ - base: { - blockSize: '100%', - display: 'block', - inlineSize: '100%', - transform: 'rotate(-90deg)', - }, -}); - -/** @internal */ +/** + * @internal + */ export const rubberBandAnimationName = keyframes({ '0%': { strokeDasharray: '2 100' }, '50%': { strokeDasharray: '65 100', strokeDashoffset: -20 }, '100%': { strokeDasharray: '2 100', strokeDashoffset: -100 }, }); -export const indicator = recipe({ - base: { - strokeDasharray: '100 100', +/** + * Raw slotted config for the `LoadingSpinner` primitive. + * + * Slots: `root` (the animated spinner span), `svg`, `indicator` (the rubber-band + * ring), and the in-place children overlay slots `childrenWrapper`, + * `hiddenChildren`, and `spinnerOverlay`. + */ +const loadingSpinnerConfig = { + slots: { + root: { + '@media': { + '(forced-colors: active)': { animationName: 'none' }, + '(prefers-reduced-motion: reduce)': { animationName: 'none' }, + }, + animationDuration: rotationDuration, + animationIterationCount: 'infinite', + animationName: spinAnimationName, + animationTimingFunction: 'linear', + color: 'currentColor', + display: 'inline-flex', + flexShrink: 0, + }, + svg: { + blockSize: '100%', + display: 'block', + inlineSize: '100%', + transform: 'rotate(-90deg)', + }, + indicator: { + '@media': { + '(forced-colors: active)': { + animationName: 'none', + strokeDasharray: '25 100', + strokeDashoffset: 0, + }, + '(prefers-reduced-motion: reduce)': { + animationName: 'none', + strokeDasharray: '25 100', + strokeDashoffset: 0, + }, + }, + animationDuration: rubberBandDuration, + animationIterationCount: 'infinite', + animationName: rubberBandAnimationName, + animationTimingFunction: rubberBandEasing, + strokeDasharray: '100 100', + }, + childrenWrapper: { + alignItems: 'center', + display: 'inline-flex', + justifyContent: 'center', + position: 'relative', + }, + hiddenChildren: { + display: 'contents', + visibility: 'hidden', + }, + spinnerOverlay: { + alignItems: 'center', + display: 'flex', + inset: 0, + justifyContent: 'center', + position: 'absolute', + }, }, defaultVariants: { - mode: 'determinate', + size: 'medium', }, variants: { - mode: { - determinate: { - transitionDuration: vars.motion.duration.fast, - transitionProperty: 'stroke-dashoffset', - transitionTimingFunction: vars.motion.easing.exit, - }, - indeterminate: { - '@media': { - '(forced-colors: active)': { - animationName: 'none', - strokeDasharray: '25 100', - strokeDashoffset: 0, - }, - '(prefers-reduced-motion: reduce)': { - animationName: 'none', - strokeDasharray: '25 100', - strokeDashoffset: 0, - }, - }, - animationDuration: rubberBandDuration, - animationIterationCount: 'infinite', - animationName: rubberBandAnimationName, - animationTimingFunction: rubberBandEasing, - }, + color: { + accent: { root: { color: vars.color.intent.accent.text } }, + danger: { root: { color: vars.color.intent.danger.text } }, + info: { root: { color: vars.color.intent.info.text } }, + primary: { root: { color: vars.color.text.primary } }, + secondary: { root: { color: vars.color.text.secondary } }, + success: { root: { color: vars.color.intent.success.text } }, + warning: { root: { color: vars.color.intent.warning.text } }, + }, + size: { + large: { root: iconSizeVariants.large }, + medium: { root: iconSizeVariants.medium }, + small: { root: iconSizeVariants.small }, + xsmall: { root: iconSizeVariants.xsmall }, }, }, -}); +} as const satisfies SlottedConfigInput; + +/** + * Slotted recipe for the `LoadingSpinner` primitive. + * + * `loadingSpinner({ color, size }).root() / .svg() / .indicator()` for the spinner + * itself, and `.childrenWrapper() / .hiddenChildren() / .spinnerOverlay()` for the + * in-place children overlay. + */ +export const loadingSpinner = recipe(loadingSpinnerConfig); + +/** Outer variant selection for the `LoadingSpinner` recipe. */ +export type LoadingSpinnerVariants = RecipeSelection; diff --git a/packages/@luke-ui/react/src/recipes/text.css.ts b/packages/@luke-ui/react/src/recipes/text.css.ts index ff31bdb1..d8eb883c 100644 --- a/packages/@luke-ui/react/src/recipes/text.css.ts +++ b/packages/@luke-ui/react/src/recipes/text.css.ts @@ -5,6 +5,7 @@ import { fontSizeSteps } from '../theme/contract.js'; import type { FontSizeStep } from '../theme/contract.js'; import type { RecipeSelection } from './recipe.js'; import { recipe } from './recipe.js'; +import { visuallyHiddenStyle } from './visually-hidden.css.js'; /** Typography size steps. */ export type TextSize = FontSizeStep; @@ -174,7 +175,7 @@ export const text = recipe({ }, isVisuallyHidden: { false: {}, - true: { position: 'absolute', transform: 'scale(0)' }, + true: visuallyHiddenStyle, }, lineClamp: lineClampVariants, shouldDisableTrim: { false: {}, true: {} }, diff --git a/packages/@luke-ui/react/src/recipes/visually-hidden.css.ts b/packages/@luke-ui/react/src/recipes/visually-hidden.css.ts new file mode 100644 index 00000000..cd79a9a7 --- /dev/null +++ b/packages/@luke-ui/react/src/recipes/visually-hidden.css.ts @@ -0,0 +1,26 @@ +import type { StyleRule } from '@vanilla-extract/css'; +import { recipe } from './recipe.js'; + +/** + * WCAG-standard "visually hidden" style: keeps content in the layout and the + * accessibility tree as a clipped 1×1px box, rather than `display: none` / + * `visibility: hidden` (which remove it from assistive technology) or + * `clip-path: circle(0)` (which leaves a full-size layout box and has + * questionable Safari focus-ring support). + * + * Shared by the `visuallyHidden` recipe and Text's `isVisuallyHidden` variant. + */ +export const visuallyHiddenStyle = { + blockSize: '1px', // 1px, not 0: zero dimensions trip screen-reader bugs + clip: 'rect(1px, 1px, 1px, 1px)', // legacy fallback for clip-path + clipPath: 'inset(100%)', + inlineSize: '1px', + overflow: 'hidden', + position: 'absolute', + whiteSpace: 'nowrap', // stop text wrapping inside the 1px box +} satisfies StyleRule; + +/** Recipe for content hidden visually but kept available to assistive technology. */ +export const visuallyHidden = recipe({ + base: visuallyHiddenStyle, +}); diff --git a/packages/@luke-ui/react/src/visually-hidden/index.tsx b/packages/@luke-ui/react/src/visually-hidden/index.tsx new file mode 100644 index 00000000..42a9a498 --- /dev/null +++ b/packages/@luke-ui/react/src/visually-hidden/index.tsx @@ -0,0 +1,31 @@ +import type { ComponentPropsWithRef, JSX } from 'react'; +import { Text as RacText } from 'react-aria-components/Text'; +import { visuallyHidden } from '../recipes/visually-hidden.css.js'; +import type { Prettify } from '../types/prettify.js'; +import { cx } from '../utils/index.js'; + +type _VisuallyHiddenProps = ComponentPropsWithRef; + +/** + * Props for `VisuallyHidden`. + * + * @tier atom + */ +export type VisuallyHiddenProps = Prettify<_VisuallyHiddenProps>; + +/** + * Hides its content visually while keeping it available to assistive technology. + * + * Use it to give assistive-technology users context conveyed visually by other + * means — a text label behind an icon-only control, extra context for a link, or + * a status message inside a live region. The content stays in the accessibility + * tree and the document flow (unlike `display: none` or the `hidden` attribute), + * so it is announced and can be referenced by `aria-labelledby`/`aria-describedby`. + * + * Renders a `span` by default. Pass `elementType` to render a different element + * (for example `elementType="h2"` for a screen-reader-only section heading). + */ +export function VisuallyHidden(props: VisuallyHiddenProps): JSX.Element { + const { className, ...racProps } = props; + return ; +} diff --git a/packages/@luke-ui/react/src/visually-hidden/visually-hidden.stories.tsx b/packages/@luke-ui/react/src/visually-hidden/visually-hidden.stories.tsx new file mode 100644 index 00000000..e3d882ac --- /dev/null +++ b/packages/@luke-ui/react/src/visually-hidden/visually-hidden.stories.tsx @@ -0,0 +1,51 @@ +import { VisuallyHidden } from '@luke-ui/react/visually-hidden'; +import { expect } from 'storybook/test'; +import preview from '../../.storybook/preview.js'; + +const meta = preview.meta({ + component: VisuallyHidden, + tags: ['layout'], + title: 'Layout/VisuallyHidden', +}); + +/** + * The label is hidden visually but stays in the accessibility tree, so the button + * still has an accessible name and screen readers announce it. + */ +export const Default = meta.story({ + play: async ({ canvas }) => { + const button = canvas.getByRole('button', { name: 'Add to favourites' }); + await expect(button).toBeInTheDocument(); + + const label = canvas.getByText('Add to favourites'); + const style = getComputedStyle(label); + await expect(style.position).toBe('absolute'); + await expect(style.width).toBe('1px'); + await expect(style.height).toBe('1px'); + }, + render: () => ( + + ), +}); + +/** + * Pass `elementType` to render a different element while keeping the hidden styles — + * here a screen-reader-only section heading, exposed to assistive technology as an `h2`. + */ +export const CustomElementType = meta.story({ + play: async ({ canvas }) => { + const heading = canvas.getByRole('heading', { name: 'Search results' }); + await expect(heading).toBeInTheDocument(); + await expect(heading.tagName).toBe('H2'); + await expect(getComputedStyle(heading).position).toBe('absolute'); + }, + render: () => ( +
+ Search results +

10 results found.

+
+ ), +}); -- 2.51.2