diff --git a/apps/design-system/components/mdx-components.tsx b/apps/design-system/components/mdx-components.tsx index 97291acb5f9..28504424149 100644 --- a/apps/design-system/components/mdx-components.tsx +++ b/apps/design-system/components/mdx-components.tsx @@ -17,6 +17,7 @@ import { cn, Tabs, TabsContent, + TabsIndicator, TabsList, TabsTrigger, } from 'ui' @@ -226,16 +227,26 @@ const components = { Tabs: ({ className, ...props }: React.ComponentProps) => ( ), - TabsList: ({ className, ...props }: React.ComponentProps) => ( + TabsList: ({ className, children, ...props }: React.ComponentProps) => ( + > + {children} + + ), TabsTrigger: ({ className, ...props }: React.ComponentProps) => ( Account Password + diff --git a/apps/docs/features/ui/PromptPanel.tsx b/apps/docs/features/ui/PromptPanel.tsx index 56424f6eea8..197170cbb62 100644 --- a/apps/docs/features/ui/PromptPanel.tsx +++ b/apps/docs/features/ui/PromptPanel.tsx @@ -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 = (
{hasTabs ? ( - + {prompts.map((prompt) => ( {prompt.title} ))} + ) : ( , 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 ( - - ) => { if (e.key === 'Enter') { e.preventDefault() @@ -242,13 +259,14 @@ export const Tabs = ({ {tab.props.icon} {tab.props.label} {tab.props.iconRight} - + ) })} {addOnAfter} - + {type === 'underlined' ? : null} + {childrenArr} - + ) } @@ -263,12 +281,8 @@ interface TabPanelProps { export const TabPanel = ({ children, id, className }: TabPanelProps) => { return ( - + {children} - + ) } diff --git a/packages/config/css/animations.css b/packages/config/css/animations.css index 77e0d959e16..86ab39a14d9 100644 --- a/packages/config/css/animations.css +++ b/packages/config/css/animations.css @@ -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; diff --git a/packages/ui/index.tsx b/packages/ui/index.tsx index 8e29e0d4635..0beb615dcdb 100644 --- a/packages/ui/index.tsx +++ b/packages/ui/index.tsx @@ -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' diff --git a/packages/ui/src/components/shadcn/ui/tabs.test.tsx b/packages/ui/src/components/shadcn/ui/tabs.test.tsx new file mode 100644 index 00000000000..40a70bd98ef --- /dev/null +++ b/packages/ui/src/components/shadcn/ui/tabs.test.tsx @@ -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( + + + One + Two + {indicator ? : null} + + First + Second + + ) + +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('[role="tablist"]') + const withIndicator = renderTabs({ indicator: true }).container.querySelector( + '[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( + + + One + + + First + + ) + const list = container.querySelector('[role="tablist"]') + await waitFor(() => expect(list?.dataset.tabIndicatorReady).toBe('')) + + rerender( + + + One + + + First + + ) + + 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( + + + One + + First + + ) + + expect(ref.current).toBe(screen.getByRole('tablist')) + }) +}) diff --git a/packages/ui/src/components/shadcn/ui/tabs.tsx b/packages/ui/src/components/shadcn/ui/tabs.tsx index f6b4b0a4b36..514c549ce0b 100644 --- a/packages/ui/src/components/shadcn/ui/tabs.tsx +++ b/packages/ui/src/components/shadcn/ui/tabs.tsx @@ -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, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - ) => { + const listRef = useRef(null) + useTabIndicator(listRef) + + return ( + { + 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} + + ) +} + +const TabsIndicator = ({ className, ...props }: ComponentPropsWithRef<'span'>) => ( + -)) -TabsList.displayName = TabsPrimitive.List.displayName +) -const TabsTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( +const TabsTrigger = ({ + className, + ...props +}: ComponentPropsWithRef) => ( -)) -TabsTrigger.displayName = TabsPrimitive.Trigger.displayName +) -const TabsContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -TabsContent.displayName = TabsPrimitive.Content.displayName +const TabsContent = ({ + className, + ...props +}: ComponentPropsWithRef) => ( + +) -export { Tabs, TabsContent, TabsList, TabsTrigger } +export { Tabs, TabsContent, TabsIndicator, TabsList, TabsTrigger } diff --git a/packages/ui/src/components/shadcn/ui/useTabIndicator.ts b/packages/ui/src/components/shadcn/ui/useTabIndicator.ts new file mode 100644 index 00000000000..2dac669a7d6 --- /dev/null +++ b/packages/ui/src/components/shadcn/ui/useTabIndicator.ts @@ -0,0 +1,66 @@ +'use client' + +import { useLayoutEffect, type RefObject } from 'react' + +export const useTabIndicator = (listRef: RefObject) => { + useLayoutEffect(() => { + const list = listRef.current + if (!list || !list.querySelector(':scope > [data-tab-indicator]')) return + + let cancelled = false + + const measure = () => { + const trigger = list.querySelector(':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]) +}