mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 09:55:06 +03:00
## 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 -->
275 lines
6.9 KiB
TypeScript
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>
|
|
)
|
|
}
|