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 -->
This commit is contained in:
Gildas Garcia authored and GitHub committed 2026-07-04 02:44:29 +10:00
1 parent 3ac8cf52e3
commit 4b7cb27ba9
14 files changed
+410 -482

No files matched your search

@@ -28,8 +28,6 @@ The instructions in this section assume that email confirmations are enabled.
stickyTabList={{
style: {
top: 'var(--header-height)',
backgroundColor:
'oklch(from var(--background-default) l c h / var(--tw-bg-opacity))',
maskImage: 'none',
borderBottom: '1px solid oklch(from var(--border-default) l c h / 1)',
}
+258 -28
View File
@@ -1,43 +1,273 @@
'use client'
import { useCallback, type ComponentPropsWithoutRef, type PropsWithChildren } from 'react'
import { Tabs as TabsPrimitive, type TabsProps } from 'ui'
import { withQueryParams, withSticky, type QueryParamsProps } from 'ui-patterns/ComplexTabs'
import { useTocRerenderTrigger } from '~/features/docs/GuidesMdx.state'
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'
const TabsWithStickyAndQueryParams = withSticky<PropsWithChildren<TabsProps & QueryParamsProps>>(
withQueryParams(TabsPrimitive)
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 TabPanel = TabsPrimitive.Panel
const Tabs = ({
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,
...props
}: ComponentPropsWithoutRef<typeof TabsWithStickyAndQueryParams>) => {
}: 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 onChangeInternal = useCallback(
(...args: Parameters<typeof onChange>) => {
rerenderToc()
onChange?.(...args)
},
[rerenderToc, onChange]
const [activeTab, setActiveTab] = useState(
queryTab ??
activeId ??
defaultActiveId ??
// if no defaultActiveId is set use the first panel
childrenArr?.[0]?.props?.id
)
if (stickyTabList && !stickyTabList.scrollMarginTop) {
// Magic number is the height of tab list + paragraph margin, worth getting
// rid of this?
stickyTabList.scrollMarginTop = 'calc(var(--header-height) + 43px + 20px)'
}
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 (
<TabsWithStickyAndQueryParams
wrappable
onChange={onChangeInternal}
stickyTabList={stickyTabList}
{...props}
/>
<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>
)
}
export { TabPanel, Tabs }
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>
)
}
+45
View File
@@ -0,0 +1,45 @@
'use client'
import { useCallback, useMemo, type FC } from 'react'
import { useSticky } from './useStickyTabs.utils'
export type UseStickyTabsOptions = { scrollMarginTop?: string; style?: CSSStyleDeclaration }
export const useStickyTabs = (options?: UseStickyTabsOptions) => {
const enabled = !!options
const { scrollMarginTop, style } = options || {}
const { inView, observedRef, stickyRef } = useSticky<HTMLDivElement>({
enabled,
style,
})
const onTabSelected = useCallback(() => {
if (enabled && inView && stickyRef.current) {
let elem = stickyRef.current as Element | null
while (elem && !elem.matches('[role="tabpanel"][data-state="active"]')) {
elem = elem.nextElementSibling
}
if (!elem) return
const top = elem.getBoundingClientRect().top
;(elem as HTMLElement).style.scrollMarginTop = scrollMarginTop || '0px'
if (top < 0) {
elem.scrollIntoView({
behavior: window.matchMedia('(prefers-reduced-motion: no-preference)').matches
? 'smooth'
: 'instant',
})
}
}
}, [enabled, inView, scrollMarginTop, stickyRef])
return useMemo(
() => ({
observedRef,
stickyRef,
onTabSelected,
}),
[observedRef, stickyRef, onTabSelected]
)
}
@@ -0,0 +1,103 @@
'use client'
import { useSearchParamsShallow } from 'common'
import { xor } from 'lodash-es'
import { useCallback, useEffect, useMemo, useRef } from 'react'
const LOCAL_STORAGE_KEY = 'supabase.ui-patterns.ComplexTabs.withQueryParams.v0'
export interface UseTabsWithQueryParamsOptions {
tabIds: string[]
queryGroup?: string
}
/**
* Wraps the basic `Tabs` component from the `ui` library so it stores
* selection state in query params.
*/
export const useTabsWithQueryParams = ({ tabIds, queryGroup }: UseTabsWithQueryParamsOptions) => {
// Store in ref to avoid stale data in later timeout
const tabIdsRef = useRef(tabIds)
tabIdsRef.current = tabIds
// Store in ref to avoid stale data in later timeout
const queryGroupRef = useRef(queryGroup)
queryGroupRef.current = queryGroup
const searchParams = useSearchParamsShallow()
const queryTabMaybe = queryGroupRef.current && searchParams.get(queryGroupRef.current)
const queryTab =
queryTabMaybe && tabIdsRef.current.includes(queryTabMaybe) ? queryTabMaybe : undefined
const onTabSelected = useCallback(
(id: string) => {
if (queryGroupRef.current) {
if (!searchParams.getAll('queryGroups').includes(queryGroupRef.current)) {
searchParams.append('queryGroups', queryGroupRef.current)
}
searchParams.set(queryGroupRef.current, id)
}
},
[searchParams]
)
const checkedLocalStorage = useRef(false)
useEffect(() => {
let timeout: number | undefined
if (!checkedLocalStorage.current) {
// Timeout to avoid something (I think the router) overwriting it
timeout = window.setTimeout(() => {
if (
queryGroupRef.current &&
!new URLSearchParams(window.location.search).has(queryGroupRef.current)
) {
try {
const storedValues = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) ?? '')
if (storedValues === null || typeof storedValues !== 'object') return
let storedValue: any = null
let maxDiff = tabIdsRef.current.length
Object.entries(storedValues).forEach(([key, value]) => {
const arr = key.split(',')
const diff = xor(arr, tabIdsRef.current)
if (diff.length < maxDiff) {
maxDiff = diff.length
storedValue = value
}
})
if (storedValue && tabIdsRef.current.includes(storedValue)) {
onTabSelected(storedValue)
}
} catch {
// ignore
}
}
}, 300)
checkedLocalStorage.current = true
}
if (queryGroupRef.current && queryTab) {
let updatedValues: Record<string, unknown> = {}
try {
const oldValues = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) ?? '')
if (oldValues && typeof oldValues === 'object') {
updatedValues = oldValues
}
} catch {
// ignore
}
updatedValues[tabIdsRef.current.sort().join(',')] = queryTab
try {
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(updatedValues))
} catch {
// ignore
}
}
}, [queryTab, onTabSelected])
return useMemo(() => ({ queryTab, onTabSelected }), [queryTab, onTabSelected])
}
+1
View File
@@ -72,6 +72,7 @@
"acorn-typescript": "^1.4.13",
"ai-commands": "workspace:*",
"beautiful-mermaid": "^1.1.3",
"class-variance-authority": "^0.7.1",
"common": "workspace:*",
"common-tags": "^1.8.2",
"config": "workspace:*",
-16
View File
@@ -250,22 +250,6 @@
"import": "./src/CommandMenu/prepackaged/shared/constants.ts",
"types": "./src/CommandMenu/prepackaged/shared/constants.ts"
},
"./ComplexTabs": {
"import": "./src/ComplexTabs/index.tsx",
"types": "./src/ComplexTabs/index.tsx"
},
"./ComplexTabs/withQueryParams": {
"import": "./src/ComplexTabs/withQueryParams.tsx",
"types": "./src/ComplexTabs/withQueryParams.tsx"
},
"./ComplexTabs/withSticky": {
"import": "./src/ComplexTabs/withSticky.tsx",
"types": "./src/ComplexTabs/withSticky.tsx"
},
"./ComplexTabs/withSticky.utils": {
"import": "./src/ComplexTabs/withSticky.utils.ts",
"types": "./src/ComplexTabs/withSticky.utils.ts"
},
"./ComputeBadge": {
"import": "./src/ComputeBadge/index.tsx",
"types": "./src/ComputeBadge/index.tsx"
@@ -1,2 +0,0 @@
export * from './withQueryParams'
export * from './withSticky'
@@ -1,131 +0,0 @@
'use client'
import { useSearchParamsShallow } from 'common'
import { xor } from 'lodash'
import { Children, isValidElement, useEffect, useRef, type FC, type PropsWithChildren } from 'react'
import { type TabsProps } from 'ui'
const isString = (maybeStr: unknown): maybeStr is string => typeof maybeStr === 'string'
const LOCAL_STORAGE_KEY = 'supabase.ui-patterns.ComplexTabs.withQueryParams.v0'
interface QueryParamsProps {
queryGroup?: string
}
/**
* Wraps the basic `Tabs` component from the `ui` library so it stores
* selection state in query params.
*/
const withQueryParams =
<Props extends PropsWithChildren<TabsProps>>(
Component: FC<Omit<Props, 'children' | 'queryGroup' | 'onClick'>>
) =>
({
children: childrenUnvalidated,
queryGroup: queryGroupTemp,
onClick,
...props
}: Props & QueryParamsProps) => {
// Avoid Children.toArray — it clones elements (accessing element.ref) which
// triggers a React 19 warning. Children.forEach iterates without cloning.
const tabIdsTemp: string[] = []
Children.forEach(childrenUnvalidated, (child) => {
if (isValidElement(child) && isString((child.props as any).id)) {
tabIdsTemp.push((child.props as any).id)
}
})
// Store in ref to avoid stale data in later timeout
const tabIdsRef = useRef(tabIdsTemp)
tabIdsRef.current = tabIdsTemp
// Store in ref to avoid stale data in later timeout
const queryGroupRef = useRef(queryGroupTemp)
queryGroupRef.current = queryGroupTemp
const searchParams = useSearchParamsShallow()
const queryTabMaybe = queryGroupRef.current && searchParams.get(queryGroupRef.current)
const queryTab =
queryTabMaybe && tabIdsRef.current.includes(queryTabMaybe) ? queryTabMaybe : undefined
const checkedLocalStorage = useRef(false)
useEffect(() => {
if (!checkedLocalStorage.current) {
// Timeout to avoid something (I think the router) overwriting it
setTimeout(() => {
if (
queryGroupRef.current &&
!new URLSearchParams(window.location.search).has(queryGroupRef.current)
) {
try {
const storedValues = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) ?? '')
if (storedValues === null || typeof storedValues !== 'object') return
let storedValue: any = null
let maxDiff = tabIdsRef.current.length
Object.entries(storedValues).forEach(([key, value]) => {
const arr = key.split(',')
const diff = xor(arr, tabIdsRef.current)
if (diff.length < maxDiff) {
maxDiff = diff.length
storedValue = value
}
})
if (storedValue && tabIdsRef.current.includes(storedValue)) {
switchTab(storedValue)
}
} catch {
// ignore
}
}
}, 300)
checkedLocalStorage.current = true
}
if (queryGroupRef.current && queryTab) {
let updatedValues: Record<string, unknown> = {}
try {
const oldValues = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) ?? '')
if (oldValues && typeof oldValues === 'object') {
updatedValues = oldValues
}
} catch {
// ignore
}
updatedValues[tabIdsRef.current.sort().join(',')] = queryTab
try {
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(updatedValues))
} catch {
// ignore
}
}
}, [queryTab])
const switchTab = (id: string) => {
if (queryGroupRef.current) {
if (!searchParams.getAll('queryGroups').includes(queryGroupRef.current)) {
searchParams.append('queryGroups', queryGroupRef.current)
}
searchParams.set(queryGroupRef.current, id)
}
}
const onTabClick = (id: string) => {
switchTab(id)
onClick?.(id)
}
return (
<Component {...props} activeId={queryTab} onClick={onTabClick}>
{/* Tabs does its own validation */}
{childrenUnvalidated}
</Component>
)
}
export { withQueryParams }
export type { QueryParamsProps }
@@ -1,63 +0,0 @@
'use client'
import { PropsWithChildren, useCallback, type FC } from 'react'
import { type TabsProps } from 'ui/src/components/Tabs'
import { useSticky } from './withSticky.utils'
interface StickyProps {
stickyTabList?: {
scrollMarginTop?: string
style?: CSSStyleDeclaration
}
}
/**
* Wrapper around Tabs to make the tab list sticky while the tab content is in
* view.
*/
const withSticky =
<Props extends PropsWithChildren<TabsProps>>(
Component: FC<Omit<Props, 'stickyTabList' | 'onClick'>>
) =>
({ stickyTabList, onClick, ...props }: Props & StickyProps) => {
const { inView, observedRef, stickyRef } = useSticky<HTMLDivElement>({
enabled: !!stickyTabList,
style: stickyTabList?.style,
})
const onClickInternal = useCallback(
(id: string) => {
if (stickyTabList && inView && stickyRef.current) {
let elem = stickyRef.current as Element | null
while (elem && !elem.matches('[role="tabpanel"][data-state="active"]')) {
elem = elem.nextElementSibling
}
if (!elem) return
const top = elem.getBoundingClientRect().top
;(elem as HTMLElement).style.scrollMarginTop = stickyTabList?.scrollMarginTop || '0px'
if (top < 0) {
elem.scrollIntoView({
behavior: window.matchMedia('(prefers-reduced-motion: no-preference)').matches
? 'smooth'
: 'instant',
})
}
}
onClick?.(id)
},
[!!stickyTabList, inView, onClick]
)
return (
<Component
{...props}
onClick={onClickInternal}
refs={{ base: observedRef, list: stickyRef }}
/>
)
}
export { withSticky }
-4
View File
@@ -3,10 +3,6 @@
export * from './src/components/Button'
export * from './src/components/Icon/IconBackground'
// DISPLAYS
export * from './src/components/Tabs'
// NAV
export * from './src/components/Menu'
-235
View File
@@ -1,235 +0,0 @@
'use client'
import { cva } from 'class-variance-authority'
import { Tabs as TabsPrimitive } from 'radix-ui'
import {
Children,
isValidElement,
useMemo,
useState,
type KeyboardEvent,
type PropsWithChildren,
type RefObject,
} from 'react'
import { cn } from '../../lib/utils'
export interface TabsProps {
type?: 'pills' | 'underlined' | 'cards' | 'rounded-pills'
defaultActiveId?: string
activeId?: string
size?: 'tiny' | 'small' | 'medium' | 'large' | 'xlarge'
block?: boolean
tabBarGutter?: number
tabBarStyle?: React.CSSProperties
onChange?: any
onClick?: any
scrollable?: boolean
wrappable?: boolean
addOnBefore?: React.ReactNode
addOnAfter?: React.ReactNode
listClassNames?: string
baseClassNames?: string
refs?: {
base: RefObject<HTMLDivElement> | ((elem: HTMLDivElement | null) => void)
list: RefObject<HTMLDivElement> | ((elem: HTMLDivElement | null) => void)
}
}
interface TabsSubComponents {
Panel: React.FC<PropsWithChildren<PanelProps>>
}
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',
},
],
}
)
/**
* @deprecated Use `import { Tabs_shadcn_ } from "ui"` instead
*/
const Tabs: React.FC<PropsWithChildren<TabsProps>> & TabsSubComponents = ({
defaultActiveId,
activeId,
type = 'pills',
size = 'tiny',
block,
onChange,
onClick,
scrollable,
wrappable,
addOnBefore,
addOnAfter,
listClassNames,
baseClassNames,
refs,
children: _children,
}) => {
// Children.toArray clones elements (to assign keys) and accesses element.ref
// internally, which triggers a React 19 warning. Children.forEach iterates
// without cloning, so it never touches .ref
// todo: remove this when we upgrade the radix packages to support react 19
const childrenArr: PanelPropsProps[] = []
Children.forEach(_children, (child) => {
if (isValidElement(child)) childrenArr.push(child as unknown as PanelPropsProps)
})
const children = childrenArr
const [activeTab, setActiveTab] = useState(
activeId ??
defaultActiveId ??
// if no defaultActiveId is set use the first panel
children?.[0]?.props?.id
)
useMemo(() => {
if (activeId && activeId !== activeTab) setActiveTab(activeId)
}, [activeId])
function onTabClick(id: string) {
onClick?.(id)
if (id !== activeTab) {
onChange?.(id)
setActiveTab(id)
}
}
return (
<TabsPrimitive.Root
value={activeTab}
className={cn('w-full justify-between space-y-4', baseClassNames)}
ref={refs?.base}
>
<TabsPrimitive.List
className={tabsListVariants({ type, scrollable, wrappable, className: listClassNames })}
ref={refs?.list}
>
{addOnBefore}
{children.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>
{children as any}
</TabsPrimitive.Root>
)
}
// bit of a hack because we map over the JSX in the parent component
interface PanelPropsProps {
props: PanelProps
}
interface PanelProps {
id: string
label?: string
icon?: React.ReactNode
iconRight?: React.ReactNode
className?: string
}
/**
* @deprecated Use ./TabsContent_Shadcn_ instead
*/
export const Panel: React.FC<PropsWithChildren<PanelProps>> = ({ children, id, className }) => {
return (
<TabsPrimitive.Content
value={id}
className={cn('focus:outline-hidden transition-height', className)}
>
{children}
</TabsPrimitive.Content>
)
}
Tabs.Panel = Panel
export default Tabs
@@ -1 +0,0 @@
export { default as Tabs, type TabsProps } from './Tabs'
+3
View File
@@ -384,6 +384,9 @@ importers:
beautiful-mermaid:
specifier: ^1.1.3
version: 1.1.3
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
common:
specifier: workspace:*
version: link:../../packages/common