From 9f5b5ea6a758efe1ca085bbd13b9a89cd2b41e4f Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:59:40 +1000 Subject: [PATCH] fix(ui): standardise disclosure focus rings (#49660) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Bug fix and design-system accessibility update. ## What is the current behavior? Accordion and collapsible triggers do not consistently use the shared focus treatment. Accordion focus can animate from the browser outline, while call sites need to manage radius and focus geometry themselves. ## What is the new behavior? Bare accordion and collapsible triggers use the rounded `focus-inset` treatment by default, positioned 2px inside the control. Interactive children composed with `asChild`, such as a standard `Button`, retain their own focus treatment. Accordion triggers include a small default horizontal hit-area padding (`px-2`) and retain an explicit outer-ring option for standalone controls. The focus indicator is not animated. Focused tests cover the default and opt-out accordion variants. | Before | After | | --- | --- | | CleanShot 2026-09-07 at 15 40
23@2x | CleanShot 2026-09-07 at 15 39
52@2x | | CleanShot 2026-09-07 at 15 35
11@2x | CleanShot 2026-09-07 at 15 36
19@2x | ## Visual impact Most call sites should look the same at rest. The exceptions are accordion triggers that did not already set their own horizontal padding: they pick up the new default `px-2`, `rounded-md`, and lose the old blanket `transition-all` on the trigger (chevron rotation is unchanged). | Resting layout change | Why | | --- | --- | | Accordion triggers without an explicit `px-*` | Inherit new default `px-2` and `rounded-md` | | Accordion triggers with their own `px-*` | Unchanged (call-site padding wins) | | Collapsible triggers | Focus ring only; spacing comes from the child element | | Direct `focus-inset` users (table rows, etc.) | Focus ring only | ## To test Use Tab to focus each disclosure trigger. Confirm the focus outline is rounded, sits 2px inside the trigger, and appears immediately without a dark flash. Previews: [design-system](https://design-system-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app) · [studio-staging](https://studio-staging-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app) · [docs](https://docs-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app) · [www](https://zone-www-dot-com-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app) ### Design system - [Accordion](https://design-system-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app/design-system/docs/components/accordion) - [Collapsible](https://design-system-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app/design-system/docs/components/collapsible) ### Studio (focus-only unless noted) - [Account → Access tokens → **New token**](https://studio-staging-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app/dashboard/account/tokens): permission category accordions (already `px-4`) - [Database → Roles](https://studio-staging-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app/dashboard/project/_/database/roles): role row collapsibles - [Observability → Query performance](https://studio-staging-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app/dashboard/project/_/observability/query-performance): index suggestion accordions (already `px-4`) - [Settings → JWT (legacy)](https://studio-staging-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app/dashboard/project/_/settings/jwt/legacy): migration help collapsible ### Docs - Docs mobile nav (narrow viewport): accordion sections **(resting layout: +px-2)** ### WWW - [Pricing → FAQs](https://zone-www-dot-com-git-dnywh-fixaccordion-focus-rings-supabase.vercel.app/pricing): FAQ accordions **(resting layout: +px-2)** - WWW mobile nav (narrow viewport): menu accordions **(resting layout: +px-2)** ### Not worth opening manually Sheets, modals, error fallbacks, and replication/cron/integration advanced-settings accordions inherit the focus fix but need specific flows or failure states to reach. Error-state troubleshooting accordions (`TroubleshootingSections`) also gain **+px-2** when visible. ## Summary by CodeRabbit ## New Features - Accordion triggers now use an inset focus style by default, with an option to use the standard ring style. - Accordion and collapsible trigger styling can be customized more flexibly. ## Accessibility - Focus indicators appear consistently without flashing browser defaults. - Focus transitions are limited to visual properties that preserve immediate keyboard focus visibility. ## Documentation - Updated accessibility guidance explains inset focus behavior and transition considerations. --- .../content/docs/accessibility.mdx | 9 ++- packages/config/css/utilities.css | 11 ++- .../components/shadcn/ui/accordion.test.tsx | 37 +++++++++ .../ui/src/components/shadcn/ui/accordion.tsx | 78 +++++++++++-------- .../src/components/shadcn/ui/collapsible.tsx | 4 +- 5 files changed, 97 insertions(+), 42 deletions(-) create mode 100644 packages/ui/src/components/shadcn/ui/accordion.test.tsx 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 (