Files
supabase/apps/docs/features/ui/Tabs.tsx
Gildas Garcia 4b7cb27ba9 chore: refactor docs tabs (#47557)
## Problem

Now that `docs` is the only place where we use the deprecated `ui/Tabs`,
we can move this component and the related HOC from `ui-patterns` in
`docs`

## Solution

- Move `ui/Tabs`, `ui-patterns/ComplexTabs/withQueryParams` and
`ui-patterns/ComplexTabs/withSticky` to `docs`
- Refactor `ui-patterns/ComplexTabs/withQueryParams` and
`ui-patterns/ComplexTabs/withSticky` HOCs as hooks to make them easier
to understand
- Refactor `Tabs` accordingly

No visual nor functional changes.

## How to test

On
https://docs-git-chore-refactor-docs-tabs-supabase.vercel.app/docs/guides/auth/passwords
(Tabs are driven by URL and the flow tabs should have sticky headers
even though there's a CSS bug already reported)
- check that by default, the first tab in each tabs is active
- change the tabs in different groups and validate it works
- refresh the page and check that previously selected tabs are active
(URL based selection)
- In a new tab, visit
https://docs-git-chore-refactor-docs-tabs-supabase.vercel.app/docs/guides/auth/passwords
again and check that previously selected tabs are active (LocalStorage
based selection)

Do the same on
https://docs-git-chore-refactor-docs-tabs-supabase.vercel.app/docs/guides/database/database-advisors
(This one is driven by URL but does not have sticky tab headers)

Do the same on
https://docs-git-chore-refactor-docs-tabs-supabase.vercel.app/docs/guides/deployment/terraform/reference
(this one is not driven by URL nor has sticky tab headers)

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

* **New Features**
* Docs tabs now persist and restore the active tab via URL query
parameters.
* Added optional “sticky” tab behavior that keeps the active panel in
view.
  * Enhanced keyboard interaction for selecting tabs.
* **Bug Fixes**
* Improved active-tab initialization and synchronization when the URL
query changes.
* **Chores**
* Refreshed the tabs UI implementation and styling to improve
consistency and remove deprecated tab exports from shared UI packages.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-04 02:44:29 +10:00

274 lines
7.0 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 focus:outline-hidden focus-visible:ring-3 focus-visible:ring-foreground-muted focus-visible:border-foreground-muted'
),
{
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)}
>
{children}
</TabsPrimitive.Content>
)
}