-
Notifications
You must be signed in to change notification settings - Fork 468
feat(ui): Add Mosaic VisuallyHidden component and tabular numbers style #9608
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| --- | ||
| --- |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 `<span>` by default; pass `render` where a `<span>` 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 | ||
|
|
||
| <Preview | ||
| name='Default' | ||
| storyModule={VisuallyHiddenStories} | ||
| /> | ||
|
|
||
| ## Props | ||
|
|
||
| <PropTable | ||
| meta={VisuallyHiddenStories.meta} | ||
| extra={[{ name: 'render', type: '(props) => ReactNode' }]} | ||
| /> | ||
|
|
||
| ## Usage | ||
|
|
||
| <Usage | ||
| component='VisuallyHidden' | ||
| module='@clerk/ui/mosaic/components/visually-hidden' | ||
| > | ||
| Sign out | ||
| </Usage> | ||
|
|
||
| --- | ||
|
|
||
| ## Examples | ||
|
|
||
| ### Live region | ||
|
|
||
| <Story | ||
| name='LiveRegion' | ||
| storyModule={VisuallyHiddenStories} | ||
| composition={[ | ||
| { name: 'Button', href: '/components/button', layer: 'Components' }, | ||
| { name: 'Text', href: '/components/text', layer: 'Components' }, | ||
| ]} | ||
| /> | ||
|
|
||
| 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). | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 `<Story>` 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<string, unknown>) { | ||
| return props as unknown as VisuallyHiddenProps; | ||
| } | ||
|
|
||
| export function Default(props: Record<string, unknown>) { | ||
| return ( | ||
| <Button shape='square'> | ||
| <Icon name='log-out' /> | ||
| <VisuallyHidden {...knobsAsProps(props)}>Sign out</VisuallyHidden> | ||
| </Button> | ||
| ); | ||
| } | ||
|
|
||
| export function LiveRegion() { | ||
| const [copies, setCopies] = React.useState(0); | ||
|
|
||
| return ( | ||
| <div style={{ alignItems: 'center', display: 'flex', gap: 12 }}> | ||
| <Button | ||
| variant='outline' | ||
| onClick={() => setCopies(count => count + 1)} | ||
| > | ||
| Copy backup code | ||
| </Button> | ||
| <Text color='neutral'>Copied {copies} times</Text> | ||
| <VisuallyHidden | ||
| render={ | ||
| <div | ||
| role='status' | ||
| aria-live='polite' | ||
| /> | ||
| } | ||
| > | ||
| {copies > 0 ? 'Backup code copied to clipboard' : ''} | ||
| </VisuallyHidden> | ||
| </div> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| export { VisuallyHidden } from './visually-hidden'; | ||
| export type { VisuallyHiddenProps } from './visually-hidden'; | ||
|
Comment on lines
+1
to
+2
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift Replace the new Move these public exports to explicit named entry modules. Update the new consumers to import from those modules. This keeps dependency direction explicit and avoids barrel-induced circular dependencies.
As per coding guidelines, “Avoid barrel files (index.ts re-exports) as they can cause circular dependencies.” 📍 Affects 2 files
🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(<VisuallyHidden>Saved</VisuallyHidden>); | ||
| 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(<VisuallyHidden render={props => <div {...props} />}>Saved</VisuallyHidden>); | ||
| 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(<VisuallyHidden render={<h1 lang='en' />}>Saved</VisuallyHidden>); | ||
| 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<HTMLSpanElement>(); | ||
| render( | ||
| <VisuallyHidden | ||
| ref={ref} | ||
| role='status' | ||
| aria-live='polite' | ||
| className='my-hidden' | ||
| > | ||
| Saved | ||
| </VisuallyHidden>, | ||
| ); | ||
| const hidden = screen.getByRole('status'); | ||
| expect(ref.current).toBe(hidden); | ||
| expect(hidden).toHaveAttribute('aria-live', 'polite'); | ||
| expect(hidden).toHaveClass('cl-visually-hidden', 'my-hidden'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| * <Button><Icon name='trash' /><VisuallyHidden>Delete</VisuallyHidden></Button> | ||
| * | ||
| * @example | ||
| * <VisuallyHidden render={<div role='status' aria-live='polite' />}>{feedback}</VisuallyHidden> | ||
| */ | ||
| export const VisuallyHidden = React.forwardRef<HTMLSpanElement, VisuallyHiddenProps>(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, | ||
| }, | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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)); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: clerk/javascript
Length of output: 8586
🏁 Script executed:
Repository: clerk/javascript
Length of output: 2983
🏁 Script executed:
Repository: clerk/javascript
Length of output: 3994
🏁 Script executed:
Repository: clerk/javascript
Length of output: 46899
🏁 Script executed:
Repository: clerk/javascript
Length of output: 278
🏁 Script executed:
Repository: clerk/javascript
Length of output: 294
🏁 Script executed:
Repository: clerk/javascript
Length of output: 10698
Document the complete
rendertype.ComponentProps<'span'>supports both((props: RenderProps) => React.ReactElement)andReact.ReactElement. Update thePropTableentry to show both forms;ReactNodeis broader than the callback contract.🤖 Prompt for AI Agents