diff --git a/docs/reference/ui-primitives.md b/docs/reference/ui-primitives.md index a76c36fa..5716e6d3 100644 --- a/docs/reference/ui-primitives.md +++ b/docs/reference/ui-primitives.md @@ -492,7 +492,9 @@ import { Widget } from '@ui/components/Widget' ## `Tabs` -ARIA-correct, keyboard-navigable tab compound component. Implements WAI-ARIA "tabs with automatic activation" — arrow keys move focus AND change the active value simultaneously. Underline-indicator style, distinct from `RangeTabs` (pill segmented control). Panel DOM nodes stay mounted (just hidden) so each panel can hold React state. +ARIA-correct, keyboard-navigable tab compound component. Implements WAI-ARIA "tabs with automatic activation" — arrow keys move focus AND change the active value simultaneously. Each trigger renders the shared `Button` primitive (`primary` when active, `secondary` otherwise) — the section-tab pattern used by the AI, Users, and Account pages. Distinct from `SegmentedControl` (compact editor-panel view switching) and `RangeTabs` (pill segmented control in widget headers). Panels lazy-mount by default; pass `keepMounted` to a `TabPanel` whose children hold state that must survive tab switches. + +`TabList` and `TabPanel`s may live in different subtrees of the same `` provider — e.g. the tab row passed to `AdminPageLayout`'s `tabs` slot while the panels render as page children. ```tsx import { Tabs, TabList, Tab, TabPanel } from '@ui/components/Tabs' @@ -517,8 +519,8 @@ const [activeTab, setActiveTab] = useState<'overview' | 'settings'>('overview') |-----------|---------------|-------| | `Tabs` | `value`, `onChange` | Context provider. Generic on `TValue extends string`. | | `TabList` | `ariaLabel` | Renders `role="tablist"`, owns arrow-key navigation. | -| `Tab` | `value` | Renders a ` + ))} - + ) return ( - -
- {tab === 'profile' && } - {tab === 'sessions' && } - {tab === 'security' && } - {tab === 'activity' && } -
-
+ + +
+ + + + +
+
+
) } diff --git a/src/admin/pages/ai/AiPage.module.css b/src/admin/pages/ai/AiPage.module.css index f67a0a61..251fbe2e 100644 --- a/src/admin/pages/ai/AiPage.module.css +++ b/src/admin/pages/ai/AiPage.module.css @@ -7,12 +7,6 @@ gap: var(--space-6xl); } -.tabsRow { - display: flex; - flex-wrap: wrap; - gap: var(--space-s); -} - .section { display: grid; gap: var(--space-2xl); diff --git a/src/admin/pages/ai/AiPage.tsx b/src/admin/pages/ai/AiPage.tsx index e73b5487..e7276f69 100644 --- a/src/admin/pages/ai/AiPage.tsx +++ b/src/admin/pages/ai/AiPage.tsx @@ -14,7 +14,7 @@ */ import { useState } from 'react' -import { Button } from '@ui/components/Button' +import { Tab, TabList, TabPanel, Tabs } from '@ui/components/Tabs' import { AdminPageLayout } from '@admin/layouts/AdminPageLayout' import { hasCapability } from '@admin/access' import { useCurrentAdminUser } from '@admin/sessionContext' @@ -24,9 +24,9 @@ import { AuditTab } from './tabs/AuditTab' import { McpTab } from './tabs/McpTab' import styles from './AiPage.module.css' -type Tab = 'providers' | 'defaults' | 'mcp' | 'audit' +type AiTab = 'providers' | 'defaults' | 'mcp' | 'audit' -const TAB_LABELS: Record = { +const TAB_LABELS: Record = { providers: 'Providers', defaults: 'Defaults', mcp: 'MCP', @@ -39,46 +39,39 @@ export function AiPage() { const canManage = unrestricted || hasCapability(currentUser, 'ai.providers.manage') const canReadAudit = unrestricted || hasCapability(currentUser, 'ai.audit.read') - const availableTabs: Tab[] = [] + const availableTabs: AiTab[] = [] if (canManage) availableTabs.push('providers', 'defaults', 'mcp') if (canReadAudit) availableTabs.push('audit') - const [tab, setTab] = useState('providers') + const [tab, setTab] = useState('providers') const activeTab = availableTabs.includes(tab) ? tab : availableTabs[0] ?? 'providers' const tabs = ( -
+ {availableTabs.map((item) => ( - + ))} -
+ ) return ( - -
- {activeTab === 'providers' && } - {activeTab === 'defaults' && } - {activeTab === 'mcp' && } - {activeTab === 'audit' && } -
-
+ + +
+ + + + +
+
+
) } diff --git a/src/admin/pages/users/UsersPage.module.css b/src/admin/pages/users/UsersPage.module.css index adcf6721..403e4b4f 100644 --- a/src/admin/pages/users/UsersPage.module.css +++ b/src/admin/pages/users/UsersPage.module.css @@ -32,7 +32,6 @@ line-height: 1.45; } -.tabsRow, .badges, .auditDetails, .groupActions { diff --git a/src/admin/pages/users/UsersPage.tsx b/src/admin/pages/users/UsersPage.tsx index 4295d8e6..84505425 100644 --- a/src/admin/pages/users/UsersPage.tsx +++ b/src/admin/pages/users/UsersPage.tsx @@ -13,7 +13,7 @@ */ import { useEffect, useEffectEvent, useState } from 'react' import { peekPendingAction } from '@admin/spotlight/pendingAction' -import { Button } from '@ui/components/Button' +import { Tab, TabList, TabPanel, Tabs } from '@ui/components/Tabs' import { AdminPageLayout } from '@admin/layouts/AdminPageLayout' import { hasCapability } from '@admin/access' import { useCurrentAdminUser } from '@admin/sessionContext' @@ -22,7 +22,7 @@ import { RolesTab } from './tabs/RolesTab' import { UsersTab } from './tabs/UsersTab' import { useUsersPageData } from './hooks/useUsersPageData' import { tabLabel } from './utils/format' -import type { Tab, UsersPageLoadAccess } from './types' +import type { Tab as UsersPageTab, UsersPageLoadAccess } from './types' import styles from './UsersPage.module.css' export function UsersPage() { @@ -36,12 +36,12 @@ export function UsersPage() { const loadAccess: UsersPageLoadAccess = { canManageUsers, canReadRoleOptions, canReadAudit } const data = useUsersPageData(loadAccess) - const availableTabs: Tab[] = [] + const availableTabs: UsersPageTab[] = [] if (canManageUsers) availableTabs.push('users') if (canManageRoles) availableTabs.push('roles') if (canReadAudit) availableTabs.push('audit') - const [tab, setTab] = useState('users') + const [tab, setTab] = useState('users') const activeTab = availableTabs.includes(tab) ? tab : availableTabs[0] ?? 'users' // Cross-workspace spotlight actions can target this page. Peek (don't @@ -69,39 +69,35 @@ export function UsersPage() { }, []) const tabs = ( -
+ {availableTabs.map((item) => ( - + ))} -
+ ) return ( - - {/* No `loading` on the layout — the tabs below own their own - skeleton states, each matching their real (DataTable) layout - 1:1 so the column ladder stays put when the data arrives. */} -
- {data.error &&

{data.error}

} + + + {/* No `loading` on the layout — the tabs below own their own + skeleton states, each matching their real (DataTable) layout + 1:1 so the column ladder stays put when the data arrives. */} +
+ {data.error &&

{data.error}

} - {activeTab === 'users' && } - {activeTab === 'roles' && } - {activeTab === 'audit' && } -
-
+ + + +
+
+
) } diff --git a/src/ui/components/Tabs/Tabs.module.css b/src/ui/components/Tabs/Tabs.module.css index 6d9b6042..11f839ad 100644 --- a/src/ui/components/Tabs/Tabs.module.css +++ b/src/ui/components/Tabs/Tabs.module.css @@ -1,65 +1,24 @@ /** - * Tabs — underline-indicator style. Intentionally distinct from RangeTabs - * (pill segmented control). This is page-level chrome. + * Tabs — page-level section tabs. * - * Tokens: --border, --text, --text-subtle (from globals.css). - * No hardcoded hex values — all colours come from var(--*) tokens per policy. + * The tab triggers themselves are Button primitives (primary = active, + * secondary = inactive), so all trigger chrome lives in Button.module.css. + * This module only owns the row layout and the panel reveal. */ /* ── Tablist ──────────────────────────────────────────────────────────────── */ .tabList { display: flex; - gap: var(--space-4xs); - border-bottom: 1px solid var(--border); -} - -/* ── Tab trigger ──────────────────────────────────────────────────────────── */ - -.tab { - padding: var(--space-s) var(--space-2xl); - font-size: 0.875rem; /* 14px */ - font-weight: 500; - color: var(--text-subtle); - background: none; - border: none; - /* 2px underline sits flush against the tablist's 1px border-bottom. - margin-bottom: calc(var(--space-px) * -1) lets the active underline overlap the border - so the two lines visually merge into one clean indicator. */ - border-bottom: var(--space-4xs) solid transparent; - margin-bottom: calc(var(--space-px) * -1); - cursor: pointer; - font-family: inherit; - line-height: 1; - transition: - color 0.1s ease, - border-color 0.1s ease; -} - -.tab:hover { - color: var(--text); -} - -.tab:focus-visible { - outline: none; - box-shadow: var(--focus-ring); - border-radius: var(--radius-sm); -} - -/* ── Active tab ───────────────────────────────────────────────────────────── */ - -.tabActive { - color: var(--text); - border-bottom-color: var(--text); - font-weight: 600; + flex-wrap: wrap; + gap: var(--space-s); } /* ── Tab panel ────────────────────────────────────────────────────────────── */ .tabPanel { - /* Subtle fade-in + upward slide on panel reveal. Kept (not dropped) because - the 0.12s duration is imperceptible as a delay but noticeably smooths - hard content swaps — identical to the Search plugin's panel animation. + /* Subtle fade-in + upward slide on panel reveal. The 0.12s duration is + imperceptible as a delay but noticeably smooths hard content swaps. The hidden attribute gates visibility so the animation only fires when a panel becomes active, not on initial render of inactive panels. */ animation: fadeIn 0.12s ease; diff --git a/src/ui/components/Tabs/Tabs.tsx b/src/ui/components/Tabs/Tabs.tsx index 8139b350..6650527a 100644 --- a/src/ui/components/Tabs/Tabs.tsx +++ b/src/ui/components/Tabs/Tabs.tsx @@ -4,14 +4,22 @@ * Implements the WAI-ARIA "tabs with automatic activation" pattern: * arrow keys move focus AND change the active value simultaneously. * - * Underline-indicator style — visually distinct from RangeTabs (pill - * segmented control). Tabs is for page-level chrome; RangeTabs is for - * compact inline widget-header toggles. + * Visuals: each tab renders the shared `Button` primitive + * (`primary` when active, `secondary` otherwise, size `sm`) — the exact + * pattern the admin pages use for section tabs. This is deliberately + * distinct from `SegmentedControl` (compact editor-panel view switching) + * and `RangeTabs` (pill segmented control in widget headers): `Tabs` is + * for page-level section chrome. * - * All four components compose a single React Context so the value type - * is generic at the Tabs boundary and the inner components only see strings. - * Tab panel DOM nodes stay mounted (just hidden) — consistent with Search's - * pattern and correct for any consumer that holds component state in panels. + * All four components compose a single React Context, so `TabList` and + * `TabPanel`s may live in different subtrees (e.g. the tab row passed to + * `AdminPageLayout`'s `tabs` slot while the panels render as children). + * The value type is generic at the Tabs boundary; the inner components + * only see strings. + * + * Panels unmount when inactive by default (matching how the admin pages + * lazy-mount tab content); pass `keepMounted` to a `TabPanel` whose + * children hold state that must survive tab switches. * * Lives under src/ui/components/ so plugins can import it via * @instatic/host-ui. @@ -24,7 +32,7 @@ import { type KeyboardEvent, type ReactNode, } from 'react' -import { cn } from '@ui/cn' +import { Button } from '@ui/components/Button' import styles from './Tabs.module.css' // --------------------------------------------------------------------------- @@ -69,12 +77,20 @@ export interface TabListProps { export interface TabProps { /** The value this tab represents. Must match a TabPanel value. */ value: TValue + /** Optional data-testid forwarded to the underlying button. */ + testId?: string children?: ReactNode } export interface TabPanelProps { /** The value this panel represents. Must match a Tab value. */ value: TValue + /** + * Keep the panel's children mounted (hidden) while inactive. Off by + * default: inactive panels render an empty placeholder so tab content + * lazy-mounts on first activation, matching the admin pages' behavior. + */ + keepMounted?: boolean children?: ReactNode } @@ -176,16 +192,18 @@ export function TabList({ ariaLabel, children }: TabListProps) { // Tab — individual tab trigger // --------------------------------------------------------------------------- -export function Tab({ value, children }: TabProps) { +export function Tab({ value, testId, children }: TabProps) { const { activeValue, onChange, idPrefix } = useTabsContext('Tab') const isActive = value === activeValue const tabId = `${idPrefix}-tab-${value}` const panelId = `${idPrefix}-panel-${value}` return ( - + ) } @@ -209,6 +227,7 @@ export function Tab({ value, children }: TabProps export function TabPanel({ value, + keepMounted = false, children, }: TabPanelProps) { const { activeValue, idPrefix } = useTabsContext('TabPanel') @@ -221,12 +240,13 @@ export function TabPanel({ id={panelId} role="tabpanel" aria-labelledby={tabId} - // hidden keeps inactive panels out of the accessibility tree and layout - // while leaving their DOM nodes (and component state) mounted. + // The panel element itself stays in the DOM (hidden) so the active + // tab's aria-controls always resolves; only the CHILDREN unmount + // when the panel is inactive and keepMounted is off. hidden={!isActive} className={styles.tabPanel} > - {children} + {(isActive || keepMounted) && children} ) }