diff --git a/apps/design-system/content/docs/accessibility.mdx b/apps/design-system/content/docs/accessibility.mdx index efdede1da8f..c1660c5b963 100644 --- a/apps/design-system/content/docs/accessibility.mdx +++ b/apps/design-system/content/docs/accessibility.mdx @@ -59,16 +59,17 @@ focus-visible:ring-offset-background Uses `outline` (not `ring`) so it paints reliably on interactive ``s. Tailwind `ring` is `box-shadow`, which browsers often skip on `display: table-row` (notably Safari). Do not put `focus-ring` or raw `ring-*` on a ``, and do not add `outline-hidden` alongside `focus-inset`. `outline-hidden` sets `outline-style: none` and will hide the indicator. ```txt +outline: 2px solid transparent +outline-offset: -2px +transition-property: color, background-color, border-color, ... + &:focus-visible { - outline-style: solid - outline-width: 2px - outline-offset: -2px outline-color: var(--ring) border-radius: var(--radius-md) } ``` -`outline-hidden` is always on (not `focus-visible:`-prefixed) so mouse click does not show the browser’s default outline; the focus indicator replaces it for keyboard focus only. +`focus-ring` keeps `outline-hidden` always on so mouse clicks do not show the browser’s default outline. `focus-inset` reserves a transparent outline instead. Its transition property list deliberately excludes outline properties so the keyboard focus indicator appears immediately, even when a call site uses `transition-all`. Rules: diff --git a/packages/config/css/utilities.css b/packages/config/css/utilities.css index 473245def85..64ca4539e5f 100644 --- a/packages/config/css/utilities.css +++ b/packages/config/css/utilities.css @@ -198,10 +198,15 @@ } @utility focus-inset { + /* Reserve outline geometry up front so focus never flashes the browser default. */ + outline: 2px solid transparent; + outline-offset: -2px; + /* Call sites often add `transition`/`transition-all`; keep outline instant. */ + transition-property: + color, background-color, border-color, text-decoration-color, fill, stroke, opacity, transform, + filter, backdrop-filter, box-shadow; + &:focus-visible { - outline-style: solid; - outline-width: 2px; - outline-offset: -2px; outline-color: var(--ring); border-radius: var(--radius-md, 0.375rem); } diff --git a/packages/ui/src/components/shadcn/ui/accordion.test.tsx b/packages/ui/src/components/shadcn/ui/accordion.test.tsx new file mode 100644 index 00000000000..734c3e813ec --- /dev/null +++ b/packages/ui/src/components/shadcn/ui/accordion.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { Accordion, AccordionItem, AccordionTrigger } from './accordion' + +describe('AccordionTrigger', () => { + it('provides rounded geometry and the shared inset focus ring', () => { + render( + + + Advanced settings + + + ) + + const trigger = screen.getByRole('button', { name: 'Advanced settings' }) + expect(trigger).toHaveClass('px-2', 'relative', 'focus-inset') + expect(trigger).not.toHaveClass('rounded-md') + expect(trigger).not.toHaveClass('focus-ring') + expect(trigger).not.toHaveClass('transition-colors') + expect(trigger).not.toHaveClass('transition-all') + }) + + it('supports an outer focus ring when explicitly requested', () => { + render( + + + Advanced settings + + + ) + + const trigger = screen.getByRole('button', { name: 'Advanced settings' }) + expect(trigger).toHaveClass('rounded-md', 'focus-ring') + expect(trigger).not.toHaveClass('focus-inset') + }) +}) diff --git a/packages/ui/src/components/shadcn/ui/accordion.tsx b/packages/ui/src/components/shadcn/ui/accordion.tsx index 0585a052ac5..cbfe1d8bf66 100644 --- a/packages/ui/src/components/shadcn/ui/accordion.tsx +++ b/packages/ui/src/components/shadcn/ui/accordion.tsx @@ -19,41 +19,51 @@ AccordionItem.displayName = 'AccordionItem' const AccordionTrigger = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef & { hideIcon?: boolean } ->(({ className, children, hideIcon, disabled, tabIndex, ...props }, ref) => { - const computedTabIndex = getExplicitTabIndex(tabIndex, disabled) + React.ComponentPropsWithoutRef & { + focusVariant?: 'ring' | 'inset' + hideIcon?: boolean + } +>( + ( + { className, children, focusVariant = 'inset', hideIcon, disabled, tabIndex, ...props }, + ref + ) => { + const computedTabIndex = getExplicitTabIndex(tabIndex, disabled) - return ( - -
- svg]:rotate-180', - className - )} - {...props} - disabled={disabled} - tabIndex={computedTabIndex} - > - {children} - {!hideIcon && ( - -
-
- ) -}) + return ( + +
+ svg]:rotate-180', + focusVariant === 'ring' && 'rounded-md', + className, + focusVariant === 'ring' ? 'focus-ring' : 'relative focus-inset' + )} + {...props} + disabled={disabled} + tabIndex={computedTabIndex} + > + {children} + {!hideIcon && ( + +
+
+ ) + } +) AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName const AccordionContent = React.forwardRef< diff --git a/packages/ui/src/components/shadcn/ui/collapsible.tsx b/packages/ui/src/components/shadcn/ui/collapsible.tsx index c129f72c8ac..d06b8300753 100644 --- a/packages/ui/src/components/shadcn/ui/collapsible.tsx +++ b/packages/ui/src/components/shadcn/ui/collapsible.tsx @@ -3,6 +3,7 @@ import { Collapsible as CollapsiblePrimitive } from 'radix-ui' import * as React from 'react' +import { cn } from '../../../lib/utils/cn' import { getExplicitTabIndex } from '../../../lib/utils/getExplicitTabIndex' const Collapsible = CollapsiblePrimitive.Root @@ -10,12 +11,13 @@ const Collapsible = CollapsiblePrimitive.Root const CollapsibleTrigger = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ disabled, tabIndex, ...props }, ref) => { +>(({ className, disabled, tabIndex, ...props }, ref) => { const computedTabIndex = getExplicitTabIndex(tabIndex, disabled) return (