mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
## What kind of change does this PR introduce? bug fix on tab component + a small refactor ## What is the current behavior? the active tab in an underline list gets border-b-2 while its siblings get nothing, so it's 2px taller and its label sits higher than the rest causing a smol layout shift within docs ## What is the new behavior? - adds one absolute positioned bar that slides between tabs so nothing moves - favors track under an underline as an inset shadow vs a border | state | preview | | -------|------| | before | <video src="https://github.com/user-attachments/assets/b73820a6-2994-46d1-aa98-452681f0fef2" /> | | after | <video src="https://github.com/user-attachments/assets/054cd67d-a50c-4aef-9340-a1e1d515047b" /> | ## Test 1. visit [api reference](https://docs-git-antlio-ui-components-tabs-supabase.vercel.app/docs/reference/javascript/installing?platform=npm&queryGroups=platform) 2. visit a [guide ](https://docs-git-antlio-ui-components-tabs-supabase.vercel.app/docs/guides/database/prisma) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added an animated tab indicator that follows the active tab and adapts to layout changes. - Added support for customizing tab indicator styling. - Respects reduced-motion preferences by disabling indicator transitions when appropriate. - **Style** - Streamlined tab borders, spacing, and underlined-tab styling. - Improved tab panel spacing and standardized tab behavior in documentation examples. - Centralized easing behavior for smoother overlays, dropdowns, slides, and panels. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Danny White <3104761+dnywh@users.noreply.github.com>
289 lines
7.7 KiB
TypeScript
289 lines
7.7 KiB
TypeScript
'use client'
|
|
|
|
import { cva } from 'class-variance-authority'
|
|
import {
|
|
Children,
|
|
isValidElement,
|
|
ReactElement,
|
|
ReactNode,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useState,
|
|
type KeyboardEvent,
|
|
} from 'react'
|
|
import { cn, TabsContent, TabsIndicator, TabsList, Tabs as TabsRoot, TabsTrigger } 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: 'border-b-0 space-x-1',
|
|
// fix focus ring so it is not clipped
|
|
underlined: cn(
|
|
'relative items-center',
|
|
'ps-(--tab-lead) -ms-(--tab-lead) [--tab-track-inset:var(--tab-lead)]',
|
|
'[--tab-track:var(--border-secondary)]'
|
|
),
|
|
cards: 'border-b-0',
|
|
'rounded-pills': 'border-b-0 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 gap-2 text-center transition-colors',
|
|
'focus-inset rounded-md',
|
|
'[&_img]:m-0 [&_img]:size-3.5 [&_svg]:size-3.5'
|
|
),
|
|
{
|
|
variants: {
|
|
type: {
|
|
pills: 'border-b-0 data-[state=active]:border-b-stronger shadow-xs rounded-sm border',
|
|
underlined: 'text-foreground-lighter data-[state=active]:shadow-none',
|
|
cards: 'border-b-0',
|
|
'rounded-pills':
|
|
'border-b-0 data-[state=active]:border-b-foreground 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',
|
|
},
|
|
{ type: 'underlined', className: 'py-3 first:-ms-(--tab-lead)' },
|
|
{
|
|
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 TAB_LEAD: Record<NonNullable<TabsProps['size']>, string> = {
|
|
tiny: 'calc(var(--spacing) * 2.5)',
|
|
small: 'calc(var(--spacing) * 3)',
|
|
medium: 'calc(var(--spacing) * 4)',
|
|
large: 'calc(var(--spacing) * 4)',
|
|
xlarge: 'calc(var(--spacing) * 6)',
|
|
}
|
|
|
|
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 (
|
|
<TabsRoot
|
|
value={activeTab}
|
|
className={cn('w-full justify-between space-y-4', baseClassNames)}
|
|
ref={observedRef}
|
|
>
|
|
<TabsList
|
|
style={{ '--tab-lead': TAB_LEAD[size] } as React.CSSProperties}
|
|
className={tabsListVariants({
|
|
type,
|
|
scrollable,
|
|
wrappable,
|
|
className: cn('not-prose', { 'bg-background': stickyTabList != null }, listClassNames),
|
|
})}
|
|
ref={stickyRef}
|
|
>
|
|
{addOnBefore}
|
|
{childrenArr.map((tab) => {
|
|
const isActive = activeTab === tab.props.id
|
|
|
|
return (
|
|
<TabsTrigger
|
|
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}
|
|
</TabsTrigger>
|
|
)
|
|
})}
|
|
{addOnAfter}
|
|
{type === 'underlined' ? <TabsIndicator /> : null}
|
|
</TabsList>
|
|
{childrenArr}
|
|
</TabsRoot>
|
|
)
|
|
}
|
|
|
|
interface TabPanelProps {
|
|
children: ReactNode
|
|
id: string
|
|
label?: string
|
|
icon?: React.ReactNode
|
|
iconRight?: React.ReactNode
|
|
className?: string
|
|
}
|
|
|
|
export const TabPanel = ({ children, id, className }: TabPanelProps) => {
|
|
return (
|
|
<TabsContent value={id} className={cn('mt-0 focus:outline-hidden', className)} tabIndex={-1}>
|
|
{children}
|
|
</TabsContent>
|
|
)
|
|
}
|