From 2fd8b220182a99654cec0d0ba64f20f186ebf644 Mon Sep 17 00:00:00 2001 From: Philip Levy Date: Fri, 17 Jul 2026 12:27:37 -0400 Subject: [PATCH 1/4] Enhance TabsField: dark bg support, SAIL spacing, full-width separator, align, nav-only mode - Remove hard-coded bg-white on triggers; use bg-transparent + opacity for dark bg support - Separator uses currentColor with opacity so it auto-adapts to any background - Increase default tab padding to match SAIL (16px/20px), add density prop (STANDARD/DENSE) - Add fullWidthSeparator prop for separator extending beyond tab content width - Add align prop (START/CENTER/END) for vertical tab label alignment (#140) - Make TabItem.content optional + add navigationOnly prop to suppress panels (#141) - Trim stories to essential demonstrations Closes #114, closes #140, closes #141 --- src/components/Tabs/Tabs.stories.tsx | 227 ++++++++++++++++++---- src/components/Tabs/TabsField.tsx | 281 ++++++++++++++++++--------- src/components/Tabs/index.ts | 2 +- 3 files changed, 382 insertions(+), 128 deletions(-) diff --git a/src/components/Tabs/Tabs.stories.tsx b/src/components/Tabs/Tabs.stories.tsx index f871c38..3b2b0eb 100644 --- a/src/components/Tabs/Tabs.stories.tsx +++ b/src/components/Tabs/Tabs.stories.tsx @@ -14,9 +14,13 @@ const meta = { argTypes: { variant: { control: 'select', options: ['UNDERLINE', 'PILL'] }, size: { control: 'select', options: ['SMALL', 'STANDARD', 'MEDIUM', 'LARGE'] }, + density: { control: 'select', options: ['STANDARD', 'DENSE'] }, color: { control: 'text' }, orientation: { control: 'select', options: ['HORIZONTAL', 'VERTICAL'] }, + align: { control: 'select', options: ['START', 'CENTER', 'END'] }, activationMode: { control: 'select', options: ['AUTOMATIC', 'MANUAL'] }, + navigationOnly: { control: 'boolean' }, + fullWidthSeparator: { control: 'boolean' }, }, } satisfies Meta @@ -193,49 +197,211 @@ export const VerticalOrientation: Story = { }, } -export const SizeSmall: Story = { +export const NavigationOnly: Story = { + name: 'Navigation Only (No Content Panels)', args: { - tabs: [ - { value: 'tab1', label: 'Tab 1', content:

Small tab content

}, - { value: 'tab2', label: 'Tab 2', content:

Small tab content

}, - ], + tabs: simpleTabs, defaultValue: 'tab1', - size: 'SMALL', + }, + render: () => { + const [activeTab, setActiveTab] = useState('dashboard') + + const navTabs: TabItem[] = [ + { value: 'dashboard', label: 'Dashboard' }, + { value: 'reports', label: 'Reports' }, + { value: 'settings', label: 'Settings' }, + { value: 'help', label: 'Help' }, + ] + + return ( +
+ +
+

+ Active route: {activeTab} +

+

+ Content is rendered externally — no content panels in the tab component. +

+
+
+ ) }, } -export const SizeLarge: Story = { +export const NavigationOnlyVertical: Story = { + name: 'Navigation Only — Vertical Sidebar', args: { - tabs: [ - { value: 'tab1', label: 'Tab 1', content:

Large tab content

}, - { value: 'tab2', label: 'Tab 2', content:

Large tab content

}, - ], + tabs: simpleTabs, + defaultValue: 'tab1', + }, + render: () => { + const [activeTab, setActiveTab] = useState('inbox') + + const navTabs: TabItem[] = [ + { value: 'inbox', label: 'Inbox' }, + { value: 'drafts', label: 'Drafts' }, + { value: 'sent', label: 'Sent' }, + { value: 'archive', label: 'Archive' }, + { value: 'trash', label: 'Trash' }, + ] + + return ( +
+ +
+

+ Viewing: {activeTab} +

+
+
+ ) + }, +} + +export const DensityComparison: Story = { + name: 'Density — STANDARD vs DENSE', + args: { + tabs: simpleTabs, + defaultValue: 'tab1', + }, + render: () => { + const tabs: TabItem[] = [ + { value: 'one', label: 'First Tab', content:

Content for first tab.

}, + { value: 'two', label: 'Second Tab', content:

Content for second tab.

}, + { value: 'three', label: 'Third Tab', content:

Content for third tab.

}, + ] + + return ( +
+
+

Density: STANDARD (default)

+ +
+
+

Density: DENSE

+ +
+
+ ) + }, +} + +export const FullWidthSeparator: Story = { + name: 'Full-Width Separator', + args: { + tabs: simpleTabs, + defaultValue: 'tab1', + }, + render: () => { + const tabs: TabItem[] = [ + { value: 'overview', label: 'Overview', content:

Overview content here.

}, + { value: 'details', label: 'Details', content:

Details content here.

}, + { value: 'history', label: 'History', content:

History content here.

}, + ] + + return ( +
+ + +
+ ) + }, +} + +export const LightBackground: Story = { + name: 'Light Background (Non-White)', + args: { + tabs: simpleTabs, + defaultValue: 'tab1', + }, + render: () => { + const tabs: TabItem[] = [ + { value: 'overview', label: 'Overview', content:

Overview content on light bg.

}, + { value: 'details', label: 'Details', content:

Details content on light bg.

}, + { value: 'history', label: 'History', content:

History content on light bg.

}, + ] + + return ( +
+ +
+ ) + }, +} + +export const DarkBackground: Story = { + name: 'Dark Background (Auto Separator)', + args: { + tabs: simpleTabs, defaultValue: 'tab1', - size: 'LARGE', + }, + render: () => { + const tabs: TabItem[] = [ + { value: 'overview', label: 'Overview', content:

Overview content on dark.

}, + { value: 'details', label: 'Details', content:

Details content on dark.

}, + { value: 'history', label: 'History', content:

History content on dark.

}, + ] + + return ( +
+ +
+ ) }, } -export const ColorPositive: Story = { +export const SizeSmall: Story = { args: { tabs: [ - { value: 'success', label: 'Success', content:

Success content with positive color scheme.

}, - { value: 'warning', label: 'Warning', content:

Warning content with negative color scheme.

}, - { value: 'info', label: 'Info', content:

Info content with secondary color scheme.

}, + { value: 'tab1', label: 'Tab 1', content:

Small tab content

}, + { value: 'tab2', label: 'Tab 2', content:

Small tab content

}, ], - defaultValue: 'success', - color: 'POSITIVE', + defaultValue: 'tab1', + size: 'SMALL', }, } -export const ColorNegative: Story = { +export const SizeLarge: Story = { args: { tabs: [ - { value: 'success', label: 'Success', content:

Success content with positive color scheme.

}, - { value: 'warning', label: 'Warning', content:

Warning content with negative color scheme.

}, - { value: 'info', label: 'Info', content:

Info content with secondary color scheme.

}, + { value: 'tab1', label: 'Tab 1', content:

Large tab content

}, + { value: 'tab2', label: 'Tab 2', content:

Large tab content

}, ], - defaultValue: 'warning', - color: 'NEGATIVE', + defaultValue: 'tab1', + size: 'LARGE', }, } @@ -251,19 +417,6 @@ export const ColorCustomHex: Story = { }, } -export const ManualActivation: Story = { - args: { - tabs: [ - { value: 'manual1', label: 'Manual 1', content:

Manual activation tab 1

}, - { value: 'manual2', label: 'Manual 2', content:

Manual activation tab 2

}, - { value: 'manual3', label: 'Manual 3', content:

Manual activation tab 3

}, - ], - defaultValue: 'manual1', - activationMode: 'MANUAL', - }, -} - - export const Pill: Story = { args: { tabs: [ diff --git a/src/components/Tabs/TabsField.tsx b/src/components/Tabs/TabsField.tsx index 6f42db6..649bb74 100644 --- a/src/components/Tabs/TabsField.tsx +++ b/src/components/Tabs/TabsField.tsx @@ -1,10 +1,10 @@ import * as React from 'react' import * as Tabs from '@radix-ui/react-tabs' -import type { SAILMarginSize, SAILSize, SAILColorInput } from '../../types/sail' +import type { SAILMarginSize, SAILSize, SAILColorInput, SAILAlign } from '../../types/sail' import { isPaletteColor } from '../../utils/colorResolver' import { paletteColorMap } from '../../types/palette-colors.generated' import { mergeClasses } from '../../utils/classNames' -import { marginAboveMap, marginBelowMap, buttonSizeMap } from '../../utils/sailMaps' +import { marginAboveMap, marginBelowMap, paddingMap } from '../../utils/sailMaps' /** * Individual tab configuration @@ -14,8 +14,8 @@ export interface TabItem { value: string /** Text to display on the tab trigger */ label: string - /** Content to display when tab is active */ - content: React.ReactNode + /** Content to display when tab is active (optional for navigation-only mode) */ + content?: React.ReactNode /** Whether this tab is disabled */ disabled?: boolean } @@ -27,6 +27,13 @@ export interface TabItem { */ export type TabsVariant = "UNDERLINE" | "PILL" +/** + * Spacing density for tabs + * - STANDARD: Default spacing matching SAIL conventions (tighter padding) + * - DENSE: Even more compact spacing for space-constrained layouts + */ +export type TabsDensity = "STANDARD" | "DENSE" + /** * Displays a set of layered sections of content (tab panels) that are displayed one at a time * Inspired by SAIL form field patterns (not an official SAIL component) @@ -47,8 +54,12 @@ export interface TabsFieldProps { variant?: TabsVariant /** Orientation of the tabs (only applies to UNDERLINE variant) */ orientation?: "HORIZONTAL" | "VERTICAL" + /** Alignment of tab labels in vertical orientation */ + align?: SAILAlign /** Size of the tab triggers */ size?: SAILSize + /** Spacing density for tab triggers */ + density?: TabsDensity /** Whether tabs should loop when navigating with keyboard */ loop?: boolean /** Determines whether component is displayed */ @@ -61,10 +72,43 @@ export interface TabsFieldProps { color?: "ACCENT" | "POSITIVE" | "NEGATIVE" | "SECONDARY" | SAILColorInput /** Activation mode - whether tabs activate on focus or click */ activationMode?: "AUTOMATIC" | "MANUAL" + /** Whether to suppress rendering of the content panel (navigation-only mode) */ + navigationOnly?: boolean + /** Determines the space between the tab edges and its contents (default: STANDARD) */ + contentsPadding?: SAILMarginSize + /** Whether the separator line extends to the full width of the parent container */ + fullWidthSeparator?: boolean /** Additional Tailwind classes for prototype-specific styling (not part of SAIL API) */ className?: string } +/** Size classes matching SAIL tab padding (16px vertical, 20px horizontal at STANDARD/14px) */ +const tabSizeMap: Record> = { + SMALL: { + STANDARD: 'px-4 py-3 text-sm leading-none', + DENSE: 'px-3 py-2 text-sm leading-none', + }, + STANDARD: { + STANDARD: 'px-5 py-4 text-base leading-none', + DENSE: 'px-4 py-2.5 text-base leading-none', + }, + MEDIUM: { + STANDARD: 'px-6 py-5 text-lg leading-none', + DENSE: 'px-5 py-3 text-lg leading-none', + }, + LARGE: { + STANDARD: 'px-7 py-6 text-xl leading-none', + DENSE: 'px-6 py-3.5 text-xl leading-none', + }, +} + +/** Alignment map for vertical tab triggers */ +const tabAlignMap: Record = { + START: 'justify-start', + CENTER: 'justify-center', + END: 'justify-end', +} + export const TabsField: React.FC = ({ tabs, value, @@ -72,13 +116,18 @@ export const TabsField: React.FC = ({ onValueChange, variant = "UNDERLINE", orientation = "HORIZONTAL", + align = "CENTER", size = "STANDARD", + density = "STANDARD", loop = true, showWhen = true, marginAbove = "NONE", marginBelow = "STANDARD", color = "ACCENT", activationMode = "AUTOMATIC", + navigationOnly = false, + contentsPadding = "STANDARD", + fullWidthSeparator = false, className }) => { // Sliding indicator state @@ -113,6 +162,9 @@ export const TabsField: React.FC = ({ // Visibility control if (!showWhen) return null + // Determine if content panels should render + const hasContent = !navigationOnly && tabs.some(tab => tab.content != null) + // Active indicator color const getIndicatorColor = (): string => { const semanticMap: Record = { @@ -131,6 +183,10 @@ export const TabsField: React.FC = ({ } const indicatorColor = getIndicatorColor() + // Separator color — uses a lighter shade so it works on both light and dark backgrounds + const separatorColor = 'currentColor' + const separatorOpacity = '0.2' + // PILL variant color helpers const getPillBgColor = (): string => { const semanticMap: Record = { @@ -162,11 +218,63 @@ export const TabsField: React.FC = ({ ? "relative flex flex-col" : "relative flex" - const contentClasses = orientation === "VERTICAL" ? "pl-4 flex-1 p-4" : "p-4" + const contentClasses = orientation === "VERTICAL" + ? `flex-1 ${paddingMap[contentsPadding]}` + : paddingMap[contentsPadding] const rootClasses = orientation === "VERTICAL" ? "flex" : "block" + // Trigger size classes based on density + const triggerSizeClasses = tabSizeMap[size][density] + + // Full-width separator: the separator extends to container edges while tabs stay content-width + const renderActiveIndicator = () => { + if (variant !== "UNDERLINE") return null + + if (orientation === "HORIZONTAL") { + return ( +