diff --git a/.changeset/lucky-moons-shave.md b/.changeset/lucky-moons-shave.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/lucky-moons-shave.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 0ab2d13a613..260229f0569 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -65,6 +65,7 @@ const docModules: Record> = { section: dynamic(() => import('../stories/section.mdx')), text: dynamic(() => import('../stories/text.mdx')), field: dynamic(() => import('../stories/field.component.mdx')), + 'visually-hidden': dynamic(() => import('../stories/visually-hidden.mdx')), }, primitives: { // Headless primitives — alphabetical. diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index d41f4e358f2..53bc5ebeef7 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -38,7 +38,11 @@ import { import { Default as DialogDefault, meta as dialogComponentMeta } from '../stories/dialog.component.stories'; import { meta as dialogMeta } from '../stories/dialog.stories'; import { meta as drawerMeta } from '../stories/drawer.stories'; -import { Default as FieldDefault, meta as fieldMeta } from '../stories/field.component.stories'; +import { + Default as FieldDefault, + meta as fieldMeta, + VisuallyHiddenLabel as FieldVisuallyHiddenLabel, +} from '../stories/field.component.stories'; import { meta as fileUploadMeta } from '../stories/file-upload.stories'; import { meta as flowMeta } from '../stories/flow.stories'; import { @@ -219,6 +223,11 @@ import { Default as UserProfileWeb3WalletsSectionDefault, meta as userProfileWeb3WalletsSectionMeta, } from '../stories/user-profile-web3-wallets-section.stories'; +import { + Default as VisuallyHiddenDefault, + LiveRegion as VisuallyHiddenLiveRegion, + meta as visuallyHiddenMeta, +} from '../stories/visually-hidden.stories'; import { toSlug } from './slug'; import type { StoryModule } from './types'; @@ -312,6 +321,13 @@ const textModule: StoryModule = { meta: textMeta, Default: TextDefault, Sizes: T const fieldModule: StoryModule = { meta: fieldMeta, Default: FieldDefault, + VisuallyHiddenLabel: FieldVisuallyHiddenLabel, +}; + +const visuallyHiddenModule: StoryModule = { + meta: visuallyHiddenMeta, + Default: VisuallyHiddenDefault, + LiveRegion: VisuallyHiddenLiveRegion, }; const iconModule: StoryModule = { @@ -513,6 +529,7 @@ export const registry: StoryModule[] = [ sectionModule, textModule, fieldModule, + visuallyHiddenModule, // Primitives — alphabetical within the group. accordionModule, autocompleteModule, diff --git a/packages/swingset/src/stories/field.component.mdx b/packages/swingset/src/stories/field.component.mdx index 3a532505d1e..6908294095a 100644 --- a/packages/swingset/src/stories/field.component.mdx +++ b/packages/swingset/src/stories/field.component.mdx @@ -35,6 +35,15 @@ Explicit `id`, `htmlFor`, `aria-labelledby`, and `aria-describedby` values remai Field does not validate controls or render errors automatically. Its parts may also be used independently without `Field.Root`. +### Visually hidden label + + + +Pass `visuallyHidden` to `Field.Label` when the surrounding UI already makes the label redundant. The label is still rendered and still associated with the control, so the control keeps its accessible name — unlike dropping the label and relying on a `placeholder`, which is not an accessible name. It applies the same style as the [VisuallyHidden](/components/visually-hidden) component. + ## Parts | Part | Stable slot class | Description | diff --git a/packages/swingset/src/stories/field.component.stories.tsx b/packages/swingset/src/stories/field.component.stories.tsx index 79a220f720e..ce642f890bf 100644 --- a/packages/swingset/src/stories/field.component.stories.tsx +++ b/packages/swingset/src/stories/field.component.stories.tsx @@ -32,3 +32,16 @@ export function Default() { ); } + +export function VisuallyHiddenLabel() { + return ( + + Search members + + + ); +} diff --git a/packages/swingset/src/stories/visually-hidden.mdx b/packages/swingset/src/stories/visually-hidden.mdx new file mode 100644 index 00000000000..39ca8d2086a --- /dev/null +++ b/packages/swingset/src/stories/visually-hidden.mdx @@ -0,0 +1,51 @@ +import * as VisuallyHiddenStories from './visually-hidden.stories'; + +# VisuallyHidden + +VisuallyHidden removes content from the page visually while leaving it in the accessibility tree, so screen readers still announce it. Reach for it when the surrounding UI already carries the meaning visually — an icon-only control, a redundant column header — or when nothing should be painted at all, as with a live region. It renders a `` by default; pass `render` where a `` is not valid in context. + +Prefer it over `display: none` or `visibility: hidden`, which remove the content from the accessibility tree too, and over `aria-label` when the text is real copy that should be translated and selectable by assistive tech. + +## Playground + + + +## Props + + ReactNode' }]} +/> + +## Usage + + + Sign out + + +--- + +## Examples + +### Live region + + + +A live region has no visual presence, so it is hidden rather than positioned. `render` supplies the `role` and `aria-live` the announcement needs. + +### Field labels + +`Field.Label` applies the same style through its own `visuallyHidden` prop, so a field whose purpose is clear from context keeps its accessible name without rendering a label. See [Field](/components/field). diff --git a/packages/swingset/src/stories/visually-hidden.stories.tsx b/packages/swingset/src/stories/visually-hidden.stories.tsx new file mode 100644 index 00000000000..6f2ae829a36 --- /dev/null +++ b/packages/swingset/src/stories/visually-hidden.stories.tsx @@ -0,0 +1,57 @@ +import { Button } from '@clerk/ui/mosaic/components/button'; +import { Icon } from '@clerk/ui/mosaic/components/icon'; +import { Text } from '@clerk/ui/mosaic/components/text'; +import type { VisuallyHiddenProps } from '@clerk/ui/mosaic/components/visually-hidden'; +import { VisuallyHidden } from '@clerk/ui/mosaic/components/visually-hidden'; +import * as React from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +// Exposes this file's own source (via the `?raw` webpack rule) so each `` example +// renders a code footer with its function's source. See `StoryModule.__source`. +export { default as __source } from './visually-hidden.stories?raw'; + +export const meta: StoryMeta = { + group: 'Components', + title: 'VisuallyHidden', + source: 'packages/ui/src/mosaic/components/visually-hidden/visually-hidden.tsx', +}; + +function knobsAsProps(props: Record) { + return props as unknown as VisuallyHiddenProps; +} + +export function Default(props: Record) { + return ( + + ); +} + +export function LiveRegion() { + const [copies, setCopies] = React.useState(0); + + return ( +
+ + Copied {copies} times + + } + > + {copies > 0 ? 'Backup code copied to clipboard' : ''} + +
+ ); +} diff --git a/packages/ui/src/mosaic/components/field/field.test.tsx b/packages/ui/src/mosaic/components/field/field.test.tsx index 6709e70e4bf..f910bd4ecec 100644 --- a/packages/ui/src/mosaic/components/field/field.test.tsx +++ b/packages/ui/src/mosaic/components/field/field.test.tsx @@ -326,6 +326,19 @@ describe('Mosaic Field', () => { expect(screen.getByText('Error').closest('p')).toHaveStyle({ fontWeight: 600 }); }); + it('hides the label visually while keeping it associated with the control', () => { + render( + + Email + + , + ); + + const label = screen.getByText('Email'); + expect(label).toHaveAttribute('data-visually-hidden', ''); + expect(screen.getByRole('textbox', { name: 'Email' })).toHaveAttribute('id', label.getAttribute('for')); + }); + it('supports render escape hatches on every part', () => { render(
}> diff --git a/packages/ui/src/mosaic/components/field/field.tsx b/packages/ui/src/mosaic/components/field/field.tsx index a8fc7a455b6..e20c5a26cd4 100644 --- a/packages/ui/src/mosaic/components/field/field.tsx +++ b/packages/ui/src/mosaic/components/field/field.tsx @@ -6,6 +6,7 @@ import type { MosaicComponentProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; import { reset } from '../../utils/reset.styles'; import { sizes as typographySizes, styles as typographyStyles } from '../../utils/typography.styles'; +import { visuallyHidden } from '../../utils/visually-hidden.styles'; import { Icon } from '../icon'; import { FieldProvider, useOptionalFieldContext, useRegisterFieldPartId } from './field.context'; import { styles } from './field.styles'; @@ -51,10 +52,13 @@ const Root = React.forwardRef(function MosaicFie }); /** Props for a native field label. */ -export type FieldLabelProps = MosaicComponentProps<'label'>; +export interface FieldLabelProps extends MosaicComponentProps<'label'> { + /** Hide the label visually while keeping it in the accessibility tree. */ + visuallyHidden?: boolean; +} const Label = React.forwardRef(function MosaicFieldLabel( - { render, className, style, id: idProp, htmlFor: htmlForProp, ...rest }, + { render, className, style, id: idProp, htmlFor: htmlForProp, visuallyHidden: isVisuallyHidden = false, ...rest }, ref, ) { const context = useOptionalFieldContext(); @@ -70,8 +74,14 @@ const Label = React.forwardRef(function Mosai ref: [ref, setLabel], props: { ...mergeStyleProps( - themeProps('field-label'), - stylex.props(reset.base, typographyStyles.base, typographySizes.sm, styles.label), + themeProps('field-label', { visuallyHidden: isVisuallyHidden }), + stylex.props( + reset.base, + typographyStyles.base, + typographySizes.sm, + styles.label, + isVisuallyHidden && visuallyHidden.base, + ), className, style, ), diff --git a/packages/ui/src/mosaic/components/visually-hidden/index.ts b/packages/ui/src/mosaic/components/visually-hidden/index.ts new file mode 100644 index 00000000000..392fcc30eae --- /dev/null +++ b/packages/ui/src/mosaic/components/visually-hidden/index.ts @@ -0,0 +1,2 @@ +export { VisuallyHidden } from './visually-hidden'; +export type { VisuallyHiddenProps } from './visually-hidden'; diff --git a/packages/ui/src/mosaic/components/visually-hidden/visually-hidden.test.tsx b/packages/ui/src/mosaic/components/visually-hidden/visually-hidden.test.tsx new file mode 100644 index 00000000000..adf6d00ba6d --- /dev/null +++ b/packages/ui/src/mosaic/components/visually-hidden/visually-hidden.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import { VisuallyHidden } from './visually-hidden'; + +describe('Mosaic VisuallyHidden', () => { + it('renders a span with its children', () => { + render(Saved); + const hidden = screen.getByText('Saved'); + expect(hidden.tagName).toBe('SPAN'); + expect(hidden).toHaveClass('cl-visually-hidden'); + }); + + it('renders a different element through the render prop, keeping the slot props', () => { + render(
}>Saved); + const hidden = screen.getByText('Saved'); + expect(hidden.tagName).toBe('DIV'); + expect(hidden).toHaveClass('cl-visually-hidden'); + }); + + it('clones an element passed to the render prop, keeping the slot props', () => { + render(}>Saved); + const hidden = screen.getByRole('heading', { name: 'Saved' }); + expect(hidden).toHaveClass('cl-visually-hidden'); + expect(hidden).toHaveAttribute('lang', 'en'); + }); + + it('forwards arbitrary props and the ref', () => { + const ref = React.createRef(); + render( + + Saved + , + ); + const hidden = screen.getByRole('status'); + expect(ref.current).toBe(hidden); + expect(hidden).toHaveAttribute('aria-live', 'polite'); + expect(hidden).toHaveClass('cl-visually-hidden', 'my-hidden'); + }); +}); diff --git a/packages/ui/src/mosaic/components/visually-hidden/visually-hidden.tsx b/packages/ui/src/mosaic/components/visually-hidden/visually-hidden.tsx new file mode 100644 index 00000000000..4dc793db439 --- /dev/null +++ b/packages/ui/src/mosaic/components/visually-hidden/visually-hidden.tsx @@ -0,0 +1,40 @@ +import { useRender } from '@clerk/headless/utils'; +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import type { MosaicComponentProps } from '../../props'; +import { mergeStyleProps, themeProps } from '../../props'; +import { reset } from '../../utils/reset.styles'; +import { visuallyHidden } from '../../utils/visually-hidden.styles'; + +export type VisuallyHiddenProps = MosaicComponentProps<'span'>; + +/** + * Content exposed to assistive technology but not painted. Renders a `span` by default and + * forwards its ref; `render` swaps the element where a `span` is not valid in context. + * + * @example + * + * + * @example + * }>{feedback} + */ +export const VisuallyHidden = React.forwardRef(function MosaicVisuallyHidden( + { render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'span', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('visually-hidden'), + stylex.props(reset.base, visuallyHidden.base), + className, + style, + ), + ...rest, + }, + }); +}); diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index c8919d9aeb7..12b27884da8 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -99,6 +99,8 @@ export { Spinner } from '../components/spinner'; export type { SpinnerProps } from '../components/spinner'; export { Text, TextContext } from '../components/text'; export type { TextProps } from '../components/text'; +export { VisuallyHidden } from '../components/visually-hidden'; +export type { VisuallyHiddenProps } from '../components/visually-hidden'; export { Popover } from '../components/popover'; export type { PopoverCloseProps, diff --git a/packages/ui/src/mosaic/utils/typography.styles.ts b/packages/ui/src/mosaic/utils/typography.styles.ts index 463c4deb187..e4731eb19d7 100644 --- a/packages/ui/src/mosaic/utils/typography.styles.ts +++ b/packages/ui/src/mosaic/utils/typography.styles.ts @@ -66,3 +66,10 @@ export const truncationStyles = stylex.create({ display: '-webkit-box', }, }); + +// Fixed-width figures, for values that change in place without shifting the layout around them. +export const tabularNumbersStyle = stylex.create({ + enabled: { + fontVariantNumeric: 'tabular-nums', + }, +}); diff --git a/packages/ui/src/mosaic/utils/typography.test.ts b/packages/ui/src/mosaic/utils/typography.test.ts new file mode 100644 index 00000000000..edc425638d7 --- /dev/null +++ b/packages/ui/src/mosaic/utils/typography.test.ts @@ -0,0 +1,20 @@ +import * as stylex from '@stylexjs/stylex'; +import { describe, expect, it } from 'vitest'; + +import { tabularNumbersStyle } from './typography.styles'; + +// StyleX generates the same atom for the same property+value across separate `create` calls, so a +// local probe names the atom to assert on without hardcoding a hash that a StyleX upgrade rewrites. +const probe = stylex.create({ + tabular: { fontVariantNumeric: 'tabular-nums' }, +}); + +const atoms = (style: stylex.StyleXStyles) => + (stylex.props(style).className ?? '').split(' ').filter(name => name && !name.includes('__')); + +describe('Mosaic typography', () => { + it('exposes tabular figures as their own style', () => { + expect(atoms(probe.tabular)).toHaveLength(1); + expect(atoms(tabularNumbersStyle.enabled)).toEqual(atoms(probe.tabular)); + }); +}); diff --git a/packages/ui/src/mosaic/utils/visually-hidden.styles.ts b/packages/ui/src/mosaic/utils/visually-hidden.styles.ts new file mode 100644 index 00000000000..5ee39c96e25 --- /dev/null +++ b/packages/ui/src/mosaic/utils/visually-hidden.styles.ts @@ -0,0 +1,22 @@ +import * as stylex from '@stylexjs/stylex'; + +// Content stays in the accessibility tree — `display: none` and `visibility: hidden` remove it. +// `clip: rect(...)` rather than `clip-path` because the deprecated property is the one every +// assistive-tech/browser combination honours. +export const visuallyHidden = stylex.create({ + base: { + margin: -1, + padding: 0, + borderStyle: 'none', + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + insetBlockStart: 0, + insetInlineStart: 0, + pointerEvents: 'none', + position: 'absolute', + userSelect: 'none', + whiteSpace: 'nowrap', + height: 1, + width: 1, + }, +}); diff --git a/packages/ui/src/mosaic/utils/visually-hidden.test.tsx b/packages/ui/src/mosaic/utils/visually-hidden.test.tsx new file mode 100644 index 00000000000..190c9c6cfd2 --- /dev/null +++ b/packages/ui/src/mosaic/utils/visually-hidden.test.tsx @@ -0,0 +1,36 @@ +import * as stylex from '@stylexjs/stylex'; +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { Field } from '../components/field'; +import { VisuallyHidden } from '../components/visually-hidden'; + +// StyleX generates the same atom for the same property+value across separate `create` calls, so a +// local probe names the atom to assert on without hardcoding a hash that a StyleX upgrade rewrites. +const probe = stylex.create({ + clipped: { clip: 'rect(0, 0, 0, 0)' }, +}); + +const clippedAtom = (stylex.props(probe.clipped).className ?? '') + .split(' ') + .filter(name => name && !name.includes('__')); + +describe('Mosaic visually hidden', () => { + it('lands on an element that composes it after the reset', () => { + expect(clippedAtom).toHaveLength(1); + + render(Loading); + + expect(screen.getByText('Loading')).toHaveClass(...clippedAtom); + }); + + it('survives the field label style chain', () => { + render( + + Email + , + ); + + expect(screen.getByText('Email')).toHaveClass(...clippedAtom); + }); +});