diff --git a/apps/www/src/content/docs/components/tabs/demo.ts b/apps/www/src/content/docs/components/tabs/demo.ts index 3a4b025d8..43ec11cc7 100644 --- a/apps/www/src/content/docs/components/tabs/demo.ts +++ b/apps/www/src/content/docs/components/tabs/demo.ts @@ -3,28 +3,20 @@ export const preview = { type: 'code', code: ` - + - }>Hoisting - Hosting - }>Editor - Billing - SEO + Hosting + Billing + SEO - General settings content + Hosting configuration content - Hosting configuration content + Billing preferences content - Editor preferences content - - - Billing information content - - SEO settings content @@ -77,3 +69,145 @@ export const disabledDemo = { ` }; + +export const standaloneVariantDemo = { + type: 'code', + code: ` +
+ + + Account + Password + Settings + + Account settings + Password settings + Other settings + +
` +}; + +export const plainVariantDemo = { + type: 'code', + code: ` +
+ + + Account + Password + Settings + + Account settings + Password settings + Other settings + +
` +}; + +export const variantsDemo = { + type: 'code', + tabs: [ + { + name: 'Segmented (default)', + code: ` +
+ + + Account + Password + Settings + + Account settings + Password settings + Other settings + +
` + }, + { + name: 'Standalone', + code: ` +
+ + + Account + Password + Settings + + Account settings + Password settings + Other settings + +
` + }, + { + name: 'Plain', + code: ` +
+ + + Account + Password + Settings + + Account settings + Password settings + Other settings + +
` + } + ] +}; + +export const sizesDemo = { + type: 'code', + tabs: [ + { + name: 'Small', + code: ` +
+ + + Account + Password + Settings + + Account settings + Password settings + Other settings + +
` + }, + { + name: 'Medium', + code: ` +
+ + + Account + Password + Settings + + Account settings + Password settings + Other settings + +
` + }, + { + name: 'Large', + code: ` +
+ + + Account + Password + Settings + + Account settings + Password settings + Other settings + +
` + } + ] +}; diff --git a/apps/www/src/content/docs/components/tabs/index.mdx b/apps/www/src/content/docs/components/tabs/index.mdx index 436956c0b..4098d1ba7 100644 --- a/apps/www/src/content/docs/components/tabs/index.mdx +++ b/apps/www/src/content/docs/components/tabs/index.mdx @@ -4,7 +4,14 @@ description: A set of layered sections of content that display one panel at a ti source: packages/raystack/components/tabs --- -import { preview, basicDemo, iconsDemo, disabledDemo } from "./demo.ts"; +import { + preview, + basicDemo, + iconsDemo, + disabledDemo, + variantsDemo, + sizesDemo +} from "./demo.ts"; @@ -63,6 +70,14 @@ Renders the content panel for a tab. +### Variants + + + +### Sizes + + + ## Accessibility - Follows the [WAI-ARIA Tabs pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/) diff --git a/apps/www/src/content/docs/components/tabs/props.ts b/apps/www/src/content/docs/components/tabs/props.ts index 7cdc4a62d..71347327b 100644 --- a/apps/www/src/content/docs/components/tabs/props.ts +++ b/apps/www/src/content/docs/components/tabs/props.ts @@ -11,6 +11,12 @@ export interface TabsRootProps { /** The orientation of the tabs. */ orientation?: 'horizontal' | 'vertical'; + /** Visual variant for how tabs are rendered. */ + variant?: 'segmented' | 'standalone' | 'plain'; + + /** Size variant applied to all tab triggers. */ + size?: 'small' | 'medium' | 'large'; + /** 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 93f0e947f..c7d8406db 100644 --- a/packages/raystack/components/tabs/__tests__/tabs.test.tsx +++ b/packages/raystack/components/tabs/__tests__/tabs.test.tsx @@ -268,6 +268,90 @@ describe('Tabs', () => { }); }); + describe('Variants', () => { + it('applies standalone variant class', () => { + render( + + + {TAB_1_TEXT} + + {CONTENT_1_TEXT} + + ); + + const tablist = screen.getByRole('tablist'); + const root = tablist.closest(`.${styles.root}`); + expect(root).not.toBeNull(); + expect(root).toHaveClass(styles['variant-standalone']); + }); + + it('defaults to segmented variant class', () => { + render( + + + {TAB_1_TEXT} + + {CONTENT_1_TEXT} + + ); + + const tablist = screen.getByRole('tablist'); + const root = tablist.closest(`.${styles.root}`); + expect(root).not.toBeNull(); + expect(root).toHaveClass(styles['variant-segmented']); + }); + + it('applies plain variant class', () => { + render( + + + {TAB_1_TEXT} + + {CONTENT_1_TEXT} + + ); + + const tablist = screen.getByRole('tablist'); + const root = tablist.closest(`.${styles.root}`); + expect(root).not.toBeNull(); + expect(root).toHaveClass(styles['variant-plain']); + }); + }); + + describe('Sizes', () => { + it('defaults to large size class', () => { + render( + + + {TAB_1_TEXT} + + {CONTENT_1_TEXT} + + ); + + const tablist = screen.getByRole('tablist'); + const root = tablist.closest(`.${styles.root}`); + expect(root).not.toBeNull(); + expect(root).toHaveClass(styles['size-large']); + }); + + it('applies small size class', () => { + render( + + + {TAB_1_TEXT} + + {CONTENT_1_TEXT} + + ); + + const tablist = screen.getByRole('tablist'); + const root = tablist.closest(`.${styles.root}`); + expect(root).not.toBeNull(); + expect(root).toHaveClass(styles['size-small']); + }); + }); + describe('Data Attributes', () => { it('has aria-selected on active trigger', () => { render(); diff --git a/packages/raystack/components/tabs/tabs.module.css b/packages/raystack/components/tabs/tabs.module.css index 9261fc44e..f518c4140 100644 --- a/packages/raystack/components/tabs/tabs.module.css +++ b/packages/raystack/components/tabs/tabs.module.css @@ -2,6 +2,27 @@ display: flex; flex-direction: column; width: 100%; + --tabs-trigger-height: var(--rs-space-8); + --tabs-trigger-padding-inline: var(--rs-space-3); + --tabs-trigger-font-size: var(--rs-font-size-regular); + --tabs-trigger-line-height: var(--rs-line-height-regular); + --tabs-trigger-letter-spacing: var(--rs-letter-spacing-regular); +} + +.size-small { + --tabs-trigger-height: var(--rs-space-6); + --tabs-trigger-padding-inline: var(--rs-space-2); + --tabs-trigger-font-size: var(--rs-font-size-mini); + --tabs-trigger-line-height: var(--rs-line-height-mini); + --tabs-trigger-letter-spacing: var(--rs-letter-spacing-mini); +} + +.size-medium { + --tabs-trigger-height: var(--rs-space-7); + --tabs-trigger-padding-inline: var(--rs-space-3); + --tabs-trigger-font-size: var(--rs-font-size-small); + --tabs-trigger-line-height: var(--rs-line-height-small); + --tabs-trigger-letter-spacing: var(--rs-letter-spacing-small); } .list { @@ -22,9 +43,10 @@ align-items: center; justify-content: center; gap: var(--rs-space-2); - font-size: var(--rs-font-size-small); + font-size: var(--tabs-trigger-font-size); + font-style: normal; font-weight: var(--rs-font-weight-medium); - padding: var(--rs-space-2) var(--rs-space-3); + padding: 0 var(--tabs-trigger-padding-inline); color: var(--rs-color-foreground-base-secondary); cursor: pointer; border-radius: var(--rs-radius-2); @@ -34,9 +56,9 @@ text-overflow: ellipsis; white-space: nowrap; overflow: hidden; - height: var(--rs-space-8); - line-height: var(--rs-line-height-small); - letter-spacing: var(--rs-letter-spacing-small); + height: var(--tabs-trigger-height); + line-height: var(--tabs-trigger-line-height); + letter-spacing: var(--tabs-trigger-letter-spacing); box-sizing: border-box; position: relative; z-index: 1; @@ -54,9 +76,6 @@ .trigger[data-disabled] { opacity: 0.5; - cursor: not-allowed; - color: var(--rs-color-foreground-base-secondary); - pointer-events: none; } .trigger-icon { @@ -103,3 +122,48 @@ asked the OS/browser for less or no motion .content[hidden] { display: none; } + +/* Shared non-segmented list reset */ +.variant-standalone .list, +.variant-plain .list { + background-color: transparent; + box-shadow: none; +} + +.variant-standalone .indicator, +.variant-plain .indicator { + display: none; +} + +/* Standalone variant: chip-like tabs with independent borders */ +.variant-standalone .trigger { + /* Chip look: no outer padding and visible border */ + border: 0.5px solid var(--rs-color-border-base-primary); + background: transparent; +} + +.variant-standalone .trigger[data-active] { + background: var(--rs-color-background-neutral-primary); + border: 0.5px solid var(--rs-color-border-base-secondary); +} + +/* Plain variant: text-like tabs with larger gaps */ +.variant-plain .list { + gap: var(--rs-space-6); + background-color: transparent; + padding: 0; + border-radius: 0; + box-shadow: none; + justify-content: center; +} + +.variant-plain .trigger { + flex: 0 0 auto; + border-radius: 0; + /* Reserve underline space to prevent layout/typography shifting when active */ + border-bottom: 1px solid transparent; +} + +.variant-plain .trigger[data-active] { + border-bottom-color: var(--rs-color-border-base-emphasis); +} \ No newline at end of file diff --git a/packages/raystack/components/tabs/tabs.tsx b/packages/raystack/components/tabs/tabs.tsx index ef58f4238..fce68acba 100644 --- a/packages/raystack/components/tabs/tabs.tsx +++ b/packages/raystack/components/tabs/tabs.tsx @@ -1,14 +1,46 @@ import { Tabs as TabsPrimitive } from '@base-ui/react'; -import { cx } from 'class-variance-authority'; +import { cva, cx } from 'class-variance-authority'; import { ReactNode } from 'react'; import styles from './tabs.module.css'; -function TabsRoot({ className, ...props }: TabsPrimitive.Root.Props) { +type TabsVariant = 'segmented' | 'standalone' | 'plain'; +type TabsSize = 'small' | 'medium' | 'large'; + +type TabsRootProps = TabsPrimitive.Root.Props & { + /** Visual variant for how tabs are rendered. */ + variant?: TabsVariant; + /** Size variant for all tab triggers. */ + size?: TabsSize; +}; + +const tabsRoot = cva(styles.root, { + variants: { + variant: { + segmented: styles['variant-segmented'], + standalone: styles['variant-standalone'], + plain: styles['variant-plain'] + }, + size: { + small: styles['size-small'], + medium: styles['size-medium'], + large: styles['size-large'] + } + }, + defaultVariants: { + variant: 'segmented', + size: 'large' + } +}); + +function TabsRoot({ className, variant, size, ...props }: TabsRootProps) { return ( - + ); } -TabsRoot.displayName = 'Tabs'; +TabsRoot.displayName = 'Tabs.Root'; function TabsList({ className, children, ...props }: TabsPrimitive.List.Props) { return (