From 71a0ac8dc5ad95b71a6f4d05ee9bddf27ff2215f Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Tue, 3 Feb 2026 01:40:36 +0530 Subject: [PATCH] feat: migrate tabs --- .../www/src/components/mdx/mdx-components.tsx | 6 +- apps/www/src/components/mdx/pre-context.tsx | 7 +- .../components/playground/tabs-examples.tsx | 12 +- .../theme-customiser/theme-customiser.tsx | 10 +- .../src/content/docs/components/tabs/demo.ts | 28 ++-- .../content/docs/components/tabs/index.mdx | 14 +- .../src/content/docs/components/tabs/props.ts | 24 ++-- .../components/tabs/__tests__/tabs.test.tsx | 91 ++++++++---- packages/raystack/components/tabs/index.tsx | 2 +- .../raystack/components/tabs/tabs.module.css | 32 ++++- packages/raystack/components/tabs/tabs.tsx | 134 +++++++----------- 11 files changed, 194 insertions(+), 166 deletions(-) diff --git a/apps/www/src/components/mdx/mdx-components.tsx b/apps/www/src/components/mdx/mdx-components.tsx index fb1766f40..991e44c66 100644 --- a/apps/www/src/components/mdx/mdx-components.tsx +++ b/apps/www/src/components/mdx/mdx-components.tsx @@ -40,9 +40,9 @@ function Table(props: TableHTMLAttributes) { } const mdxComponents = { - CodeBlockTabsTrigger: ( - props: ComponentPropsWithoutRef - ) => , + CodeBlockTabsTrigger: (props: ComponentPropsWithoutRef) => ( + + ), CodeBlockTabs: (props: HTMLAttributes) => ( {props.children} diff --git a/apps/www/src/components/mdx/pre-context.tsx b/apps/www/src/components/mdx/pre-context.tsx index a9a707ed9..4c635ad27 100644 --- a/apps/www/src/components/mdx/pre-context.tsx +++ b/apps/www/src/components/mdx/pre-context.tsx @@ -1,6 +1,6 @@ 'use client'; -import { ReactNode, createContext, useContext } from 'react'; +import { createContext, ReactNode, useContext } from 'react'; const PreContext = createContext<{ hasPreParent: boolean; @@ -11,7 +11,10 @@ const PreContext = createContext<{ export const PreContextProvider = ({ children, hasPreParent -}: { children: ReactNode; hasPreParent: boolean }) => { +}: { + children: ReactNode; + hasPreParent: boolean; +}) => { return ( {children} diff --git a/apps/www/src/components/playground/tabs-examples.tsx b/apps/www/src/components/playground/tabs-examples.tsx index 2b8ef25da..8817f9308 100644 --- a/apps/www/src/components/playground/tabs-examples.tsx +++ b/apps/www/src/components/playground/tabs-examples.tsx @@ -11,11 +11,11 @@ export function TabsExamples() { - Account - + Account + Password - - Settings + + Settings Account settings Password settings @@ -25,8 +25,8 @@ export function TabsExamples() { - Home - } /> + Home + } /> Home Info diff --git a/apps/www/src/components/theme-customiser/theme-customiser.tsx b/apps/www/src/components/theme-customiser/theme-customiser.tsx index 405e6d582..ab3ef529c 100644 --- a/apps/www/src/components/theme-customiser/theme-customiser.tsx +++ b/apps/www/src/components/theme-customiser/theme-customiser.tsx @@ -1,6 +1,6 @@ 'use client'; -import { getPropsString } from '@/lib/utils'; import { Button, Radio, Tabs } from '@raystack/apsara'; +import { getPropsString } from '@/lib/utils'; import { ThemeOptions, useTheme } from '../theme'; import styles from './theme-customiser.module.css'; @@ -44,8 +44,8 @@ export default function ThemeCustomizer() { } > - Modern - Traditional + Modern + Traditional @@ -58,8 +58,8 @@ export default function ThemeCustomizer() { } > - Light - Dark + Light + Dark diff --git a/apps/www/src/content/docs/components/tabs/demo.ts b/apps/www/src/content/docs/components/tabs/demo.ts index b1a2d757f..3a4b025d8 100644 --- a/apps/www/src/content/docs/components/tabs/demo.ts +++ b/apps/www/src/content/docs/components/tabs/demo.ts @@ -6,11 +6,11 @@ export const preview = { - }>Hoisting - Hosting - }>Editor - Billing - SEO + }>Hoisting + Hosting + }>Editor + Billing + SEO General settings content @@ -37,9 +37,9 @@ export const basicDemo = {
- Account - Password - Settings + Account + Password + Settings Account settings Password settings @@ -54,11 +54,11 @@ export const iconsDemo = {
- Home - } /> + Home + }>Info - Home - Info + Home content + Info content
` }; @@ -69,8 +69,8 @@ export const disabledDemo = {
- Active - Disabled + Active + Disabled Active tab content Disabled tab content diff --git a/apps/www/src/content/docs/components/tabs/index.mdx b/apps/www/src/content/docs/components/tabs/index.mdx index 5a12685d3..a8bb98213 100644 --- a/apps/www/src/content/docs/components/tabs/index.mdx +++ b/apps/www/src/content/docs/components/tabs/index.mdx @@ -22,9 +22,9 @@ import { Tabs } from "@raystack/apsara"; -### Tabs.Trigger Props +### Tabs.Tab Props - + ### Tabs.Content Props @@ -36,18 +36,10 @@ import { Tabs } from "@raystack/apsara"; -### With Icons +### With Leading Icons ### Disabled Tab - -## Accessibility - -Tabs follow the [WAI-ARIA Tabs Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabpanel/). They include the following accessibility features: - -- Keyboard navigation between tabs using arrow keys -- Proper ARIA roles, states, and properties -- Focus management for tab panels diff --git a/apps/www/src/content/docs/components/tabs/props.ts b/apps/www/src/content/docs/components/tabs/props.ts index 6c4f2c7b3..62e6a32d6 100644 --- a/apps/www/src/content/docs/components/tabs/props.ts +++ b/apps/www/src/content/docs/components/tabs/props.ts @@ -1,12 +1,15 @@ export interface TabsRootProps { - /** The initial active tab value. If not provided, no tab will be selected by default. */ - defaultValue?: string; + /** The initial active tab value. */ + defaultValue?: any; /** The controlled active tab value. */ - value?: string; + value?: any; /** Callback function triggered when the active tab changes. */ - onValueChange?: (value: string) => void; + onValueChange?: (value: any) => void; + + /** The orientation of the tabs. */ + orientation?: 'horizontal' | 'vertical'; /** Additional CSS class names. */ className?: string; @@ -17,12 +20,12 @@ export interface TabsListProps { className?: string; } -export interface TabsTriggerProps { +export interface TabsTabProps { /** Unique identifier for the tab. */ - value: string; + value: any; - /** Optional icon element to display. */ - icon?: React.ReactNode; + /** Optional icon element to display before the label. */ + leadingIcon?: React.ReactNode; /** Whether the tab is disabled. */ disabled?: boolean; @@ -33,7 +36,10 @@ export interface TabsTriggerProps { export interface TabsContentProps { /** Matching identifier for the tab. */ - value: string; + value: any; + + /** Whether to keep the panel in the DOM while hidden. */ + keepMounted?: boolean; /** Additional CSS class names. */ className?: string; diff --git a/packages/raystack/components/tabs/__tests__/tabs.test.tsx b/packages/raystack/components/tabs/__tests__/tabs.test.tsx index 9e0de3770..66a57f98b 100644 --- a/packages/raystack/components/tabs/__tests__/tabs.test.tsx +++ b/packages/raystack/components/tabs/__tests__/tabs.test.tsx @@ -1,7 +1,8 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { ComponentPropsWithoutRef } from 'react'; import { describe, expect, it, vi } from 'vitest'; -import { Tabs, TabsRootProps } from '../tabs'; +import { Tabs } from '../tabs'; import styles from '../tabs.module.css'; const TAB_1_TEXT = 'Tab 1'; @@ -10,17 +11,19 @@ const CONTENT_1_TEXT = 'Content 1'; const CONTENT_2_TEXT = 'Content 2'; const CUSTOM_ARIA_LABEL = 'Navigation tabs'; +type TabsProps = ComponentPropsWithoutRef; + const BasicTabs = ({ defaultValue = 'tab1', hasDisabledTab = false, ...props -}: TabsRootProps & { hasDisabledTab?: boolean }) => ( +}: TabsProps & { hasDisabledTab?: boolean }) => ( - {TAB_1_TEXT} - + {TAB_1_TEXT} + {TAB_2_TEXT} - + {CONTENT_1_TEXT} {CONTENT_2_TEXT} @@ -35,7 +38,7 @@ describe('Tabs', () => { expect(screen.getByRole('tablist')).toBeInTheDocument(); }); - it('renders tab triggers', () => { + it('renders tabs', () => { render(); expect(screen.getByText(TAB_1_TEXT)).toBeInTheDocument(); @@ -106,7 +109,9 @@ describe('Tabs', () => { render(); await user.click(screen.getByText(TAB_2_TEXT)); - expect(handleChange).toHaveBeenCalledWith('tab2'); + // Base UI calls onValueChange with (value, details) - check first argument + expect(handleChange).toHaveBeenCalled(); + expect(handleChange.mock.calls[0][0]).toBe('tab2'); }); }); @@ -115,8 +120,9 @@ describe('Tabs', () => { render(); const disabledTab = screen.getByText(TAB_2_TEXT); - expect(disabledTab).toBeDisabled(); + // Base UI uses aria-disabled and data-disabled attributes expect(disabledTab).toHaveAttribute('aria-disabled', 'true'); + expect(disabledTab).toHaveAttribute('data-disabled', ''); }); it('does not allow clicking disabled tabs', () => { @@ -129,15 +135,15 @@ describe('Tabs', () => { }); }); - describe('Icons', () => { - it('renders trigger with icon', () => { + describe('Leading Icons', () => { + it('renders trigger with leadingIcon', () => { const icon = 📁; render( - + {TAB_1_TEXT} - + {CONTENT_1_TEXT} @@ -146,14 +152,14 @@ describe('Tabs', () => { expect(screen.getByTestId('tab-icon')).toBeInTheDocument(); }); - it('wraps icon in trigger-icon class', () => { + it('wraps leadingIcon in trigger-icon class', () => { const icon = 📁; const { container } = render( - + {TAB_1_TEXT} - + {CONTENT_1_TEXT} @@ -165,6 +171,25 @@ describe('Tabs', () => { }); }); + describe('Indicator', () => { + it('automatically renders indicator in list', () => { + const { container } = render( + + + {TAB_1_TEXT} + {TAB_2_TEXT} + + {CONTENT_1_TEXT} + + ); + + // Indicator is automatically included in the List + expect( + container.querySelector(`.${styles.indicator}`) + ).toBeInTheDocument(); + }); + }); + describe('Accessibility', () => { it('has correct ARIA roles', () => { render(); @@ -183,10 +208,7 @@ describe('Tabs', () => { const tab2 = screen.getByText(TAB_2_TEXT); expect(tab1).toHaveAttribute('aria-selected', 'true'); - expect(tab1).toHaveAttribute('aria-controls'); - expect(tab2).toHaveAttribute('aria-selected', 'false'); - expect(tab2).toHaveAttribute('aria-controls'); }); it('has correct ARIA attributes on content', () => { @@ -194,14 +216,13 @@ describe('Tabs', () => { const content = screen.getByRole('tabpanel'); expect(content).toHaveAttribute('aria-labelledby'); - expect(content).toHaveAttribute('tabIndex', '0'); }); it('supports custom aria-label', () => { render( - {TAB_1_TEXT} + {TAB_1_TEXT} {CONTENT_1_TEXT} @@ -222,30 +243,42 @@ describe('Tabs', () => { expect(tablist).toHaveAttribute('aria-orientation', 'vertical'); }); - it('defaults to horizontal orientation', () => { + it('has correct data-orientation attribute', () => { render(); - const tablist = screen.getByRole('tablist'); - expect(tablist).toHaveAttribute('aria-orientation', 'horizontal'); + const tab = screen.getByText(TAB_1_TEXT); + expect(tab).toHaveAttribute('data-orientation', 'horizontal'); }); }); describe('Data Attributes', () => { - it('has data-state on triggers', () => { + it('has aria-selected on active trigger', () => { render(); const tab1 = screen.getByText(TAB_1_TEXT); const tab2 = screen.getByText(TAB_2_TEXT); - expect(tab1).toHaveAttribute('data-state', 'active'); - expect(tab2).toHaveAttribute('data-state', 'inactive'); + // Base UI uses aria-selected for indicating the active tab + expect(tab1).toHaveAttribute('aria-selected', 'true'); + expect(tab2).toHaveAttribute('aria-selected', 'false'); }); - it('has data-state on content', () => { + it('updates aria-selected when switching tabs', async () => { + const user = userEvent.setup(); render(); - const content1 = screen.getByText(CONTENT_1_TEXT).closest('[data-state]'); - expect(content1).toHaveAttribute('data-state', 'active'); + const tab1 = screen.getByText(TAB_1_TEXT); + const tab2 = screen.getByText(TAB_2_TEXT); + + expect(tab1).toHaveAttribute('aria-selected', 'true'); + expect(tab2).toHaveAttribute('aria-selected', 'false'); + + await user.click(tab2); + + await waitFor(() => { + expect(tab1).toHaveAttribute('aria-selected', 'false'); + expect(tab2).toHaveAttribute('aria-selected', 'true'); + }); }); }); }); diff --git a/packages/raystack/components/tabs/index.tsx b/packages/raystack/components/tabs/index.tsx index c17b64023..81aabb71e 100644 --- a/packages/raystack/components/tabs/index.tsx +++ b/packages/raystack/components/tabs/index.tsx @@ -1 +1 @@ -export { Tabs } from "./tabs"; \ No newline at end of file +export { Tabs } from './tabs'; diff --git a/packages/raystack/components/tabs/tabs.module.css b/packages/raystack/components/tabs/tabs.module.css index 5b0bc8743..89952bb2b 100644 --- a/packages/raystack/components/tabs/tabs.module.css +++ b/packages/raystack/components/tabs/tabs.module.css @@ -5,6 +5,7 @@ } .list { + position: relative; display: flex; align-items: center; gap: var(--rs-space-2); @@ -27,7 +28,7 @@ color: var(--rs-color-foreground-base-secondary); cursor: pointer; border-radius: var(--rs-radius-2); - transition: all 0.2s ease; + transition: color 0.2s ease; flex: 1; text-align: center; text-overflow: ellipsis; @@ -37,18 +38,17 @@ line-height: var(--rs-line-height-small); letter-spacing: var(--rs-letter-spacing-small); box-sizing: border-box; + position: relative; + z-index: 1; } .trigger:hover:not([data-disabled]) { color: var(--rs-color-foreground-base-primary); } -.trigger[data-state="active"] { - background-color: var(--rs-color-background-base-primary); +/* Active tab - transparent background so indicator shows through */ +.trigger[data-active] { color: var(--rs-color-foreground-base-primary); - box-shadow: var(--rs-shadow-feather); - font-size: var(--rs-font-size-small); - font-weight: var(--rs-font-weight-medium); } .trigger[data-disabled] { @@ -68,10 +68,28 @@ flex-shrink: 0; } +.indicator { + position: absolute; + background-color: var(--rs-color-background-base-primary); + border-radius: var(--rs-radius-2); + box-shadow: var(--rs-shadow-feather); + transition: + top 0.25s cubic-bezier(0.4, 0, 0.2, 1), + left 0.25s cubic-bezier(0.4, 0, 0.2, 1), + width 0.25s cubic-bezier(0.4, 0, 0.2, 1), + height 0.25s cubic-bezier(0.4, 0, 0.2, 1); + top: var(--active-tab-top, 0); + left: var(--active-tab-left, 0); + width: var(--active-tab-width, 0); + height: var(--active-tab-height, 0); + z-index: 0; +} + .content { outline: none; } -.content[data-state="inactive"] { +/* Base UI uses hidden attribute for inactive panels */ +.content[hidden] { display: none; } diff --git a/packages/raystack/components/tabs/tabs.tsx b/packages/raystack/components/tabs/tabs.tsx index 404518c98..205dd51cb 100644 --- a/packages/raystack/components/tabs/tabs.tsx +++ b/packages/raystack/components/tabs/tabs.tsx @@ -1,91 +1,67 @@ -import { type VariantProps, cva } from 'class-variance-authority'; -import { Tabs as TabsPrimitive } from 'radix-ui'; -import { - ComponentPropsWithoutRef, - ElementRef, - ReactNode, - forwardRef -} from 'react'; - +import { Tabs as TabsPrimitive } from '@base-ui/react'; +import { cx } from 'class-variance-authority'; +import { ComponentPropsWithoutRef, forwardRef, ReactNode } from 'react'; import styles from './tabs.module.css'; -const root = cva(styles.root); -const list = cva(styles.list); -const trigger = cva(styles.trigger); -const content = cva(styles.content); +const TabsRoot = forwardRef( + ({ className, ...props }, ref) => ( + + ) +); +TabsRoot.displayName = 'Tabs.Root'; -export interface TabsRootProps - extends ComponentPropsWithoutRef, - VariantProps { - defaultValue?: string; - 'aria-label'?: string; -} +const TabsList = forwardRef( + ({ className, children, ...props }, ref) => ( + + {children} + + + ) +); +TabsList.displayName = 'Tabs.List'; -interface TabsTriggerProps - extends ComponentPropsWithoutRef { - icon?: ReactNode; - disabled?: boolean; +interface TabsTabProps + extends ComponentPropsWithoutRef { + leadingIcon?: ReactNode; } -const TabsRoot = forwardRef< - ElementRef, - TabsRootProps ->(({ className, 'aria-label': ariaLabel, ...props }, ref) => ( - -)); - -const TabsList = forwardRef< - ElementRef, - ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); - -const TabsTrigger = forwardRef< - ElementRef, - TabsTriggerProps ->(({ className, icon, children, disabled, ...props }, ref) => ( - - {icon && {icon}} - {children} - -)); - -const TabsContent = forwardRef< - ElementRef, - ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); +const TabsTab = forwardRef( + ({ className, leadingIcon, children, ...props }, ref) => ( + + {leadingIcon && ( + {leadingIcon} + )} + {children} + + ) +); +TabsTab.displayName = 'Tabs.Tab'; -TabsRoot.displayName = TabsPrimitive.Root.displayName; -TabsList.displayName = TabsPrimitive.List.displayName; -TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; -TabsContent.displayName = TabsPrimitive.Content.displayName; +const TabsContent = forwardRef( + ({ className, ...props }, ref) => ( + + ) +); +TabsContent.displayName = 'Tabs.Content'; export const Tabs = Object.assign(TabsRoot, { List: TabsList, - Trigger: TabsTrigger, + Tab: TabsTab, Content: TabsContent });