mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
fix(ui): tab component (#50183)
## 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>
This commit is contained in:
1 parent
6d08a747f1
commit
38f448b01c
9 files changed
+292
-68
No files matched your search
@@ -17,6 +17,7 @@ import {
|
||||
cn,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsIndicator,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from 'ui'
|
||||
@@ -226,16 +227,26 @@ const components = {
|
||||
Tabs: ({ className, ...props }: React.ComponentProps<typeof Tabs>) => (
|
||||
<Tabs className={cn('relative mt-6 w-full', className)} {...props} />
|
||||
),
|
||||
TabsList: ({ className, ...props }: React.ComponentProps<typeof TabsList>) => (
|
||||
TabsList: ({ className, children, ...props }: React.ComponentProps<typeof TabsList>) => (
|
||||
<TabsList
|
||||
className={cn('w-full justify-start rounded-none border-b bg-transparent p-0', className)}
|
||||
className={cn(
|
||||
'w-full justify-start rounded-none bg-transparent p-0',
|
||||
'ps-4 -ms-4 [--tab-track-inset:--spacing(4)]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{children}
|
||||
<TabsIndicator />
|
||||
</TabsList>
|
||||
),
|
||||
TabsTrigger: ({ className, ...props }: React.ComponentProps<typeof TabsTrigger>) => (
|
||||
<TabsTrigger
|
||||
className={cn(
|
||||
'relative h-9 rounded-none border-b-2 border-b-transparent bg-transparent px-4 pb-3 pt-2 font-semibold text-muted-foreground shadow-none transition-none data-[state=active]:border-b-primary data-[state=active]:text-foreground data-[state=active]:shadow-none',
|
||||
'relative h-9 rounded-none bg-transparent px-4 pb-3 pt-2 font-semibold text-muted-foreground shadow-none transition-none data-[state=active]:text-foreground data-[state=active]:shadow-none',
|
||||
// The first label lines up with the surrounding content, keeping its
|
||||
// padding so the focus ring sits off the glyphs
|
||||
'first:-ms-4',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Label,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsIndicator,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from 'ui'
|
||||
@@ -20,6 +21,7 @@ export default function TabsDemo() {
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="account">Account</TabsTrigger>
|
||||
<TabsTrigger value="password">Password</TabsTrigger>
|
||||
<TabsIndicator />
|
||||
</TabsList>
|
||||
<TabsContent value="account">
|
||||
<Card>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
// shadcn tabs from packages/ui/src/components/shadcn/ui/tabs.tsx
|
||||
import { cn, copyToClipboard, Tabs, TabsContent, TabsList, TabsTrigger } from 'ui'
|
||||
import { cn, copyToClipboard, Tabs, TabsContent, TabsIndicator, TabsList, TabsTrigger } from 'ui'
|
||||
|
||||
type PromptTitleProps = {
|
||||
children: ReactNode
|
||||
@@ -248,12 +248,13 @@ function PromptPanel({ children, className }: PromptPanelProps) {
|
||||
const header = (
|
||||
<div className="flex h-11 items-center justify-between border-b bg-surface-75 px-4">
|
||||
{hasTabs ? (
|
||||
<TabsList className="h-full gap-5 border-0">
|
||||
<TabsList className="h-full gap-5 border-0 [--tab-track:transparent]">
|
||||
{prompts.map((prompt) => (
|
||||
<TabsTrigger key={prompt.value} value={prompt.value} className={tabTriggerClassName}>
|
||||
<TabLabel icon={prompt.icon}>{prompt.title}</TabLabel>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
<TabsIndicator />
|
||||
</TabsList>
|
||||
) : (
|
||||
<span
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { cva } from 'class-variance-authority'
|
||||
import { Tabs as TabsPrimitive } from 'radix-ui'
|
||||
import {
|
||||
Children,
|
||||
isValidElement,
|
||||
@@ -13,7 +12,7 @@ import {
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
} from 'react'
|
||||
import { cn } from 'ui'
|
||||
import { cn, TabsContent, TabsIndicator, TabsList, Tabs as TabsRoot, TabsTrigger } from 'ui'
|
||||
|
||||
import { useTocRerenderTrigger } from '../docs/GuidesMdx.state'
|
||||
import { useStickyTabs, UseStickyTabsOptions } from './useStickyTabs'
|
||||
@@ -39,10 +38,15 @@ export interface TabsProps {
|
||||
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',
|
||||
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',
|
||||
@@ -55,15 +59,18 @@ export const tabsListVariants = cva(cn('flex'), {
|
||||
|
||||
export const tabsTriggerListVariants = cva(
|
||||
cn(
|
||||
'relative cursor-pointer flex items-center space-x-2 text-center transition-colors focus-ring [&_img]:m-0 [&_img]:size-3.5 [&_svg]:size-3.5'
|
||||
'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: 'shadow-xs rounded-sm border',
|
||||
underlined: 'text-foreground-lighter',
|
||||
cards: '',
|
||||
'rounded-pills': 'shadow-xs rounded-full',
|
||||
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',
|
||||
@@ -94,8 +101,9 @@ export const tabsTriggerListVariants = cva(
|
||||
{
|
||||
type: 'underlined',
|
||||
isActive: true,
|
||||
className: '!text-foreground border-b-2 border-foreground',
|
||||
className: '!text-foreground',
|
||||
},
|
||||
{ type: 'underlined', className: 'py-3 first:-ms-(--tab-lead)' },
|
||||
{
|
||||
type: 'rounded-pills',
|
||||
isActive: true,
|
||||
@@ -111,6 +119,14 @@ export const tabsTriggerListVariants = cva(
|
||||
}
|
||||
)
|
||||
|
||||
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 = ({
|
||||
@@ -208,12 +224,13 @@ export const Tabs = ({
|
||||
]
|
||||
)
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
<TabsRoot
|
||||
value={activeTab}
|
||||
className={cn('w-full justify-between space-y-4', baseClassNames)}
|
||||
ref={observedRef}
|
||||
>
|
||||
<TabsPrimitive.List
|
||||
<TabsList
|
||||
style={{ '--tab-lead': TAB_LEAD[size] } as React.CSSProperties}
|
||||
className={tabsListVariants({
|
||||
type,
|
||||
scrollable,
|
||||
@@ -227,7 +244,7 @@ export const Tabs = ({
|
||||
const isActive = activeTab === tab.props.id
|
||||
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
<TabsTrigger
|
||||
onKeyDown={(e: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
@@ -242,13 +259,14 @@ export const Tabs = ({
|
||||
{tab.props.icon}
|
||||
<span>{tab.props.label}</span>
|
||||
{tab.props.iconRight}
|
||||
</TabsPrimitive.Trigger>
|
||||
</TabsTrigger>
|
||||
)
|
||||
})}
|
||||
{addOnAfter}
|
||||
</TabsPrimitive.List>
|
||||
{type === 'underlined' ? <TabsIndicator /> : null}
|
||||
</TabsList>
|
||||
{childrenArr}
|
||||
</TabsPrimitive.Root>
|
||||
</TabsRoot>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -263,12 +281,8 @@ interface TabPanelProps {
|
||||
|
||||
export const TabPanel = ({ children, id, className }: TabPanelProps) => {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
value={id}
|
||||
className={cn('focus:outline-hidden transition-height', className)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<TabsContent value={id} className={cn('mt-0 focus:outline-hidden', className)} tabIndex={-1}>
|
||||
{children}
|
||||
</TabsPrimitive.Content>
|
||||
</TabsContent>
|
||||
)
|
||||
}
|
||||
@@ -10,36 +10,39 @@
|
||||
*/
|
||||
|
||||
@theme {
|
||||
--ease-enter: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-move: cubic-bezier(0.87, 0, 0.13, 1);
|
||||
|
||||
--animate-fade-in: fadeIn 300ms both;
|
||||
--animate-fade-out: fadeOut 300ms both;
|
||||
|
||||
/* may be unused — verify before pruning */
|
||||
--animate-dropdown-content-show: overlayContentShow 100ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--animate-dropdown-content-show: overlayContentShow 100ms var(--ease-enter);
|
||||
/* may be unused — verify before pruning */
|
||||
--animate-dropdown-content-hide: overlayContentHide 100ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--animate-dropdown-content-hide: overlayContentHide 100ms var(--ease-enter);
|
||||
|
||||
--animate-overlay-show: overlayContentShow 300ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--animate-overlay-hide: overlayContentHide 300ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--animate-overlay-show: overlayContentShow 300ms var(--ease-enter);
|
||||
--animate-overlay-hide: overlayContentHide 300ms var(--ease-enter);
|
||||
|
||||
/* may be unused — verify before pruning */
|
||||
--animate-fade-in-overlay-bg: fadeInOverlayBg 300ms;
|
||||
/* may be unused — verify before pruning */
|
||||
--animate-fade-out-overlay-bg: fadeOutOverlayBg 300ms;
|
||||
|
||||
--animate-slide-down: slideDown 300ms cubic-bezier(0.87, 0, 0.13, 1);
|
||||
--animate-slide-up: slideUp 300ms cubic-bezier(0.87, 0, 0.13, 1);
|
||||
--animate-slide-down: slideDown 300ms var(--ease-move);
|
||||
--animate-slide-up: slideUp 300ms var(--ease-move);
|
||||
|
||||
--animate-slide-down-normal: slideDownNormal 300ms cubic-bezier(0.87, 0, 0.13, 1);
|
||||
--animate-slide-up-normal: slideUpNormal 300ms cubic-bezier(0.87, 0, 0.13, 1);
|
||||
--animate-slide-down-normal: slideDownNormal 300ms var(--ease-move);
|
||||
--animate-slide-up-normal: slideUpNormal 300ms var(--ease-move);
|
||||
|
||||
/* may be unused — verify before pruning */
|
||||
--animate-panel-slide-left-out: panelSlideLeftOut 200ms cubic-bezier(0.87, 0, 0.13, 1);
|
||||
--animate-panel-slide-left-out: panelSlideLeftOut 200ms var(--ease-move);
|
||||
/* may be unused — verify before pruning */
|
||||
--animate-panel-slide-left-in: panelSlideLeftIn 250ms cubic-bezier(0.87, 0, 0.13, 1);
|
||||
--animate-panel-slide-left-in: panelSlideLeftIn 250ms var(--ease-move);
|
||||
/* may be unused — verify before pruning */
|
||||
--animate-panel-slide-right-out: panelSlideRightOut 200ms cubic-bezier(0.87, 0, 0.13, 1);
|
||||
--animate-panel-slide-right-out: panelSlideRightOut 200ms var(--ease-move);
|
||||
/* may be unused — verify before pruning */
|
||||
--animate-panel-slide-right-in: panelSlideRightIn 250ms cubic-bezier(0.87, 0, 0.13, 1);
|
||||
--animate-panel-slide-right-in: panelSlideRightIn 250ms var(--ease-move);
|
||||
|
||||
--animate-line-loading: lineLoading 1.8s infinite;
|
||||
--animate-line-loading-slower: lineLoading 2.3s infinite;
|
||||
|
||||
@@ -113,6 +113,7 @@ export * from './src/components/ShadowScrollArea'
|
||||
export * from './src/components/shadcn/ui/collapsible'
|
||||
|
||||
export * from './src/components/shadcn/ui/tabs'
|
||||
export * from './src/components/shadcn/ui/useTabIndicator'
|
||||
|
||||
export * from './src/components/shadcn/ui/tooltip'
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { Tabs, TabsContent, TabsIndicator, TabsList, TabsTrigger } from './tabs'
|
||||
|
||||
const renderTabs = ({ indicator = false } = {}) =>
|
||||
render(
|
||||
<Tabs defaultValue="one">
|
||||
<TabsList>
|
||||
<TabsTrigger value="one">One</TabsTrigger>
|
||||
<TabsTrigger value="two">Two</TabsTrigger>
|
||||
{indicator ? <TabsIndicator /> : null}
|
||||
</TabsList>
|
||||
<TabsContent value="one">First</TabsContent>
|
||||
<TabsContent value="two">Second</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
|
||||
describe('Tabs', () => {
|
||||
it('borders each trigger when no indicator is rendered', () => {
|
||||
const { container } = renderTabs()
|
||||
|
||||
expect(screen.getByRole('tab', { name: 'One' })).toHaveClass('border-b-2')
|
||||
expect(container.querySelector('[data-tab-indicator]')).toBeNull()
|
||||
})
|
||||
|
||||
it('measures the active tab only for lists that render an indicator', async () => {
|
||||
const withoutIndicator = renderTabs().container.querySelector<HTMLElement>('[role="tablist"]')
|
||||
const withIndicator = renderTabs({ indicator: true }).container.querySelector<HTMLElement>(
|
||||
'[role="tablist"]'
|
||||
)
|
||||
|
||||
await waitFor(() => expect(withIndicator?.dataset.tabIndicatorReady).toBe(''))
|
||||
expect(withoutIndicator?.dataset.tabIndicatorReady).toBeUndefined()
|
||||
})
|
||||
|
||||
it('hides the underline when no trigger is active', async () => {
|
||||
const { container, rerender } = render(
|
||||
<Tabs value="one">
|
||||
<TabsList>
|
||||
<TabsTrigger value="one">One</TabsTrigger>
|
||||
<TabsIndicator />
|
||||
</TabsList>
|
||||
<TabsContent value="one">First</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
const list = container.querySelector<HTMLElement>('[role="tablist"]')
|
||||
await waitFor(() => expect(list?.dataset.tabIndicatorReady).toBe(''))
|
||||
|
||||
rerender(
|
||||
<Tabs value="gone">
|
||||
<TabsList>
|
||||
<TabsTrigger value="one">One</TabsTrigger>
|
||||
<TabsIndicator />
|
||||
</TabsList>
|
||||
<TabsContent value="one">First</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
|
||||
await waitFor(() => expect(list?.dataset.tabIndicatorReady).toBeUndefined())
|
||||
})
|
||||
|
||||
it('renders the first trigger as the first element in the list', () => {
|
||||
const { container } = renderTabs({ indicator: true })
|
||||
|
||||
expect(container.querySelector('[role="tablist"]')?.firstElementChild).toBe(
|
||||
screen.getByRole('tab', { name: 'One' })
|
||||
)
|
||||
})
|
||||
|
||||
it('passes a caller ref through to the list element', () => {
|
||||
const ref = { current: null as HTMLDivElement | null }
|
||||
|
||||
render(
|
||||
<Tabs defaultValue="one">
|
||||
<TabsList ref={ref}>
|
||||
<TabsTrigger value="one">One</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="one">First</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
|
||||
expect(ref.current).toBe(screen.getByRole('tablist'))
|
||||
})
|
||||
})
|
||||
@@ -1,46 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import { Tabs as TabsPrimitive } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
import { useRef, type ComponentPropsWithRef } from 'react'
|
||||
|
||||
import { cn } from '../../../lib/utils/cn'
|
||||
import { useTabIndicator } from './useTabIndicator'
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn('flex items-center border-b', className)}
|
||||
const trackClasses = cn(
|
||||
'has-[[data-tab-indicator]]:border-b-0',
|
||||
'has-[[data-tab-indicator]]:after:pointer-events-none has-[[data-tab-indicator]]:after:absolute',
|
||||
'has-[[data-tab-indicator]]:after:bottom-0 has-[[data-tab-indicator]]:after:h-px',
|
||||
'has-[[data-tab-indicator]]:after:left-[var(--tab-track-inset,0px)]',
|
||||
'has-[[data-tab-indicator]]:after:right-0',
|
||||
'has-[[data-tab-indicator]]:after:bg-[var(--tab-track,var(--border-default))]'
|
||||
)
|
||||
|
||||
const TabsList = ({
|
||||
className,
|
||||
children,
|
||||
ref,
|
||||
...props
|
||||
}: ComponentPropsWithRef<typeof TabsPrimitive.List>) => {
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
useTabIndicator(listRef)
|
||||
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
ref={(node) => {
|
||||
listRef.current = node
|
||||
if (typeof ref === 'function') ref(node)
|
||||
else if (ref) ref.current = node
|
||||
}}
|
||||
className={cn('group/list relative flex items-center border-b', trackClasses, className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TabsPrimitive.List>
|
||||
)
|
||||
}
|
||||
|
||||
const TabsIndicator = ({ className, ...props }: ComponentPropsWithRef<'span'>) => (
|
||||
<span
|
||||
aria-hidden
|
||||
data-tab-indicator
|
||||
className={cn(
|
||||
'pointer-events-none absolute bottom-0 left-0 h-px bg-foreground',
|
||||
'w-[var(--active-tab-width,0)] translate-x-[var(--active-tab-left,0)]',
|
||||
'transition-none opacity-0',
|
||||
'group-data-[tab-indicator-ready]/list:opacity-100',
|
||||
'group-data-[tab-indicator-ready]/list:transition-[translate,width]',
|
||||
'group-data-[tab-indicator-ready]/list:duration-[250ms]',
|
||||
'group-data-[tab-indicator-ready]/list:ease-move',
|
||||
'motion-reduce:transition-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
)
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
const TabsTrigger = ({
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithRef<typeof TabsPrimitive.Trigger>) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex cursor-pointer items-center justify-center whitespace-nowrap py-1.5 text-sm transition-colors focus-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:text-foreground data-[state=active]:shadow-xs text-foreground-lighter hover:text-foreground data-[state=active]:border-foreground border-b-2 border-transparent',
|
||||
'inline-flex cursor-pointer items-center justify-center whitespace-nowrap py-1.5 text-sm transition-colors disabled:pointer-events-none disabled:opacity-50 data-[state=active]:text-foreground data-[state=active]:shadow-xs text-foreground-lighter hover:text-foreground',
|
||||
'focus-inset',
|
||||
'border-b-2 border-b-transparent data-[state=active]:border-b-foreground',
|
||||
'group-has-[[data-tab-indicator]]/list:border-b-0',
|
||||
'group',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
)
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content ref={ref} className={cn('mt-4 focus-ring', className)} {...props} />
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
const TabsContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithRef<typeof TabsPrimitive.Content>) => (
|
||||
<TabsPrimitive.Content className={cn('mt-4 focus-ring', className)} {...props} />
|
||||
)
|
||||
|
||||
export { Tabs, TabsContent, TabsList, TabsTrigger }
|
||||
export { Tabs, TabsContent, TabsIndicator, TabsList, TabsTrigger }
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
|
||||
import { useLayoutEffect, type RefObject } from 'react'
|
||||
|
||||
export const useTabIndicator = (listRef: RefObject<HTMLElement | null>) => {
|
||||
useLayoutEffect(() => {
|
||||
const list = listRef.current
|
||||
if (!list || !list.querySelector(':scope > [data-tab-indicator]')) return
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const measure = () => {
|
||||
const trigger = list.querySelector<HTMLElement>(':scope > [role="tab"][data-state="active"]')
|
||||
if (cancelled) return
|
||||
if (!trigger) {
|
||||
delete list.dataset.tabIndicatorReady
|
||||
return
|
||||
}
|
||||
|
||||
const styles = getComputedStyle(trigger)
|
||||
const paddingLeft = parseFloat(styles.paddingLeft) || 0
|
||||
const paddingRight = parseFloat(styles.paddingRight) || 0
|
||||
|
||||
list.style.setProperty('--active-tab-left', `${trigger.offsetLeft + paddingLeft}px`)
|
||||
list.style.setProperty(
|
||||
'--active-tab-width',
|
||||
`${trigger.offsetWidth - paddingLeft - paddingRight}px`
|
||||
)
|
||||
if (list.dataset.tabIndicatorReady === undefined) {
|
||||
// makes the bar already sitting on the active tab rather than sliding in
|
||||
requestAnimationFrame(() => {
|
||||
if (!cancelled) list.dataset.tabIndicatorReady = ''
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const resizes = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(measure)
|
||||
const sync = () => {
|
||||
resizes?.disconnect()
|
||||
resizes?.observe(list)
|
||||
Array.from(list.children).forEach((child) => resizes?.observe(child))
|
||||
measure()
|
||||
}
|
||||
|
||||
sync()
|
||||
|
||||
const mutations =
|
||||
typeof MutationObserver === 'undefined' ? undefined : new MutationObserver(sync)
|
||||
mutations?.observe(list, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-state'],
|
||||
subtree: true,
|
||||
childList: true,
|
||||
})
|
||||
|
||||
// label widths shift when webfonts land
|
||||
document.fonts?.ready.then(measure).catch(() => {})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
mutations?.disconnect()
|
||||
resizes?.disconnect()
|
||||
delete list.dataset.tabIndicatorReady
|
||||
}
|
||||
}, [listRef])
|
||||
}
|
||||
Reference in new issue
Block a user