@talelk/newui-organisms 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +12 -2
- package/package.json +38 -3
- package/src/exports.test.ts +27 -0
- package/src/index.ts +1 -0
- package/src/organisms/AppSidebar/AppSidebar.test.tsx +100 -0
- package/src/organisms/AppSidebar/AppSidebar.tsx +214 -0
- package/src/organisms/AppSidebar/index.ts +1 -0
- package/src/organisms/AuthCard/AuthCard.a11y.test.tsx +36 -0
- package/src/organisms/AuthCard/AuthCard.test.tsx +56 -0
- package/src/organisms/AuthCard/AuthCard.tsx +80 -0
- package/src/organisms/AuthCard/index.ts +1 -0
- package/src/organisms/CommandPalette/CommandPalette.test.tsx +196 -0
- package/src/organisms/CommandPalette/CommandPalette.tsx +457 -0
- package/src/organisms/CommandPalette/index.ts +1 -0
- package/src/organisms/DataTable/DataTable.test.tsx +89 -0
- package/src/organisms/DataTable/DataTable.tsx +334 -0
- package/src/organisms/DataTable/index.ts +1 -0
- package/src/organisms/DetailHeader/DetailHeader.test.tsx +55 -0
- package/src/organisms/DetailHeader/DetailHeader.tsx +87 -0
- package/src/organisms/DetailHeader/index.ts +1 -0
- package/src/organisms/FilterBar/FilterBar.test.tsx +151 -0
- package/src/organisms/FilterBar/FilterBar.tsx +232 -0
- package/src/organisms/FilterBar/index.ts +1 -0
- package/src/organisms/KanbanBoard/KanbanBoard.test.tsx +255 -0
- package/src/organisms/KanbanBoard/KanbanBoard.tsx +550 -0
- package/src/organisms/KanbanBoard/drag.test.ts +448 -0
- package/src/organisms/KanbanBoard/drag.ts +407 -0
- package/src/organisms/KanbanBoard/index.ts +17 -0
- package/src/organisms/MobileNav/MobileNav.browser.test.tsx +39 -0
- package/src/organisms/MobileNav/MobileNav.test.tsx +108 -0
- package/src/organisms/MobileNav/MobileNav.tsx +120 -0
- package/src/organisms/MobileNav/index.ts +6 -0
- package/src/organisms/PageToolbar/PageToolbar.a11y.test.tsx +35 -0
- package/src/organisms/PageToolbar/PageToolbar.test.tsx +42 -0
- package/src/organisms/PageToolbar/PageToolbar.tsx +77 -0
- package/src/organisms/PageToolbar/index.ts +1 -0
- package/src/organisms/SettingsDialog/SettingsDialog.a11y.test.tsx +58 -0
- package/src/organisms/SettingsDialog/SettingsDialog.browser.test.tsx +43 -0
- package/src/organisms/SettingsDialog/SettingsDialog.ssr.test.tsx +27 -0
- package/src/organisms/SettingsDialog/SettingsDialog.test.tsx +202 -0
- package/src/organisms/SettingsDialog/SettingsDialog.tsx +539 -0
- package/src/organisms/SettingsDialog/index.ts +20 -0
- package/src/organisms/Sidebar/Sidebar.browser.test.tsx +52 -0
- package/src/organisms/Sidebar/Sidebar.test.tsx +308 -0
- package/src/organisms/Sidebar/Sidebar.tsx +725 -0
- package/src/organisms/Sidebar/__screenshots__/Sidebar.browser.test.tsx/Sidebar--browser--real-CSS--reserves-its-declared-width-1.png +0 -0
- package/src/organisms/Sidebar/index.ts +22 -0
- package/src/organisms/StatGrid/StatGrid.a11y.test.tsx +40 -0
- package/src/organisms/StatGrid/StatGrid.test.tsx +83 -0
- package/src/organisms/StatGrid/StatGrid.tsx +124 -0
- package/src/organisms/StatGrid/index.ts +1 -0
- package/src/organisms/VirtualList/VirtualList.test.tsx +80 -0
- package/src/organisms/VirtualList/VirtualList.tsx +79 -0
- package/src/organisms/VirtualList/index.ts +1 -0
- package/src/organisms/index.ts +13 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { render } from '@solidjs/testing-library'
|
|
2
|
+
import axe, { type AxeResults } from 'axe-core'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
import { PageToolbar } from './PageToolbar'
|
|
5
|
+
|
|
6
|
+
const AXE_OPTIONS = {
|
|
7
|
+
rules: {
|
|
8
|
+
'color-contrast': { enabled: false },
|
|
9
|
+
region: { enabled: false },
|
|
10
|
+
},
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function summarize(results: AxeResults) {
|
|
14
|
+
return results.violations.map((violation) => ({
|
|
15
|
+
id: violation.id,
|
|
16
|
+
impact: violation.impact,
|
|
17
|
+
targets: violation.nodes.map((node) => node.target),
|
|
18
|
+
}))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('PageToolbar accessibility', () => {
|
|
22
|
+
it('has no axe violations with a menu and a primary action', async () => {
|
|
23
|
+
const { container } = render(() => (
|
|
24
|
+
<PageToolbar
|
|
25
|
+
title="Q4 invoices"
|
|
26
|
+
menu={[
|
|
27
|
+
{ label: 'Rename', onClick: () => {} },
|
|
28
|
+
{ label: 'Archive', onClick: () => {} },
|
|
29
|
+
]}
|
|
30
|
+
action={{ label: 'New invoice', onClick: () => {} }}
|
|
31
|
+
/>
|
|
32
|
+
))
|
|
33
|
+
expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
|
|
34
|
+
})
|
|
35
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { render, screen } from '@solidjs/testing-library'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import { PageToolbar } from './PageToolbar'
|
|
5
|
+
|
|
6
|
+
describe('PageToolbar', () => {
|
|
7
|
+
it('renders the title and the primary action', () => {
|
|
8
|
+
render(() => <PageToolbar title="Tickets" action={{ label: 'Add new' }} />)
|
|
9
|
+
expect(screen.getByText('Tickets')).toBeTruthy()
|
|
10
|
+
expect(screen.getByRole('button', { name: /Add new/ })).toBeTruthy()
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('calls the primary action', async () => {
|
|
14
|
+
const onClick = vi.fn()
|
|
15
|
+
render(() => (
|
|
16
|
+
<PageToolbar title="Tickets" action={{ label: 'Add new', onClick }} />
|
|
17
|
+
))
|
|
18
|
+
await userEvent.click(screen.getByRole('button', { name: /Add new/ }))
|
|
19
|
+
expect(onClick).toHaveBeenCalled()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('renders no title menu unless one is given', () => {
|
|
23
|
+
const { container } = render(() => <PageToolbar title="Tickets" />)
|
|
24
|
+
expect(container.querySelector('[data-slot="menu-trigger"]')).toBeNull()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('opens the title menu and runs the chosen entry', async () => {
|
|
28
|
+
const onArchive = vi.fn()
|
|
29
|
+
render(() => (
|
|
30
|
+
<PageToolbar
|
|
31
|
+
title="Tickets"
|
|
32
|
+
menu={[{ label: 'Archive', onClick: onArchive }]}
|
|
33
|
+
/>
|
|
34
|
+
))
|
|
35
|
+
|
|
36
|
+
await userEvent.click(screen.getByRole('button', { name: 'Page actions' }))
|
|
37
|
+
await userEvent.click(
|
|
38
|
+
await screen.findByRole('menuitem', { name: /Archive/ }),
|
|
39
|
+
)
|
|
40
|
+
expect(onArchive).toHaveBeenCalled()
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Button, type IconProp } from '@talelk/newui-atoms'
|
|
2
|
+
import { Dropdown } from '@talelk/newui-molecules'
|
|
3
|
+
import { type JSX, Show } from 'solid-js'
|
|
4
|
+
|
|
5
|
+
export interface PageToolbarAction {
|
|
6
|
+
label: string
|
|
7
|
+
/** A `lucide-*` string or a component (P11). */
|
|
8
|
+
icon?: IconProp
|
|
9
|
+
onClick?: () => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface PageToolbarProps {
|
|
13
|
+
title: JSX.Element
|
|
14
|
+
/** Options for the ellipsis menu beside the title. Omitted → no menu. */
|
|
15
|
+
menu?: PageToolbarAction[]
|
|
16
|
+
/** The primary action — the one thing this screen is for. */
|
|
17
|
+
action?: PageToolbarAction
|
|
18
|
+
/** Anything else on the trailing edge, after the primary action. */
|
|
19
|
+
children?: JSX.Element
|
|
20
|
+
class?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The page header: a title, an optional title menu, and the actions.
|
|
25
|
+
*
|
|
26
|
+
* Small, but it is the three details inside it that were being re-decided on
|
|
27
|
+
* every screen: the title menu is an ellipsis *button* (a chevron next to a
|
|
28
|
+
* title reads as a dropdown for the title, which is exactly what it is — but
|
|
29
|
+
* the ellipsis is the convention), the primary action carries a leading `+` only
|
|
30
|
+
* when it creates something, and the header is `PageHeader` rather than a bare
|
|
31
|
+
* sticky div so it flips to the pinned mobile variant with the shell.
|
|
32
|
+
*/
|
|
33
|
+
export function PageToolbar(props: PageToolbarProps): JSX.Element {
|
|
34
|
+
return (
|
|
35
|
+
<header
|
|
36
|
+
class={`flex min-h-12 shrink-0 items-center justify-between gap-control-gap border-b border-outline-gray-1 bg-surface-base px-page-gutter-compact sm:px-page-gutter ${props.class ?? ''}`}
|
|
37
|
+
>
|
|
38
|
+
<div class="flex min-w-0 items-center gap-1">
|
|
39
|
+
<span class="truncate text-heading text-ink-gray-9">{props.title}</span>
|
|
40
|
+
<Show when={props.menu}>
|
|
41
|
+
{(menu) => (
|
|
42
|
+
<Dropdown
|
|
43
|
+
options={menu().map((entry) => ({
|
|
44
|
+
label: entry.label,
|
|
45
|
+
value: entry.label,
|
|
46
|
+
...(typeof entry.icon === 'string' ? { icon: entry.icon } : {}),
|
|
47
|
+
}))}
|
|
48
|
+
onSelect={(value) =>
|
|
49
|
+
menu()
|
|
50
|
+
.find((entry) => entry.label === value)
|
|
51
|
+
?.onClick?.()
|
|
52
|
+
}
|
|
53
|
+
>
|
|
54
|
+
<span class="sr-only">Page actions</span>
|
|
55
|
+
</Dropdown>
|
|
56
|
+
)}
|
|
57
|
+
</Show>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<div class="flex shrink-0 items-center gap-control-gap">
|
|
61
|
+
{props.children}
|
|
62
|
+
<Show when={props.action}>
|
|
63
|
+
{(action) => (
|
|
64
|
+
<Button
|
|
65
|
+
variant="solid"
|
|
66
|
+
theme="blue"
|
|
67
|
+
iconLeft={action().icon ?? 'lucide-plus'}
|
|
68
|
+
onClick={action().onClick}
|
|
69
|
+
>
|
|
70
|
+
{action().label}
|
|
71
|
+
</Button>
|
|
72
|
+
)}
|
|
73
|
+
</Show>
|
|
74
|
+
</div>
|
|
75
|
+
</header>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './PageToolbar'
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { render } from '@solidjs/testing-library'
|
|
2
|
+
import axe, { type AxeResults } from 'axe-core'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
import { SettingsDialog } from './SettingsDialog'
|
|
5
|
+
|
|
6
|
+
const AXE_OPTIONS = {
|
|
7
|
+
rules: {
|
|
8
|
+
'color-contrast': { enabled: false },
|
|
9
|
+
region: { enabled: false },
|
|
10
|
+
},
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function summarize(results: AxeResults) {
|
|
14
|
+
return results.violations.map((violation) => ({
|
|
15
|
+
id: violation.id,
|
|
16
|
+
impact: violation.impact,
|
|
17
|
+
targets: violation.nodes.map((node) => node.target),
|
|
18
|
+
}))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('SettingsDialog accessibility', () => {
|
|
22
|
+
it('has no axe violations when open', async () => {
|
|
23
|
+
render(() => (
|
|
24
|
+
<SettingsDialog.Root defaultOpen defaultValue="profile">
|
|
25
|
+
<SettingsDialog.Sidebar>
|
|
26
|
+
<SettingsDialog.NavGroup label="User settings">
|
|
27
|
+
<SettingsDialog.NavItem value="profile">
|
|
28
|
+
Profile
|
|
29
|
+
</SettingsDialog.NavItem>
|
|
30
|
+
<SettingsDialog.NavItem value="notifications">
|
|
31
|
+
Notifications
|
|
32
|
+
</SettingsDialog.NavItem>
|
|
33
|
+
</SettingsDialog.NavGroup>
|
|
34
|
+
</SettingsDialog.Sidebar>
|
|
35
|
+
<SettingsDialog.Content>
|
|
36
|
+
<SettingsDialog.Panel value="profile">
|
|
37
|
+
<SettingsDialog.Header title="Profile" description="Your details" />
|
|
38
|
+
<SettingsDialog.Body>
|
|
39
|
+
<SettingsDialog.Row
|
|
40
|
+
title="Display name"
|
|
41
|
+
description="Shown to others"
|
|
42
|
+
labelFor="display-name"
|
|
43
|
+
>
|
|
44
|
+
<input id="display-name" type="text" />
|
|
45
|
+
</SettingsDialog.Row>
|
|
46
|
+
</SettingsDialog.Body>
|
|
47
|
+
</SettingsDialog.Panel>
|
|
48
|
+
<SettingsDialog.Panel value="notifications">
|
|
49
|
+
<SettingsDialog.Header title="Notifications" />
|
|
50
|
+
<SettingsDialog.Body>Nothing here.</SettingsDialog.Body>
|
|
51
|
+
</SettingsDialog.Panel>
|
|
52
|
+
</SettingsDialog.Content>
|
|
53
|
+
</SettingsDialog.Root>
|
|
54
|
+
))
|
|
55
|
+
|
|
56
|
+
expect(summarize(await axe.run(document.body, AXE_OPTIONS))).toEqual([])
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { render } from '@solidjs/testing-library'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { SettingsDialog } from './SettingsDialog'
|
|
4
|
+
|
|
5
|
+
describe('SettingsDialog (browser, real CSS)', () => {
|
|
6
|
+
it('paints the dialog, sidebar and nav rows', () => {
|
|
7
|
+
render(() => (
|
|
8
|
+
<SettingsDialog.Root defaultOpen>
|
|
9
|
+
<SettingsDialog.Sidebar>
|
|
10
|
+
<SettingsDialog.NavItem value="general">
|
|
11
|
+
General
|
|
12
|
+
</SettingsDialog.NavItem>
|
|
13
|
+
</SettingsDialog.Sidebar>
|
|
14
|
+
<SettingsDialog.Content>
|
|
15
|
+
<SettingsDialog.Panel value="general">
|
|
16
|
+
<SettingsDialog.Header title="General" />
|
|
17
|
+
<SettingsDialog.Body>Body</SettingsDialog.Body>
|
|
18
|
+
</SettingsDialog.Panel>
|
|
19
|
+
</SettingsDialog.Content>
|
|
20
|
+
</SettingsDialog.Root>
|
|
21
|
+
))
|
|
22
|
+
|
|
23
|
+
const content = document.querySelector(
|
|
24
|
+
'[data-slot="dialog-content"]',
|
|
25
|
+
) as HTMLElement
|
|
26
|
+
expect(content).not.toBeNull()
|
|
27
|
+
expect(getComputedStyle(content).backgroundColor).not.toBe(
|
|
28
|
+
'rgba(0, 0, 0, 0)',
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
const sidebar = document.querySelector(
|
|
32
|
+
'[data-slot="settings-sidebar"]',
|
|
33
|
+
) as HTMLElement
|
|
34
|
+
expect(getComputedStyle(sidebar).backgroundColor).not.toBe(
|
|
35
|
+
'rgba(0, 0, 0, 0)',
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
const navItem = document.querySelector(
|
|
39
|
+
'[data-slot="settings-nav-item"]',
|
|
40
|
+
) as HTMLElement
|
|
41
|
+
expect(navItem.getBoundingClientRect().height).toBeGreaterThanOrEqual(24)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { renderToString } from 'solid-js/web'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { SettingsDialog } from './SettingsDialog'
|
|
4
|
+
|
|
5
|
+
describe('SSR smoke', () => {
|
|
6
|
+
it('SettingsDialog renders its closed state without a dialog', () => {
|
|
7
|
+
const html = renderToString(() => (
|
|
8
|
+
<SettingsDialog.Root>
|
|
9
|
+
<SettingsDialog.Sidebar>
|
|
10
|
+
<SettingsDialog.NavGroup label="User settings">
|
|
11
|
+
<SettingsDialog.NavItem value="profile">
|
|
12
|
+
Profile
|
|
13
|
+
</SettingsDialog.NavItem>
|
|
14
|
+
</SettingsDialog.NavGroup>
|
|
15
|
+
</SettingsDialog.Sidebar>
|
|
16
|
+
<SettingsDialog.Content>
|
|
17
|
+
<SettingsDialog.Panel value="profile">
|
|
18
|
+
Profile body
|
|
19
|
+
</SettingsDialog.Panel>
|
|
20
|
+
</SettingsDialog.Content>
|
|
21
|
+
</SettingsDialog.Root>
|
|
22
|
+
))
|
|
23
|
+
|
|
24
|
+
expect(html).not.toContain('role="dialog"')
|
|
25
|
+
expect(html).not.toContain('data-slot="settings-sidebar"')
|
|
26
|
+
})
|
|
27
|
+
})
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { render, screen } from '@solidjs/testing-library'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { createSignal } from 'solid-js'
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { SettingsDialog } from './SettingsDialog'
|
|
6
|
+
|
|
7
|
+
interface ExampleProps {
|
|
8
|
+
open?: boolean
|
|
9
|
+
value?: string
|
|
10
|
+
defaultValue?: string
|
|
11
|
+
onValueChange?: (value: string) => void
|
|
12
|
+
onOpenChange?: (open: boolean) => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function Example(props: ExampleProps) {
|
|
16
|
+
return (
|
|
17
|
+
<SettingsDialog.Root
|
|
18
|
+
open={props.open}
|
|
19
|
+
defaultOpen={props.open === undefined ? true : undefined}
|
|
20
|
+
onOpenChange={props.onOpenChange}
|
|
21
|
+
value={props.value}
|
|
22
|
+
defaultValue={props.defaultValue}
|
|
23
|
+
onValueChange={props.onValueChange}
|
|
24
|
+
>
|
|
25
|
+
<SettingsDialog.Sidebar>
|
|
26
|
+
<SettingsDialog.NavGroup label="User settings">
|
|
27
|
+
<SettingsDialog.NavItem value="profile">
|
|
28
|
+
Profile
|
|
29
|
+
</SettingsDialog.NavItem>
|
|
30
|
+
<SettingsDialog.NavItem value="notifications">
|
|
31
|
+
Notifications
|
|
32
|
+
</SettingsDialog.NavItem>
|
|
33
|
+
</SettingsDialog.NavGroup>
|
|
34
|
+
</SettingsDialog.Sidebar>
|
|
35
|
+
<SettingsDialog.Content>
|
|
36
|
+
<SettingsDialog.Panel value="profile">
|
|
37
|
+
<SettingsDialog.Header title="Profile" />
|
|
38
|
+
<SettingsDialog.Body>Profile body</SettingsDialog.Body>
|
|
39
|
+
</SettingsDialog.Panel>
|
|
40
|
+
<SettingsDialog.Panel value="notifications">
|
|
41
|
+
<SettingsDialog.Header title="Notifications" />
|
|
42
|
+
<SettingsDialog.Body>Notifications body</SettingsDialog.Body>
|
|
43
|
+
</SettingsDialog.Panel>
|
|
44
|
+
</SettingsDialog.Content>
|
|
45
|
+
</SettingsDialog.Root>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('SettingsDialog', () => {
|
|
50
|
+
it('opens on request and closes on Escape', async () => {
|
|
51
|
+
function Harness() {
|
|
52
|
+
const [open, setOpen] = createSignal(false)
|
|
53
|
+
return (
|
|
54
|
+
<div>
|
|
55
|
+
<button type="button" onClick={() => setOpen(true)}>
|
|
56
|
+
open settings
|
|
57
|
+
</button>
|
|
58
|
+
<SettingsDialog.Root open={open()} onOpenChange={setOpen}>
|
|
59
|
+
<SettingsDialog.Sidebar>
|
|
60
|
+
<SettingsDialog.NavItem value="profile">
|
|
61
|
+
Profile
|
|
62
|
+
</SettingsDialog.NavItem>
|
|
63
|
+
</SettingsDialog.Sidebar>
|
|
64
|
+
<SettingsDialog.Content>
|
|
65
|
+
<SettingsDialog.Panel value="profile">
|
|
66
|
+
Profile body
|
|
67
|
+
</SettingsDialog.Panel>
|
|
68
|
+
</SettingsDialog.Content>
|
|
69
|
+
</SettingsDialog.Root>
|
|
70
|
+
</div>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
render(() => <Harness />)
|
|
75
|
+
expect(screen.queryByRole('dialog')).toBeNull()
|
|
76
|
+
|
|
77
|
+
await userEvent.click(screen.getByRole('button', { name: 'open settings' }))
|
|
78
|
+
expect(screen.getByRole('dialog')).toBeTruthy()
|
|
79
|
+
|
|
80
|
+
await userEvent.keyboard('{Escape}')
|
|
81
|
+
expect(screen.queryByRole('dialog')).toBeNull()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('reports a close through onOpenChange', async () => {
|
|
85
|
+
const onOpenChange = vi.fn()
|
|
86
|
+
render(() => <Example open onOpenChange={onOpenChange} />)
|
|
87
|
+
|
|
88
|
+
await userEvent.keyboard('{Escape}')
|
|
89
|
+
expect(onOpenChange).toHaveBeenCalledWith(false)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('marks the selected nav item active', () => {
|
|
93
|
+
render(() => <Example defaultValue="profile" />)
|
|
94
|
+
|
|
95
|
+
const profile = screen.getByRole('tab', { name: 'Profile' })
|
|
96
|
+
const notifications = screen.getByRole('tab', { name: 'Notifications' })
|
|
97
|
+
|
|
98
|
+
expect(profile.getAttribute('aria-selected')).toBe('true')
|
|
99
|
+
expect(profile.getAttribute('data-state')).toBe('active')
|
|
100
|
+
expect(notifications.getAttribute('aria-selected')).toBe('false')
|
|
101
|
+
expect(notifications.getAttribute('data-state')).toBe('inactive')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('switches panels when a nav item is clicked', async () => {
|
|
105
|
+
const onValueChange = vi.fn()
|
|
106
|
+
render(() => (
|
|
107
|
+
<Example defaultValue="profile" onValueChange={onValueChange} />
|
|
108
|
+
))
|
|
109
|
+
|
|
110
|
+
expect(screen.getByRole('tabpanel').textContent).toContain('Profile body')
|
|
111
|
+
|
|
112
|
+
await userEvent.click(screen.getByRole('tab', { name: 'Notifications' }))
|
|
113
|
+
|
|
114
|
+
expect(onValueChange).toHaveBeenCalledWith('notifications')
|
|
115
|
+
expect(screen.getByRole('tabpanel').textContent).toContain(
|
|
116
|
+
'Notifications body',
|
|
117
|
+
)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('keeps every panel in the DOM, hiding the inactive ones', () => {
|
|
121
|
+
render(() => <Example defaultValue="profile" />)
|
|
122
|
+
|
|
123
|
+
const panels = document.querySelectorAll('[data-slot="settings-panel"]')
|
|
124
|
+
expect(panels).toHaveLength(2)
|
|
125
|
+
expect(panels[0]?.getAttribute('data-state')).toBe('active')
|
|
126
|
+
expect(panels[1]?.getAttribute('data-state')).toBe('inactive')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('honours an explicit active prop over the dialog value', () => {
|
|
130
|
+
render(() => (
|
|
131
|
+
<SettingsDialog.Root defaultOpen defaultValue="beta">
|
|
132
|
+
<SettingsDialog.Sidebar>
|
|
133
|
+
<SettingsDialog.NavItem value="alpha" active>
|
|
134
|
+
Alpha
|
|
135
|
+
</SettingsDialog.NavItem>
|
|
136
|
+
<SettingsDialog.NavItem value="beta">Beta</SettingsDialog.NavItem>
|
|
137
|
+
</SettingsDialog.Sidebar>
|
|
138
|
+
</SettingsDialog.Root>
|
|
139
|
+
))
|
|
140
|
+
|
|
141
|
+
expect(
|
|
142
|
+
screen.getByRole('tab', { name: 'Alpha' }).getAttribute('data-state'),
|
|
143
|
+
).toBe('active')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('renders the header title, description and actions', () => {
|
|
147
|
+
render(() => (
|
|
148
|
+
<SettingsDialog.Root defaultOpen defaultValue="profile">
|
|
149
|
+
<SettingsDialog.Content>
|
|
150
|
+
<SettingsDialog.Panel value="profile">
|
|
151
|
+
<SettingsDialog.Header
|
|
152
|
+
title="Profile"
|
|
153
|
+
description="Your details"
|
|
154
|
+
actions={<button type="button">Save</button>}
|
|
155
|
+
/>
|
|
156
|
+
<SettingsDialog.Body>Body</SettingsDialog.Body>
|
|
157
|
+
</SettingsDialog.Panel>
|
|
158
|
+
</SettingsDialog.Content>
|
|
159
|
+
</SettingsDialog.Root>
|
|
160
|
+
))
|
|
161
|
+
|
|
162
|
+
expect(screen.getByText('Profile')).toBeTruthy()
|
|
163
|
+
expect(screen.getByText('Your details')).toBeTruthy()
|
|
164
|
+
expect(screen.getByRole('button', { name: 'Save' })).toBeTruthy()
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('associates a row title with the control through labelFor', () => {
|
|
168
|
+
render(() => (
|
|
169
|
+
<SettingsDialog.Root defaultOpen defaultValue="profile">
|
|
170
|
+
<SettingsDialog.Content>
|
|
171
|
+
<SettingsDialog.Panel value="profile">
|
|
172
|
+
<SettingsDialog.Row title="Display name" labelFor="display-name">
|
|
173
|
+
<input id="display-name" type="text" />
|
|
174
|
+
</SettingsDialog.Row>
|
|
175
|
+
</SettingsDialog.Panel>
|
|
176
|
+
</SettingsDialog.Content>
|
|
177
|
+
</SettingsDialog.Root>
|
|
178
|
+
))
|
|
179
|
+
|
|
180
|
+
const label = screen.getByText('Display name')
|
|
181
|
+
expect(label.tagName).toBe('LABEL')
|
|
182
|
+
expect(label.getAttribute('for')).toBe('display-name')
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('detects the control id for the row title when labelFor is omitted', () => {
|
|
186
|
+
render(() => (
|
|
187
|
+
<SettingsDialog.Root defaultOpen defaultValue="profile">
|
|
188
|
+
<SettingsDialog.Content>
|
|
189
|
+
<SettingsDialog.Panel value="profile">
|
|
190
|
+
<SettingsDialog.Row title="Display name">
|
|
191
|
+
<input id="auto-name" type="text" />
|
|
192
|
+
</SettingsDialog.Row>
|
|
193
|
+
</SettingsDialog.Panel>
|
|
194
|
+
</SettingsDialog.Content>
|
|
195
|
+
</SettingsDialog.Root>
|
|
196
|
+
))
|
|
197
|
+
|
|
198
|
+
const label = screen.getByText('Display name')
|
|
199
|
+
expect(label.tagName).toBe('LABEL')
|
|
200
|
+
expect(label.getAttribute('for')).toBe('auto-name')
|
|
201
|
+
})
|
|
202
|
+
})
|