Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions docs/reference/ui-primitives.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Tabs>` 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'
Expand All @@ -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 `<button role="tab">`. Active tab is in the natural focus order; inactive tabs use `tabIndex={-1}`. |
| `TabPanel`| `value` | Renders `role="tabpanel"`, `hidden={!isActive}`. DOM stays mounted. |
| `Tab` | `value` | Renders a `Button` with `role="tab"` (`primary` when active, `secondary` otherwise). Active tab is in the natural focus order; inactive tabs use `tabIndex={-1}`. Optional `testId` forwards `data-testid`. |
| `TabPanel`| `value` | Renders `role="tabpanel"`, `hidden={!isActive}`. Children unmount while inactive unless `keepMounted` is set; the panel element itself stays in the DOM so `aria-controls` always resolves. |

**Do not** hand-roll a `role="tablist"` div — this is gated by `no-plugin-tab-shells.test.ts`. Use `<Tabs>` / `<TabList>` from `@ui/components/Tabs` instead.

Expand Down
116 changes: 116 additions & 0 deletions src/__tests__/ui/tabs.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* Tabs primitive — Button-based ARIA tabs with automatic activation.
*
* Covers the contract the admin pages rely on:
* - triggers render as role="tab" Buttons (primary when active)
* - clicking a tab activates it; panels lazy-mount by default
* - keepMounted panels stay in the DOM (hidden) while inactive
* - arrow keys move focus AND activate (automatic activation),
* with roving tabindex on the triggers
* - testId lands on the underlying button
*/
import { afterEach, describe, expect, it } from 'bun:test'
import { useState } from 'react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { Tab, TabList, TabPanel, Tabs } from '@ui/components/Tabs'

afterEach(cleanup)

type Section = 'one' | 'two' | 'three'

function Harness({ keepMountedTwo = false }: { keepMountedTwo?: boolean }) {
const [value, setValue] = useState<Section>('one')
return (
<Tabs value={value} onChange={setValue}>
<TabList ariaLabel="Test sections">
<Tab value="one" testId="tab-one">One</Tab>
<Tab value="two" testId="tab-two">Two</Tab>
<Tab value="three" testId="tab-three">Three</Tab>
</TabList>
<TabPanel value="one"><p>Panel one</p></TabPanel>
<TabPanel value="two" keepMounted={keepMountedTwo}><p>Panel two</p></TabPanel>
<TabPanel value="three"><p>Panel three</p></TabPanel>
</Tabs>
)
}

describe('Tabs', () => {
it('renders an ARIA tablist of Button triggers with roving tabindex', () => {
render(<Harness />)

const list = screen.getByRole('tablist', { name: 'Test sections' })
expect(list).toBeDefined()

const active = screen.getByRole('tab', { name: 'One' })
const inactive = screen.getByRole('tab', { name: 'Two' })
expect(active.getAttribute('aria-selected')).toBe('true')
expect(active.getAttribute('tabindex')).toBe('0')
expect(inactive.getAttribute('aria-selected')).toBe('false')
expect(inactive.getAttribute('tabindex')).toBe('-1')
})

it('activates on click and lazy-mounts panels by default', () => {
render(<Harness />)

expect(screen.getByText('Panel one')).toBeDefined()
// Inactive panel children are NOT mounted by default.
expect(screen.queryByText('Panel two')).toBeNull()

fireEvent.click(screen.getByRole('tab', { name: 'Two' }))

expect(screen.getByRole('tab', { name: 'Two' }).getAttribute('aria-selected')).toBe('true')
expect(screen.getByText('Panel two')).toBeDefined()
expect(screen.queryByText('Panel one')).toBeNull()
})

it('keeps keepMounted panels in the DOM (hidden) while inactive', () => {
render(<Harness keepMountedTwo />)

// Mounted even though "one" is active…
const panelTwoText = screen.getByText('Panel two')
// …but hidden via the panel wrapper.
expect(panelTwoText.closest('[role="tabpanel"]')?.hasAttribute('hidden')).toBe(true)

fireEvent.click(screen.getByRole('tab', { name: 'Two' }))
expect(panelTwoText.closest('[role="tabpanel"]')?.hasAttribute('hidden')).toBe(false)
})

it('wires aria-controls / aria-labelledby between tab and panel', () => {
render(<Harness />)

const tab = screen.getByRole('tab', { name: 'One' })
const panel = screen.getByText('Panel one').closest('[role="tabpanel"]')!
expect(tab.getAttribute('aria-controls')).toBe(panel.getAttribute('id'))
expect(panel.getAttribute('aria-labelledby')).toBe(tab.getAttribute('id'))
})

it('moves focus and activates with arrow keys (automatic activation)', () => {
render(<Harness />)

const list = screen.getByRole('tablist', { name: 'Test sections' })
const one = screen.getByRole('tab', { name: 'One' })
const two = screen.getByRole('tab', { name: 'Two' })
const three = screen.getByRole('tab', { name: 'Three' })

one.focus()
fireEvent.keyDown(list, { key: 'ArrowRight' })
expect(document.activeElement).toBe(two)
expect(two.getAttribute('aria-selected')).toBe('true')

// Wraps from the last tab back to the first.
fireEvent.keyDown(list, { key: 'ArrowLeft' })
expect(document.activeElement).toBe(one)
fireEvent.keyDown(list, { key: 'ArrowLeft' })
expect(document.activeElement).toBe(three)
expect(three.getAttribute('aria-selected')).toBe('true')

fireEvent.keyDown(list, { key: 'Home' })
expect(document.activeElement).toBe(one)
expect(one.getAttribute('aria-selected')).toBe('true')
})

it('forwards testId to the underlying button', () => {
render(<Harness />)
expect(screen.getByTestId('tab-two')).toBe(screen.getByRole('tab', { name: 'Two' }))
})
})
22 changes: 11 additions & 11 deletions src/__tests__/users/usersAdmin.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -286,9 +286,9 @@ describe('UsersPage', () => {
)

expect(await screen.findByRole('table', { name: 'Users' })).toBeDefined()
expect(screen.getByRole('button', { name: 'Users' })).toBeDefined()
expect(screen.queryByRole('button', { name: 'Roles' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Audit' })).toBeNull()
expect(screen.getByRole('tab', { name: 'Users' })).toBeDefined()
expect(screen.queryByRole('tab', { name: 'Roles' })).toBeNull()
expect(screen.queryByRole('tab', { name: 'Audit' })).toBeNull()
expect(screen.getByRole('button', { name: 'Create User' })).toBeDefined()
expect(calls).toContain('GET /admin/api/cms/users')
expect(calls).toContain('GET /admin/api/cms/roles')
Expand All @@ -315,9 +315,9 @@ describe('UsersPage', () => {
)

expect(await screen.findByRole('table', { name: 'Roles' })).toBeDefined()
expect(screen.queryByRole('button', { name: 'Users' })).toBeNull()
expect(screen.getByRole('button', { name: 'Roles' })).toBeDefined()
expect(screen.queryByRole('button', { name: 'Audit' })).toBeNull()
expect(screen.queryByRole('tab', { name: 'Users' })).toBeNull()
expect(screen.getByRole('tab', { name: 'Roles' })).toBeDefined()
expect(screen.queryByRole('tab', { name: 'Audit' })).toBeNull()
expect(screen.getByRole('button', { name: 'Create Role' })).toBeDefined()
expect(calls).not.toContain('GET /admin/api/cms/users')
expect(calls).toContain('GET /admin/api/cms/roles')
Expand Down Expand Up @@ -348,9 +348,9 @@ describe('UsersPage', () => {
)

expect(await screen.findByRole('table', { name: 'Audit events' })).toBeDefined()
expect(screen.queryByRole('button', { name: 'Users' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Roles' })).toBeNull()
expect(screen.getByRole('button', { name: 'Audit' })).toBeDefined()
expect(screen.queryByRole('tab', { name: 'Users' })).toBeNull()
expect(screen.queryByRole('tab', { name: 'Roles' })).toBeNull()
expect(screen.getByRole('tab', { name: 'Audit' })).toBeDefined()
expect(screen.queryByRole('button', { name: /create user/i })).toBeNull()
expect(screen.queryByRole('button', { name: /create role/i })).toBeNull()
expect(screen.getByText('Tester One was created')).toBeDefined()
Expand Down Expand Up @@ -464,7 +464,7 @@ describe('UsersPage', () => {
</Wrapper>,
)

fireEvent.click(await screen.findByRole('button', { name: 'Roles' }))
fireEvent.click(await screen.findByRole('tab', { name: 'Roles' }))

expect(screen.queryByRole('heading', { name: 'Create Role' })).toBeNull()
const rolesTable = await screen.findByRole('table', { name: 'Roles' })
Expand Down Expand Up @@ -525,7 +525,7 @@ describe('UsersPage', () => {
</Wrapper>,
)

fireEvent.click(await screen.findByRole('button', { name: 'Audit' }))
fireEvent.click(await screen.findByRole('tab', { name: 'Audit' }))

const auditTable = await screen.findByRole('table', { name: 'Audit events' })
expect(auditTable.getAttribute('data-density')).toBe('compact')
Expand Down
6 changes: 0 additions & 6 deletions src/admin/pages/account/AccountPage.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,6 @@
line-height: 1.45;
}

.tabsRow {
display: flex;
flex-wrap: wrap;
gap: var(--space-s);
}

.section {
display: grid;
gap: var(--space-2xl);
Expand Down
57 changes: 25 additions & 32 deletions src/admin/pages/account/AccountPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
* overlay panels (DOM tree, properties) on the Site workspace.
*/
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 { useAuthenticatedAdminUser } from '@admin/sessionContext'
import { ProfileTab } from './tabs/ProfileTab'
Expand All @@ -37,16 +37,16 @@ import { SecurityTab } from './tabs/SecurityTab'
import { ActivityTab } from './tabs/ActivityTab'
import styles from './AccountPage.module.css'

type Tab = 'profile' | 'sessions' | 'security' | 'activity'
type AccountTab = 'profile' | 'sessions' | 'security' | 'activity'

const TAB_LABELS: Record<Tab, string> = {
const TAB_LABELS: Record<AccountTab, string> = {
profile: 'Profile',
sessions: 'Active devices',
security: 'Security',
activity: 'Sign-in history',
}

const TAB_ORDER: readonly Tab[] = ['profile', 'sessions', 'security', 'activity']
const TAB_ORDER: readonly AccountTab[] = ['profile', 'sessions', 'security', 'activity']

export function AccountPage() {
// The page renders inside the authenticated branch of `AdminEntry` — by
Expand All @@ -55,41 +55,34 @@ export function AccountPage() {
// hand a non-nullable `user` down to its tabs without a "what if it's
// null" fallback.
const user = useAuthenticatedAdminUser()
const [tab, setTab] = useState<Tab>('profile')
const [tab, setTab] = useState<AccountTab>('profile')

const tabs = (
<div role="tablist" aria-label="Account sections" className={styles.tabsRow}>
<TabList ariaLabel="Account sections">
{TAB_ORDER.map((id) => (
<Button
key={id}
type="button"
variant={tab === id ? 'primary' : 'secondary'}
size="sm"
onClick={() => setTab(id)}
role="tab"
aria-selected={tab === id}
data-testid={`account-tab-${id}`}
>
<Tab key={id} value={id} testId={`account-tab-${id}`}>
<span>{TAB_LABELS[id]}</span>
</Button>
</Tab>
))}
</div>
</TabList>
)

return (
<AdminPageLayout
workspace="account"
title="Account"
titleId="account-title"
description="Manage your profile, devices, security, and sign-in activity."
tabs={tabs}
>
<div className={styles.body}>
{tab === 'profile' && <ProfileTab user={user} />}
{tab === 'sessions' && <SessionsTab />}
{tab === 'security' && <SecurityTab user={user} />}
{tab === 'activity' && <ActivityTab />}
</div>
</AdminPageLayout>
<Tabs value={tab} onChange={setTab}>
<AdminPageLayout
workspace="account"
title="Account"
titleId="account-title"
description="Manage your profile, devices, security, and sign-in activity."
tabs={tabs}
>
<div className={styles.body}>
<TabPanel value="profile"><ProfileTab user={user} /></TabPanel>
<TabPanel value="sessions"><SessionsTab /></TabPanel>
<TabPanel value="security"><SecurityTab user={user} /></TabPanel>
<TabPanel value="activity"><ActivityTab /></TabPanel>
</div>
</AdminPageLayout>
</Tabs>
)
}
6 changes: 0 additions & 6 deletions src/admin/pages/ai/AiPage.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading