Files
Danny White 3bca21b3f8 chore(a11y): convert leftover focus recipes to focus-ring (#48219)
## What kind of change does this PR introduce?

Accessibility cleanup (DEPR-628).

## What is the current behavior?

Leftover call sites still use ad-hoc focus recipes
(`ring-foreground-muted`, `outline-brand`, Dialog/Sheet `focus:` rings,
etc.) instead of the shared utilities from #41575.

## What is the new behavior?

Converts those leftovers across `packages/ui`, Studio, www, docs, and
design-system to `focus-ring`, preferring `focus-visible`. Keeps
documented exceptions (`group-focus-visible`, InputGroup `:has()`).

## To test

Tab through controls (keyboard only). Expect a consistent offset ring on
`:focus-visible`, not a green/brand/custom stack, and no ring animation.

### www (marketing)

Preview:
https://zone-www-dot-com-git-danny-depr-628-focus-ring-fbccf9-supabase.vercel.app

- Global nav on `/`: Product, Developers, Solutions dropdowns; logo;
hamburger + mobile menu
- `/features`: view toggles and feature cards
- `/company`: card links
- `/changelog`: timeline / entry links
- `/partners/catalog`: grid/list toggle and partner cards
- `/pricing`: compute section expand control
- Product / Modules / Solutions sticky navs on product pages (e.g.
`/database`, `/storage`)
- `/state-of-startups`: TwoOptionToggle if present

### docs

Preview:
https://docs-git-danny-depr-628-focus-ring-long-tail-supabase.vercel.app

- Any guide page: top nav dropdowns and items
- Narrow viewport: hamburger, then mobile menu links + close
- Guide with PromptPanel / tabs: tab to prompt actions and tab list

### studio (dashboard)

Preview:
https://studio-staging-git-danny-depr-628-focus-ring-long-tail-supabase.vercel.app

- Project home: Connect section tiles; drag-handle focus on sortable
sections
- Integrations marketplace (`/project/<ref>/integrations`): featured
cards, list/grid toggle, list rows
- Auth (`/project/<ref>/auth/oauth-apps`,
`/project/<ref>/auth/providers`): open create/edit sheet, tab to close
(X)
- Database policies (`/project/<ref>/database/policies`): open policy
editor sheet, tab to close
- Storage policies (`/project/<ref>/storage/files/policies`): bucket
section links; policy modal close
- Query performance (`/project/<ref>/observability/query-performance`):
info icon buttons on metrics
- Replication pipeline detail (if available): slot lag / status info
icons
- Support (`/support/new`): attachment add/remove controls
- Table editor: spreadsheet import preview checkboxes; row text/JSON
editor TwoOptionToggle
- Any Dialog/Sheet/toast close (X): ring on keyboard focus only, not
mouse click

### design-system

Preview:
https://design-system-git-danny-depr-628-focus-ring-long-tail-supabase.vercel.app

- Colour palette swatches (keyboard focus)
- Form patterns sidepanel example: avatar / focusable control in the
example

## Additional context

- Linear: [DEPR-628](https://linear.app/supabase/issue/DEPR-628)
- Follow-ups: form-group CSS (DEPR-629), Storage columns selection
(DEPR-630), ESLint rule (DEPR-632)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Accessibility & Usability**
* Standardized keyboard focus indicators across navigation, dialogs,
forms, buttons, toggles, links, and tooltips using a consolidated focus
style.
* Improved toggle controls to use proper button semantics (instead of
clickable text), including `aria-pressed`/disabled handling and better
keyboard navigation.

* **Visual Updates**
* Harmonized hover/focus ring visuals across the design system, Studio,
documentation, and marketing pages while preserving existing layout and
interaction behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-23 08:52:22 +10:00

275 lines
6.9 KiB
TypeScript

'use client'
import { cva } from 'class-variance-authority'
import { Tabs as TabsPrimitive } from 'radix-ui'
import {
Children,
isValidElement,
ReactElement,
ReactNode,
useCallback,
useEffect,
useMemo,
useState,
type KeyboardEvent,
} from 'react'
import { cn } from 'ui'
import { useTocRerenderTrigger } from '../docs/GuidesMdx.state'
import { useStickyTabs, UseStickyTabsOptions } from './useStickyTabs'
import { useTabsWithQueryParams, UseTabsWithQueryParamsOptions } from './useTabsWithQueryParams'
export interface TabsProps {
children: ReactNode
type?: 'pills' | 'underlined' | 'cards' | 'rounded-pills'
defaultActiveId?: string
activeId?: string
size?: 'tiny' | 'small' | 'medium' | 'large' | 'xlarge'
block?: boolean
onChange?: (id: string) => void
onClick?: any
scrollable?: boolean
wrappable?: boolean
addOnBefore?: React.ReactNode
addOnAfter?: React.ReactNode
listClassNames?: string
baseClassNames?: string
}
export const tabsListVariants = cva(cn('flex'), {
variants: {
type: {
pills: 'space-x-1',
underlined: 'items-center border-b border-secondary',
cards: '',
'rounded-pills': 'flex-wrap gap-2',
},
scrollable: {
true: 'overflow-auto whitespace-nowrap no-scrollbar mask-fadeout-right',
},
wrappable: {
true: 'flex-wrap',
},
},
})
export const tabsTriggerListVariants = cva(
cn(
'relative cursor-pointer flex items-center space-x-2 text-center transition-colors focus-ring'
),
{
variants: {
type: {
pills: 'shadow-xs rounded-sm border',
underlined: 'text-foreground-lighter',
cards: '',
'rounded-pills': 'shadow-xs rounded-full',
},
size: {
tiny: 'text-xs px-2.5 py-1',
small: 'text-base md:text-sm leading-4 px-3 py-2',
medium: 'text-base md:text-sm px-4 py-2',
large: 'text-base px-4 py-2',
xlarge: 'text-base px-6 py-3',
},
block: {
true: 'w-full flex items-center justify-center',
},
isActive: {
false: 'hover:text-foreground',
},
},
compoundVariants: [
{
type: 'pills',
isActive: true,
className: 'bg-selection text-foreground border-stronger',
},
{
type: 'pills',
isActive: false,
className:
'bg-background border-strong hover:border-foreground-muted text-foreground-muted',
},
{
type: 'underlined',
isActive: true,
className: '!text-foreground border-b-2 border-foreground',
},
{
type: 'rounded-pills',
isActive: true,
className: 'bg-foreground text-background border-foreground',
},
{
type: 'rounded-pills',
isActive: false,
className:
'bg-surface-200 hover:bg-surface-300 hover:border-foreground-lighter text-foreground-lighter',
},
],
}
)
const isString = (maybeStr: unknown): maybeStr is string => typeof maybeStr === 'string'
export const Tabs = ({
defaultActiveId,
activeId,
type = 'pills',
size = 'tiny',
block,
onChange,
onClick,
scrollable,
wrappable,
addOnBefore,
addOnAfter,
listClassNames,
baseClassNames,
children,
queryGroup,
stickyTabList,
}: TabsProps &
Pick<UseTabsWithQueryParamsOptions, 'queryGroup'> & {
stickyTabList?: UseStickyTabsOptions
}) => {
const childrenArr: ReactElement<TabPanelProps>[] = []
const tabIds: string[] = []
Children.forEach(children, (child) => {
if (isValidElement<TabPanelProps>(child) && isString(child.props.id)) {
childrenArr.push(child)
tabIds.push(child.props.id)
}
})
const { queryTab, onTabSelected: onTabSelectedForQuery } = useTabsWithQueryParams({
tabIds,
queryGroup,
})
const sanitizedStickyTabList = useMemo(
() =>
stickyTabList != undefined
? {
...stickyTabList,
// Magic number is the height of tab list + paragraph margin, worth getting
// rid of this?
scrollMarginTop:
stickyTabList.scrollMarginTop || 'calc(var(--header-height) + 43px + 20px)',
}
: undefined,
[stickyTabList]
)
const {
observedRef,
stickyRef,
onTabSelected: onTabSelectedForSticky,
} = useStickyTabs(sanitizedStickyTabList)
const rerenderToc = useTocRerenderTrigger()
const [activeTab, setActiveTab] = useState(
queryTab ??
activeId ??
defaultActiveId ??
// if no defaultActiveId is set use the first panel
childrenArr?.[0]?.props?.id
)
useEffect(() => {
// If we have a queryTab, Tabs is controller by URL params
if (queryTab && queryTab !== activeTab) {
setActiveTab(queryTab)
return
}
if (activeId && activeId !== activeTab) {
setActiveTab(activeId)
}
}, [activeId, activeTab, queryTab])
const onTabClick = useCallback(
(id: string) => {
if (id !== activeTab) {
setActiveTab(id)
onTabSelectedForSticky()
onTabSelectedForQuery(id)
rerenderToc()
onChange?.(id)
onClick?.(id)
}
},
[
activeTab,
onTabSelectedForSticky,
onTabSelectedForQuery,
rerenderToc,
onChange,
onClick,
setActiveTab,
]
)
return (
<TabsPrimitive.Root
value={activeTab}
className={cn('w-full justify-between space-y-4', baseClassNames)}
ref={observedRef}
>
<TabsPrimitive.List
className={tabsListVariants({
type,
scrollable,
wrappable,
className: cn({ 'bg-background': stickyTabList != null }, listClassNames),
})}
ref={stickyRef}
>
{addOnBefore}
{childrenArr.map((tab) => {
const isActive = activeTab === tab.props.id
return (
<TabsPrimitive.Trigger
onKeyDown={(e: KeyboardEvent<HTMLButtonElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
onTabClick(tab.props.id)
}
}}
onClick={() => onTabClick(tab.props.id)}
key={`${tab.props.id}-tab-button`}
value={tab.props.id}
className={tabsTriggerListVariants({ type, isActive, size, block })}
>
{tab.props.icon}
<span>{tab.props.label}</span>
{tab.props.iconRight}
</TabsPrimitive.Trigger>
)
})}
{addOnAfter}
</TabsPrimitive.List>
{childrenArr}
</TabsPrimitive.Root>
)
}
interface TabPanelProps {
children: ReactNode
id: string
label?: string
icon?: React.ReactNode
iconRight?: React.ReactNode
className?: string
}
export const TabPanel = ({ children, id, className }: TabPanelProps) => {
return (
<TabsPrimitive.Content
value={id}
className={cn('focus:outline-hidden transition-height', className)}
tabIndex={-1}
>
{children}
</TabsPrimitive.Content>
)
}