[READ-ONLY] Mirror of https://github.com/lukebennett88/luke-ui. luke-ui.netlify.app/
0

Configure Feed

Select the types of activity you want to include in your feed.

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

authored by

Luke Bennett and committed by
GitHub
(Jul 23, 2026, 7:07 PM +1000) 11a46ba4 820e3e00

+417 -257
+1
packages/@luke-ui/react/package.json
··· 38 38 "./theme": "./dist/theme/index.js", 39 39 "./themes": "./dist/themes/index.js", 40 40 "./utils": "./dist/utils/index.js", 41 + "./visually-hidden": "./dist/visually-hidden/index.js", 41 42 "./package.json": "./package.json", 42 43 "./stylesheet.css": "./dist/stylesheet.css", 43 44 "./spritesheet.svg": "./dist/spritesheet.svg",
+26
apps/docs/src/examples/loading-spinner/children.tsx
··· 1 + import { Box } from '@luke-ui/react/box'; 2 + import { Button } from '@luke-ui/react/button'; 3 + import { LoadingSpinner } from '@luke-ui/react/loading-spinner'; 4 + import { useState } from 'react'; 5 + 6 + export default function Children() { 7 + const [isLoading, setIsLoading] = useState(true); 8 + 9 + return ( 10 + <Box alignItems="center" display="flex" flexWrap="wrap" gap="400"> 11 + <LoadingSpinner aria-label="Saving changes" isLoading={isLoading}> 12 + <Button>Save changes</Button> 13 + </LoadingSpinner> 14 + <label> 15 + <Box alignItems="center" display="flex" gap="200"> 16 + <input 17 + checked={isLoading} 18 + onChange={(event) => setIsLoading(event.target.checked)} 19 + type="checkbox" 20 + /> 21 + Saving 22 + </Box> 23 + </label> 24 + </Box> 25 + ); 26 + }
-13
apps/docs/src/examples/loading-spinner/progress.tsx
··· 1 - import { Box } from '@luke-ui/react/box'; 2 - import { LoadingSpinner } from '@luke-ui/react/loading-spinner'; 3 - 4 - export default function Progress() { 5 - return ( 6 - <Box alignItems="center" display="flex" flexWrap="wrap" gap="400"> 7 - <LoadingSpinner aria-label="Uploading file: 25% complete" value={25} /> 8 - <LoadingSpinner aria-label="Uploading file: 50% complete" value={50} /> 9 - <LoadingSpinner aria-label="Uploading file: 75% complete" value={75} /> 10 - <LoadingSpinner aria-label="Uploading file: 100% complete" value={100} /> 11 - </Box> 12 - ); 13 - }
+10
apps/docs/src/examples/visually-hidden/basic.tsx
··· 1 + import { VisuallyHidden } from '@luke-ui/react/visually-hidden'; 2 + 3 + export default function Basic() { 4 + return ( 5 + <p> 6 + <span aria-hidden="true">★★★★☆</span> 7 + <VisuallyHidden> Rated 4 out of 5 stars</VisuallyHidden> 8 + </p> 9 + ); 10 + }
+1 -1
packages/@luke-ui/react/src/button/button.stories.tsx
··· 154 154 play: async ({ args, canvas, step }) => { 155 155 const pending = canvas.getByRole('button', { name: 'Pending' }); 156 156 const disabled = canvas.getByRole('button', { name: 'Disabled' }); 157 - const busyCue = canvas.getByRole('progressbar', { hidden: true }); 157 + const busyCue = canvas.getByRole('status', { hidden: true }); 158 158 159 159 await step('pending remains focusable, busy, and non-interactive', async () => { 160 160 await userEvent.tab();
+1 -3
packages/@luke-ui/react/src/combobox-field/combobox-field.visual.test.tsx
··· 296 296 await expect 297 297 .element(page.getByRole('option', { name: 'Sweden' })) 298 298 .toHaveAttribute('aria-disabled', 'true'); 299 - await expect 300 - .element(page.getByRole('progressbar', { name: 'Loading more options...' })) 301 - .toBeVisible(); 299 + await expect.element(page.getByRole('status', { name: 'Loading more options...' })).toBeVisible(); 302 300 await captureVisualAppearance( 303 301 page.elementLocator(document.body), 304 302 'combobox-field/open-selected-disabled-loading',
+68 -52
packages/@luke-ui/react/src/loading-spinner/index.tsx
··· 1 - import { clamp } from '@react-aria/utils'; 2 - import type { ComponentProps } from 'react'; 1 + import type { ComponentProps, ReactNode } from 'react'; 2 + import { useId } from 'react'; 3 3 import { useIconSizeContext } from '../icon-size-context/index.js'; 4 4 import * as styles from '../recipes/loading-spinner.css.js'; 5 5 import { ··· 11 11 import type { DistributiveOmit } from '../types/distributive-omit.js'; 12 12 import type { Prettify } from '../types/prettify.js'; 13 13 import { useSynchronizeAnimations } from '../use-synchronize-animations/use-synchronize-animations.js'; 14 - import { cx } from '../utils/index.js'; 14 + import { VisuallyHidden } from '../visually-hidden/index.js'; 15 15 16 16 interface LoadingSpinnerVariantProps extends NonNullable<styles.LoadingSpinnerVariants> {} 17 17 ··· 22 22 size?: LoadingSpinnerVariantProps['size']; 23 23 } 24 24 25 - type _LoadingSpinnerOmit = DistributiveOmit< 26 - ComponentProps<'div'>, 27 - 'aria-valuemax' | 'aria-valuemin' | 'aria-valuenow' | 'color' | 'role' 28 - >; 25 + type _LoadingSpinnerOmit = DistributiveOmit<ComponentProps<'span'>, 'color' | 'role'>; 29 26 30 27 interface _LoadingSpinnerProps extends _LoadingSpinnerOmit, LoadingSpinnerStyleProps { 28 + /** Content to show once loading finishes. While loading, the spinner replaces it in place. */ 29 + children?: ReactNode; 31 30 /** 32 - * Max value for determinate mode. 33 - * @default 100 31 + * Whether the spinner is shown in place of `children`. 32 + * @default true 34 33 */ 35 - maxValue?: number; 36 - /** 37 - * Min value for determinate mode. 38 - * @default 0 39 - */ 40 - minValue?: number; 41 - /** Current value. Omit for indeterminate mode. */ 42 - value?: number; 34 + isLoading?: boolean; 43 35 } 44 36 45 37 /** ··· 49 41 */ 50 42 export type LoadingSpinnerProps = Prettify<_LoadingSpinnerProps>; 51 43 52 - /** Progress spinner for determinate or indeterminate loading state. */ 53 - export function LoadingSpinner(props: LoadingSpinnerProps) { 44 + /** Animated spinner shown while work is in progress. Wrap content in it to show the spinner in place of that content until loading finishes. */ 45 + export function LoadingSpinner(props: LoadingSpinnerProps): ReactNode { 54 46 const { 55 - 'aria-label': ariaLabel = 'pending', 47 + 'aria-label': ariaLabel = 'loading', 48 + children, 56 49 className, 57 50 color, 58 - maxValue = 100, 59 - minValue = 0, 51 + isLoading = true, 60 52 size, 61 53 style, 62 - value, 63 - ...divProps 54 + ...spanProps 64 55 } = props; 65 56 66 57 const contextSize = useIconSizeContext(); 67 58 const resolvedSize = size ?? contextSize ?? 'medium'; 68 59 69 - const hasValue = value !== undefined && value !== null; 70 - const normalizedMin = Math.min(minValue, maxValue); 71 - const normalizedMax = Math.max(minValue, maxValue); 72 - const clampedRange = Math.max(normalizedMax - normalizedMin, 1); 73 - const clampedValue = hasValue ? clamp(value, normalizedMin, normalizedMax) : 0; 74 - const progress = ((clampedValue - normalizedMin) / clampedRange) * 100; 75 - const dashOffset = 100 - progress; 76 - const mode = hasValue ? 'determinate' : 'indeterminate'; 60 + if (!isLoading) return children; 77 61 78 - useSynchronizeAnimations(mode === 'indeterminate' ? styles.spinAnimationName : null); 79 - useSynchronizeAnimations(mode === 'indeterminate' ? styles.rubberBandAnimationName : null); 62 + const spinnerElement = ( 63 + <SpinnerElement 64 + {...spanProps} 65 + aria-label={ariaLabel} 66 + color={color} 67 + size={resolvedSize} 68 + className={className} 69 + style={style} 70 + /> 71 + ); 72 + 73 + if (!children) return spinnerElement; 74 + 75 + const slots = styles.loadingSpinner(); 80 76 81 77 return ( 82 - <div 83 - {...divProps} 84 - aria-label={ariaLabel} 85 - aria-valuemax={maxValue} 86 - aria-valuemin={minValue} 87 - aria-valuenow={hasValue ? clampedValue : undefined} 88 - className={cx( 89 - styles.spinner({ color, size: resolvedSize }), 90 - styles.spinnerState({ mode }), 91 - className, 92 - )} 93 - role="progressbar" 78 + <span className={slots.childrenWrapper()}> 79 + <span aria-hidden className={slots.hiddenChildren()} inert> 80 + {children} 81 + </span> 82 + <span className={slots.spinnerOverlay()}>{spinnerElement}</span> 83 + </span> 84 + ); 85 + } 86 + 87 + type SpinnerElementProps = DistributiveOmit<LoadingSpinnerProps, 'children' | 'isLoading'>; 88 + 89 + function SpinnerElement({ 90 + 'aria-label': ariaLabel, 91 + className, 92 + color, 93 + size, 94 + style, 95 + ...spanProps 96 + }: SpinnerElementProps) { 97 + useSynchronizeAnimations(styles.spinAnimationName); 98 + useSynchronizeAnimations(styles.rubberBandAnimationName); 99 + 100 + const labelId = useId(); 101 + const slots = styles.loadingSpinner({ color, size }); 102 + const viewBoxCenter = ICON_VIEWBOX_SIZE / 2; 103 + 104 + return ( 105 + <span 106 + {...spanProps} 107 + aria-labelledby={labelId} 108 + className={slots.root(className)} 109 + role="status" 94 110 style={style} 95 111 > 96 - <svg aria-hidden="true" className={styles.svg()} fill="none" viewBox={ICON_VIEWBOX}> 112 + <VisuallyHidden id={labelId}>{ariaLabel}</VisuallyHidden> 113 + <svg aria-hidden="true" className={slots.svg()} fill="none" viewBox={ICON_VIEWBOX}> 97 114 <circle 98 - className={styles.indicator({ mode })} 99 - cx={ICON_VIEWBOX_SIZE / 2} 100 - cy={ICON_VIEWBOX_SIZE / 2} 115 + className={slots.indicator()} 116 + cx={viewBoxCenter} 117 + cy={viewBoxCenter} 101 118 fill="none" 102 119 pathLength={100} 103 120 r={SPINNER_CIRCLE_RADIUS} 104 121 stroke="currentColor" 105 - strokeDashoffset={hasValue ? dashOffset : undefined} 106 122 strokeWidth={SPINNER_STROKE_WIDTH} 107 123 /> 108 124 </svg> 109 - </div> 125 + </span> 110 126 ); 111 127 }
+20 -66
packages/@luke-ui/react/src/loading-spinner/loading-spinner.stories.tsx
··· 12 12 title: 'Feedback/LoadingSpinner', 13 13 }); 14 14 15 - const baseArgs = { 16 - 'aria-label': 'pending', 17 - } as const satisfies Partial<LoadingSpinnerProps>; 18 - 19 15 const flexRowStyle = { 20 16 display: 'flex', 21 17 gap: '1rem', ··· 29 25 } as const satisfies CSSProperties; 30 26 31 27 /** 32 - * Use an indeterminate spinner when completion time is unknown. 28 + * The spinner shows an animated loading indicator. 33 29 */ 34 30 export const Default = meta.story({ 35 - args: baseArgs, 36 31 play: async ({ canvas }) => { 37 - await expect(canvas.getByRole('progressbar', { name: 'pending' })).toBeInTheDocument(); 38 - }, 39 - }); 40 - 41 - /** Use a value when progress can be measured against a known range. */ 42 - export const Determinate = meta.story({ 43 - args: { 44 - 'aria-label': 'Uploading', 45 - maxValue: 200, 46 - minValue: 100, 47 - value: 175, 48 - }, 49 - play: async ({ canvas }) => { 50 - await expect(canvas.getByRole('progressbar', { name: 'Uploading' })).toHaveAttribute( 51 - 'aria-valuenow', 52 - '175', 53 - ); 32 + await expect(canvas.getByRole('status', { name: 'loading' })).toBeInTheDocument(); 54 33 }, 55 34 }); 56 35 ··· 60 39 * Size adjusts the spinner footprint for compact and standard layouts. 61 40 */ 62 41 export const Size = meta.story({ 63 - args: baseArgs, 64 42 render: (props) => ( 65 43 <div style={flexRowStyle}> 66 44 {sizes.map((size) => ( ··· 76 54 * Spinner color can use semantic content roles, or inherit its parent's color when omitted. 77 55 */ 78 56 export const Color = meta.story({ 79 - args: baseArgs, 80 57 play: async ({ canvas }) => { 81 - const inherited = canvas.getByRole('progressbar', { name: 'Inherited accent' }); 58 + const inherited = canvas.getByRole('status', { name: 'Inherited accent' }); 82 59 const parent = inherited.parentElement; 83 60 if (!parent) throw new Error('Expected spinner parent.'); 84 61 ··· 97 74 }); 98 75 99 76 /** 100 - * All mounted indeterminate spinners rotate in sync, even when they mount at different times. 77 + * Wrap content in `LoadingSpinner` to show the spinner in its place while `isLoading` is `true`. 78 + * Interactive descendants become unavailable until loading finishes. 101 79 */ 102 - export const Synchronized = meta.story({ 103 - args: baseArgs, 104 - play: async ({ canvas, canvasElement }) => { 105 - const [first] = findSpinAnimations(canvasElement); 106 - if (!first) throw new Error('Expected a spin CSS animation.'); 107 - // Pause so currentTime holds at 400 instead of drifting while we wait for the sync to run. 108 - first.pause(); 109 - first.currentTime = 400; 80 + export const Children = meta.story({ 81 + play: async ({ canvas }) => { 82 + await expect(canvas.getByRole('status', { name: 'loading' })).toBeInTheDocument(); 83 + await expect(canvas.queryByRole('button', { name: 'Save' })).toBeNull(); 110 84 111 - await userEvent.click(canvas.getByRole('button', { name: 'Mount another spinner' })); 85 + await userEvent.click(canvas.getByRole('button', { name: 'Toggle loading' })); 112 86 113 - const [, second] = findSpinAnimations(canvasElement); 114 - if (!second) throw new Error('Expected a second spin CSS animation.'); 115 - // Pause before the sync runs so its result can't drift away from 400 while we wait below, 116 - // no matter how many frames a loaded CI runner takes to get to it. 117 - second.pause(); 118 - 119 - await new Promise(requestAnimationFrame); 120 - await new Promise(requestAnimationFrame); 121 - await expect(second.currentTime).toBe(400); 87 + await expect(canvas.queryByRole('status', { name: 'loading' })).toBeNull(); 88 + await expect(canvas.getByRole('button', { name: 'Save' })).toBeInTheDocument(); 122 89 }, 123 - render: (props) => <StaggeredSpinners {...props} />, 90 + render: () => <ToggleableChildren />, 124 91 }); 125 92 126 - function StaggeredSpinners(props: LoadingSpinnerProps) { 127 - const [spinnerCount, setSpinnerCount] = useState(1); 93 + function ToggleableChildren() { 94 + const [loading, setLoading] = useState(true); 128 95 129 96 return ( 130 97 <div style={flexStackStyle}> 131 - <div style={flexRowStyle}> 132 - {Array.from({ length: spinnerCount }, (_, index) => ( 133 - <LoadingSpinner key={index} {...props} /> 134 - ))} 135 - </div> 136 - <button onClick={() => setSpinnerCount((count) => count + 1)} type="button"> 137 - Mount another spinner 98 + <LoadingSpinner isLoading={loading}> 99 + <button type="button">Save</button> 100 + </LoadingSpinner> 101 + <button onClick={() => setLoading((current) => !current)} type="button"> 102 + Toggle loading 138 103 </button> 139 104 </div> 140 105 ); 141 - } 142 - 143 - function findSpinAnimations(root: Element): Array<CSSAnimation> { 144 - return root.getAnimations({ subtree: true }).filter((animation): animation is CSSAnimation => { 145 - return ( 146 - animation instanceof CSSAnimation && 147 - animation.effect instanceof KeyframeEffect && 148 - animation.effect.target instanceof HTMLElement && 149 - animation.effect.target.getAttribute('role') === 'progressbar' 150 - ); 151 - }); 152 106 }
+18 -5
packages/@luke-ui/react/src/loading-spinner/loading-spinner.visual.test.tsx
··· 20 20 const sizes = variantValuesFor<typeof LoadingSpinner, 'size'>()(['small', 'medium', 'large']); 21 21 const colors = variantValuesFor<typeof LoadingSpinner, 'color'>()(['primary', 'info', 'danger']); 22 22 23 - test('sizes colors and modes', async () => { 23 + const fixedChildStyle = { 24 + blockSize: '2.5rem', 25 + inlineSize: '8rem', 26 + } satisfies CSSProperties; 27 + 28 + test('sizes and colors', async () => { 24 29 const locator = renderVisual( 25 30 <Stack> 26 31 <div style={rowStyle}> ··· 34 39 ))} 35 40 </div> 36 41 <div style={rowStyle}> 37 - <LoadingSpinner aria-label="25 percent" value={25} /> 38 - <LoadingSpinner aria-label="75 percent" value={75} /> 42 + <LoadingSpinner aria-label="loading fixed size button"> 43 + <button style={fixedChildStyle} type="button"> 44 + Save 45 + </button> 46 + </LoadingSpinner> 47 + <LoadingSpinner aria-label="loaded fixed size button" isLoading={false}> 48 + <button style={fixedChildStyle} type="button"> 49 + Save 50 + </button> 51 + </LoadingSpinner> 39 52 </div> 40 53 </Stack>, 41 54 ); 42 55 43 - await captureVisual(locator, 'loading-spinner/sizes-colors-modes'); 56 + await captureVisual(locator, 'loading-spinner/sizes-and-colors'); 44 57 }); 45 58 46 59 test.each(visualAppearances)('theme matrix: $theme $mode', async (appearance) => { ··· 51 64 <LoadingSpinner aria-label="Root theme pending" style={spinnerStyle} /> 52 65 </ThemeMatrixScope> 53 66 <ThemeMatrixScope label="Opposite mode" mode={oppositeMode}> 54 - <LoadingSpinner aria-label="Opposite mode theme" style={spinnerStyle} value={65} /> 67 + <LoadingSpinner aria-label="Opposite mode theme" style={spinnerStyle} /> 55 68 </ThemeMatrixScope> 56 69 </div>, 57 70 appearance,
+2 -1
packages/@luke-ui/react/src/recipes/index.ts
··· 15 15 export { link } from '../recipes/link.css.js'; 16 16 export { loadingSkeleton } from '../recipes/loading-skeleton.css.js'; 17 17 export type { LoadingSpinnerVariants } from '../recipes/loading-spinner.css.js'; 18 - export { spinner as loadingSpinner } from '../recipes/loading-spinner.css.js'; 18 + export { loadingSpinner } from '../recipes/loading-spinner.css.js'; 19 19 export type { 20 20 TextAlign, 21 21 TextColor, ··· 31 31 export { text } from '../recipes/text.css.js'; 32 32 export type { TextInputVariants } from '../recipes/text-input.css.js'; 33 33 export { textInput } from '../recipes/text-input.css.js'; 34 + export { visuallyHidden } from '../recipes/visually-hidden.css.js';
+1
packages/@luke-ui/react/src/recipes/layers.css.ts
··· 9 9 import './loading-spinner.css.js'; 10 10 import './text.css.js'; 11 11 import './text-input.css.js'; 12 + import './visually-hidden.css.js';
+5 -5
packages/@luke-ui/react/src/recipes/loading-spinner.browser.test.ts
··· 1 1 import { afterEach, expect, test } from 'vite-plus/test'; 2 2 import { cdp } from 'vite-plus/test/context'; 3 - import { indicator, spinnerState } from './loading-spinner.css.js'; 3 + import { loadingSpinner } from './loading-spinner.css.js'; 4 4 5 5 const mounted: Array<Element> = []; 6 6 ··· 10 10 await setEmulatedMedia(); 11 11 }); 12 12 13 - test('indeterminate spinner uses the original rotation and rubber-band timing', () => { 13 + test('spinner uses the original rotation and rubber-band timing', () => { 14 14 const { ring, spinner } = mountSpinner(); 15 15 const spinnerStyle = getComputedStyle(spinner); 16 16 const ringStyle = getComputedStyle(ring); ··· 25 25 ['forced-colors', 'active'], 26 26 ['prefers-reduced-motion', 'reduce'], 27 27 ] as const) { 28 - test(`${name} renders an indeterminate spinner as a static partial ring`, async () => { 28 + test(`${name} renders the spinner as a static partial ring`, async () => { 29 29 await setEmulatedMedia(name, value); 30 30 const { ring, spinner } = mountSpinner(); 31 31 ··· 38 38 39 39 function mountSpinner() { 40 40 const spinner = document.body.appendChild(document.createElement('div')); 41 - spinner.className = spinnerState({ mode: 'indeterminate' }); 41 + spinner.className = loadingSpinner().root(); 42 42 const svg = spinner.appendChild(document.createElementNS('http://www.w3.org/2000/svg', 'svg')); 43 43 const ring = svg.appendChild(document.createElementNS('http://www.w3.org/2000/svg', 'circle')); 44 - ring.setAttribute('class', indicator({ mode: 'indeterminate' })); 44 + ring.setAttribute('class', loadingSpinner().indicator()); 45 45 mounted.push(spinner); 46 46 return { ring, spinner }; 47 47 }
+100 -95
packages/@luke-ui/react/src/recipes/loading-spinner.css.ts
··· 1 1 import { keyframes } from '@vanilla-extract/css'; 2 - import { styleInLayer } from '../styles/layered-style.css.js'; 3 2 import { vars } from '../theme/contract.css.js'; 4 3 import { iconSizeVariants } from './icon.css.js'; 5 - import type { RecipeSelection } from './recipe.js'; 4 + import type { RecipeSelection, SlottedConfigInput } from './recipe.js'; 6 5 import { recipe } from './recipe.js'; 7 6 8 7 const rotationDuration = '1.2s'; 9 8 const rubberBandDuration = '2s'; 10 9 const rubberBandEasing = 'cubic-bezier(0.42, 0, 0.58, 1)'; 11 10 12 - const colorVariants = { 13 - accent: { color: vars.color.intent.accent.text }, 14 - danger: { color: vars.color.intent.danger.text }, 15 - info: { color: vars.color.intent.info.text }, 16 - primary: { color: vars.color.text.primary }, 17 - secondary: { color: vars.color.text.secondary }, 18 - success: { color: vars.color.intent.success.text }, 19 - warning: { color: vars.color.intent.warning.text }, 20 - } as const; 21 - 22 - const base = styleInLayer('recipes', { 23 - color: 'currentColor', 24 - display: 'inline-flex', 25 - flexShrink: 0, 26 - }); 27 - 28 - /** Vanilla-extract recipe for the `LoadingSpinner` primitive's styles. */ 29 - export const spinner = recipe({ 30 - base, 31 - defaultVariants: { 32 - size: 'medium', 33 - }, 34 - variants: { 35 - color: colorVariants, 36 - size: iconSizeVariants, 37 - }, 38 - }); 39 - 40 - /** Variant type for the `LoadingSpinner` recipe. */ 41 - export type LoadingSpinnerVariants = RecipeSelection<typeof spinner>; 42 - 43 - /** @internal */ 11 + /** 12 + * @internal 13 + */ 44 14 export const spinAnimationName = keyframes({ 45 15 to: { transform: 'rotate(360deg)' }, 46 16 }); 47 17 48 - export const spinnerState = recipe({ 49 - defaultVariants: { 50 - mode: 'determinate', 51 - }, 52 - variants: { 53 - mode: { 54 - determinate: {}, 55 - indeterminate: { 56 - '@media': { 57 - '(forced-colors: active)': { animationName: 'none' }, 58 - '(prefers-reduced-motion: reduce)': { animationName: 'none' }, 59 - }, 60 - animationDuration: rotationDuration, 61 - animationIterationCount: 'infinite', 62 - animationName: spinAnimationName, 63 - animationTimingFunction: 'linear', 64 - }, 65 - }, 66 - }, 67 - }); 68 - 69 - export const svg = recipe({ 70 - base: { 71 - blockSize: '100%', 72 - display: 'block', 73 - inlineSize: '100%', 74 - transform: 'rotate(-90deg)', 75 - }, 76 - }); 77 - 78 - /** @internal */ 18 + /** 19 + * @internal 20 + */ 79 21 export const rubberBandAnimationName = keyframes({ 80 22 '0%': { strokeDasharray: '2 100' }, 81 23 '50%': { strokeDasharray: '65 100', strokeDashoffset: -20 }, 82 24 '100%': { strokeDasharray: '2 100', strokeDashoffset: -100 }, 83 25 }); 84 26 85 - export const indicator = recipe({ 86 - base: { 87 - strokeDasharray: '100 100', 88 - }, 89 - defaultVariants: { 90 - mode: 'determinate', 91 - }, 92 - variants: { 93 - mode: { 94 - determinate: { 95 - transitionDuration: vars.motion.duration.fast, 96 - transitionProperty: 'stroke-dashoffset', 97 - transitionTimingFunction: vars.motion.easing.exit, 27 + /** 28 + * Raw slotted config for the `LoadingSpinner` primitive. 29 + * 30 + * Slots: `root` (the animated spinner span), `svg`, `indicator` (the rubber-band 31 + * ring), and the in-place children overlay slots `childrenWrapper`, 32 + * `hiddenChildren`, and `spinnerOverlay`. 33 + */ 34 + const loadingSpinnerConfig = { 35 + slots: { 36 + root: { 37 + '@media': { 38 + '(forced-colors: active)': { animationName: 'none' }, 39 + '(prefers-reduced-motion: reduce)': { animationName: 'none' }, 98 40 }, 99 - indeterminate: { 100 - '@media': { 101 - '(forced-colors: active)': { 102 - animationName: 'none', 103 - strokeDasharray: '25 100', 104 - strokeDashoffset: 0, 105 - }, 106 - '(prefers-reduced-motion: reduce)': { 107 - animationName: 'none', 108 - strokeDasharray: '25 100', 109 - strokeDashoffset: 0, 110 - }, 41 + animationDuration: rotationDuration, 42 + animationIterationCount: 'infinite', 43 + animationName: spinAnimationName, 44 + animationTimingFunction: 'linear', 45 + color: 'currentColor', 46 + display: 'inline-flex', 47 + flexShrink: 0, 48 + }, 49 + svg: { 50 + blockSize: '100%', 51 + display: 'block', 52 + inlineSize: '100%', 53 + transform: 'rotate(-90deg)', 54 + }, 55 + indicator: { 56 + '@media': { 57 + '(forced-colors: active)': { 58 + animationName: 'none', 59 + strokeDasharray: '25 100', 60 + strokeDashoffset: 0, 111 61 }, 112 - animationDuration: rubberBandDuration, 113 - animationIterationCount: 'infinite', 114 - animationName: rubberBandAnimationName, 115 - animationTimingFunction: rubberBandEasing, 62 + '(prefers-reduced-motion: reduce)': { 63 + animationName: 'none', 64 + strokeDasharray: '25 100', 65 + strokeDashoffset: 0, 66 + }, 116 67 }, 68 + animationDuration: rubberBandDuration, 69 + animationIterationCount: 'infinite', 70 + animationName: rubberBandAnimationName, 71 + animationTimingFunction: rubberBandEasing, 72 + strokeDasharray: '100 100', 73 + }, 74 + childrenWrapper: { 75 + alignItems: 'center', 76 + display: 'inline-flex', 77 + justifyContent: 'center', 78 + position: 'relative', 79 + }, 80 + hiddenChildren: { 81 + display: 'contents', 82 + visibility: 'hidden', 83 + }, 84 + spinnerOverlay: { 85 + alignItems: 'center', 86 + display: 'flex', 87 + inset: 0, 88 + justifyContent: 'center', 89 + position: 'absolute', 117 90 }, 118 91 }, 119 - }); 92 + defaultVariants: { 93 + size: 'medium', 94 + }, 95 + variants: { 96 + color: { 97 + accent: { root: { color: vars.color.intent.accent.text } }, 98 + danger: { root: { color: vars.color.intent.danger.text } }, 99 + info: { root: { color: vars.color.intent.info.text } }, 100 + primary: { root: { color: vars.color.text.primary } }, 101 + secondary: { root: { color: vars.color.text.secondary } }, 102 + success: { root: { color: vars.color.intent.success.text } }, 103 + warning: { root: { color: vars.color.intent.warning.text } }, 104 + }, 105 + size: { 106 + large: { root: iconSizeVariants.large }, 107 + medium: { root: iconSizeVariants.medium }, 108 + small: { root: iconSizeVariants.small }, 109 + xsmall: { root: iconSizeVariants.xsmall }, 110 + }, 111 + }, 112 + } as const satisfies SlottedConfigInput; 113 + 114 + /** 115 + * Slotted recipe for the `LoadingSpinner` primitive. 116 + * 117 + * `loadingSpinner({ color, size }).root() / .svg() / .indicator()` for the spinner 118 + * itself, and `.childrenWrapper() / .hiddenChildren() / .spinnerOverlay()` for the 119 + * in-place children overlay. 120 + */ 121 + export const loadingSpinner = recipe(loadingSpinnerConfig); 122 + 123 + /** Outer variant selection for the `LoadingSpinner` recipe. */ 124 + export type LoadingSpinnerVariants = RecipeSelection<typeof loadingSpinner>;
+2 -1
packages/@luke-ui/react/src/recipes/text.css.ts
··· 5 5 import type { FontSizeStep } from '../theme/contract.js'; 6 6 import type { RecipeSelection } from './recipe.js'; 7 7 import { recipe } from './recipe.js'; 8 + import { visuallyHiddenStyle } from './visually-hidden.css.js'; 8 9 9 10 /** Typography size steps. */ 10 11 export type TextSize = FontSizeStep; ··· 174 175 }, 175 176 isVisuallyHidden: { 176 177 false: {}, 177 - true: { position: 'absolute', transform: 'scale(0)' }, 178 + true: visuallyHiddenStyle, 178 179 }, 179 180 lineClamp: lineClampVariants, 180 181 shouldDisableTrim: { false: {}, true: {} },
+26
packages/@luke-ui/react/src/recipes/visually-hidden.css.ts
··· 1 + import type { StyleRule } from '@vanilla-extract/css'; 2 + import { recipe } from './recipe.js'; 3 + 4 + /** 5 + * WCAG-standard "visually hidden" style: keeps content in the layout and the 6 + * accessibility tree as a clipped 1×1px box, rather than `display: none` / 7 + * `visibility: hidden` (which remove it from assistive technology) or 8 + * `clip-path: circle(0)` (which leaves a full-size layout box and has 9 + * questionable Safari focus-ring support). 10 + * 11 + * Shared by the `visuallyHidden` recipe and Text's `isVisuallyHidden` variant. 12 + */ 13 + export const visuallyHiddenStyle = { 14 + blockSize: '1px', // 1px, not 0: zero dimensions trip screen-reader bugs 15 + clip: 'rect(1px, 1px, 1px, 1px)', // legacy fallback for clip-path 16 + clipPath: 'inset(100%)', 17 + inlineSize: '1px', 18 + overflow: 'hidden', 19 + position: 'absolute', 20 + whiteSpace: 'nowrap', // stop text wrapping inside the 1px box 21 + } satisfies StyleRule; 22 + 23 + /** Recipe for content hidden visually but kept available to assistive technology. */ 24 + export const visuallyHidden = recipe({ 25 + base: visuallyHiddenStyle, 26 + });
+31
packages/@luke-ui/react/src/visually-hidden/index.tsx
··· 1 + import type { ComponentPropsWithRef, JSX } from 'react'; 2 + import { Text as RacText } from 'react-aria-components/Text'; 3 + import { visuallyHidden } from '../recipes/visually-hidden.css.js'; 4 + import type { Prettify } from '../types/prettify.js'; 5 + import { cx } from '../utils/index.js'; 6 + 7 + type _VisuallyHiddenProps = ComponentPropsWithRef<typeof RacText>; 8 + 9 + /** 10 + * Props for `VisuallyHidden`. 11 + * 12 + * @tier atom 13 + */ 14 + export type VisuallyHiddenProps = Prettify<_VisuallyHiddenProps>; 15 + 16 + /** 17 + * Hides its content visually while keeping it available to assistive technology. 18 + * 19 + * Use it to give assistive-technology users context conveyed visually by other 20 + * means — a text label behind an icon-only control, extra context for a link, or 21 + * a status message inside a live region. The content stays in the accessibility 22 + * tree and the document flow (unlike `display: none` or the `hidden` attribute), 23 + * so it is announced and can be referenced by `aria-labelledby`/`aria-describedby`. 24 + * 25 + * Renders a `span` by default. Pass `elementType` to render a different element 26 + * (for example `elementType="h2"` for a screen-reader-only section heading). 27 + */ 28 + export function VisuallyHidden(props: VisuallyHiddenProps): JSX.Element { 29 + const { className, ...racProps } = props; 30 + return <RacText {...racProps} className={cx(visuallyHidden(), className)} />; 31 + }
+51
packages/@luke-ui/react/src/visually-hidden/visually-hidden.stories.tsx
··· 1 + import { VisuallyHidden } from '@luke-ui/react/visually-hidden'; 2 + import { expect } from 'storybook/test'; 3 + import preview from '../../.storybook/preview.js'; 4 + 5 + const meta = preview.meta({ 6 + component: VisuallyHidden, 7 + tags: ['layout'], 8 + title: 'Layout/VisuallyHidden', 9 + }); 10 + 11 + /** 12 + * The label is hidden visually but stays in the accessibility tree, so the button 13 + * still has an accessible name and screen readers announce it. 14 + */ 15 + export const Default = meta.story({ 16 + play: async ({ canvas }) => { 17 + const button = canvas.getByRole('button', { name: 'Add to favourites' }); 18 + await expect(button).toBeInTheDocument(); 19 + 20 + const label = canvas.getByText('Add to favourites'); 21 + const style = getComputedStyle(label); 22 + await expect(style.position).toBe('absolute'); 23 + await expect(style.width).toBe('1px'); 24 + await expect(style.height).toBe('1px'); 25 + }, 26 + render: () => ( 27 + <button type="button"> 28 + <span aria-hidden="true">★</span> 29 + <VisuallyHidden>Add to favourites</VisuallyHidden> 30 + </button> 31 + ), 32 + }); 33 + 34 + /** 35 + * Pass `elementType` to render a different element while keeping the hidden styles — 36 + * here a screen-reader-only section heading, exposed to assistive technology as an `h2`. 37 + */ 38 + export const CustomElementType = meta.story({ 39 + play: async ({ canvas }) => { 40 + const heading = canvas.getByRole('heading', { name: 'Search results' }); 41 + await expect(heading).toBeInTheDocument(); 42 + await expect(heading.tagName).toBe('H2'); 43 + await expect(getComputedStyle(heading).position).toBe('absolute'); 44 + }, 45 + render: () => ( 46 + <section> 47 + <VisuallyHidden elementType="h2">Search results</VisuallyHidden> 48 + <p>10 results found.</p> 49 + </section> 50 + ), 51 + });
+18 -14
apps/docs/content/docs/components/feedback/loading-spinner.mdx
··· 3 3 description: Animated indicator for work that is still in progress. 4 4 --- 5 5 6 - Use `LoadingSpinner` when work is in progress. Omit `value` when you cannot report completion. 6 + Use `LoadingSpinner` to show an animated spinner while work is in progress. Wrap content in it to 7 + show the spinner in place of that content until loading finishes. 7 8 8 9 <ExampleBlock 9 10 src="loading-spinner/basic" 10 - title="Loading Spinner — Indeterminate" 11 - description="A spinner for work with no known completion point." 11 + title="Loading Spinner — Basic" 12 + description="A standalone spinner for work in progress." 12 13 /> 13 14 14 - Indeterminate spinners rotate and pulse in sync, including spinners that mount at different times. 15 + ## Loading children 15 16 16 - ## Progress 17 - 18 - Pass `value` when you can report progress. `minValue` and `maxValue` set the range and default to 19 - `0` and `100`. Values outside that range are clamped before the indicator is drawn. 17 + Pass `children` along with `isLoading` to show the spinner in place of that content. While 18 + `isLoading` is `true`, the spinner replaces the children but preserves their dimensions, and any 19 + interactive descendants become unavailable. When `isLoading` is `false`, the children render 20 + normally. 20 21 21 22 <ExampleBlock 22 - src="loading-spinner/progress" 23 - title="Loading Spinner — Progress" 24 - description="Indeterminate and determinate progress indicators." 23 + src="loading-spinner/children" 24 + title="Loading Spinner — Children" 25 + description="Wrap content so the spinner takes its place while loading." 25 26 /> 26 27 27 28 ## Size ··· 48 49 49 50 ## Accessibility 50 51 51 - The spinner has `progressbar` semantics. Its accessible name defaults to `pending`. Provide an 52 - `aria-label` that names the work, such as `Loading profile`. A determinate spinner also exposes its 53 - current, minimum, and maximum values. 52 + The spinner has `status` semantics, a polite live region announced by assistive technology. Its 53 + accessible name defaults to `loading`; provide an `aria-label` that names the work, such as 54 + `Loading profile`. That name is rendered as visually hidden text inside the live region, so it is 55 + announced as region content rather than relying on the region's label alone. While loading, wrapped 56 + children are hidden from assistive technology and made inert, so they cannot be focused or 57 + activated. 54 58 55 59 ## Props 56 60
+1 -1
apps/docs/content/docs/components/primitives/meta.json
··· 1 1 { 2 2 "title": "Primitives", 3 - "pages": ["button", "field", "text-input", "combobox"] 3 + "pages": ["button", "field", "text-input", "combobox", "visually-hidden"] 4 4 }
+35
apps/docs/content/docs/components/primitives/visually-hidden.mdx
··· 1 + --- 2 + title: Visually Hidden 3 + description: Hide content visually while keeping it available to assistive technology. 4 + --- 5 + 6 + Use `VisuallyHidden` to hide content from sight while keeping it in the accessibility tree and the 7 + document flow. Screen readers still announce it, and it can be referenced by 8 + `aria-labelledby`/`aria-describedby`. Unlike `display: none` or the `hidden` attribute, the content 9 + is not removed from assistive technology. 10 + 11 + Reach for it when meaning is conveyed visually but needs a text equivalent — a label behind an 12 + icon-only control, extra context for a link, or a status message inside a live region. 13 + 14 + <ExampleBlock 15 + src="visually-hidden/basic" 16 + title="Visually Hidden — Basic" 17 + description="A text equivalent that only assistive technology can perceive." 18 + /> 19 + 20 + ## Render a different element 21 + 22 + By default `VisuallyHidden` renders a `span`. Pass `elementType` to render a different element — a 23 + semantic heading, `label`, or list item — while keeping the hidden styles. No render prop or prop 24 + spreading required. 25 + 26 + ```tsx 27 + <VisuallyHidden elementType="h2">Search results</VisuallyHidden> 28 + ``` 29 + 30 + ## Props 31 + 32 + <auto-type-table 33 + path="packages/@luke-ui/react/src/visually-hidden/index.tsx" 34 + name="VisuallyHiddenProps" 35 + />