@talelk/newui-organisms 0.0.0-stage → 2.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 +136 -0
- package/src/organisms/AppSidebar/AppSidebar.tsx +259 -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 +89 -0
- package/src/organisms/AuthCard/index.ts +1 -0
- package/src/organisms/CommandPalette/CommandPalette.test.tsx +196 -0
- package/src/organisms/CommandPalette/CommandPalette.tsx +468 -0
- package/src/organisms/CommandPalette/index.ts +1 -0
- package/src/organisms/DataState/DataState.test.tsx +45 -0
- package/src/organisms/DataState/DataState.tsx +111 -0
- package/src/organisms/DataState/index.ts +6 -0
- package/src/organisms/DataTable/DataTable.test.tsx +161 -0
- package/src/organisms/DataTable/DataTable.tsx +612 -0
- package/src/organisms/DataTable/index.ts +1 -0
- package/src/organisms/DetailHeader/DetailHeader.test.tsx +55 -0
- package/src/organisms/DetailHeader/DetailHeader.tsx +95 -0
- package/src/organisms/DetailHeader/index.ts +1 -0
- package/src/organisms/FilterBar/FilterBar.test.tsx +152 -0
- package/src/organisms/FilterBar/FilterBar.tsx +251 -0
- package/src/organisms/FilterBar/index.ts +1 -0
- package/src/organisms/KanbanBoard/KanbanBoard.test.tsx +255 -0
- package/src/organisms/KanbanBoard/KanbanBoard.tsx +605 -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 +126 -0
- package/src/organisms/PageToolbar/index.ts +1 -0
- package/src/organisms/Section/Section.test.tsx +41 -0
- package/src/organisms/Section/Section.tsx +108 -0
- package/src/organisms/Section/index.ts +1 -0
- package/src/organisms/SelectionBar/SelectionBar.test.tsx +35 -0
- package/src/organisms/SelectionBar/SelectionBar.tsx +84 -0
- package/src/organisms/SelectionBar/index.ts +5 -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 +548 -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 +732 -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 +95 -0
- package/src/organisms/StatGrid/StatGrid.tsx +151 -0
- package/src/organisms/StatGrid/index.ts +1 -0
- package/src/organisms/VirtualList/VirtualList.test.tsx +80 -0
- package/src/organisms/VirtualList/VirtualList.tsx +113 -0
- package/src/organisms/VirtualList/index.ts +1 -0
- package/src/organisms/index.ts +16 -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,126 @@
|
|
|
1
|
+
import { Button, cn, Icon, type IconProp } from '@talelk/newui-atoms'
|
|
2
|
+
import { Menu } from '@talelk/newui-molecules'
|
|
3
|
+
import { For, type JSX, Show } from 'solid-js'
|
|
4
|
+
import { Dynamic } from 'solid-js/web'
|
|
5
|
+
|
|
6
|
+
export interface PageToolbarAction {
|
|
7
|
+
label: string
|
|
8
|
+
/** A `lucide-*` string or a component (P11). No icon is implied. */
|
|
9
|
+
icon?: IconProp
|
|
10
|
+
onClick?: () => void
|
|
11
|
+
loading?: boolean
|
|
12
|
+
disabled?: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PageToolbarProps {
|
|
16
|
+
title: JSX.Element
|
|
17
|
+
/** Semantic level of the title. @default 1 */
|
|
18
|
+
headingLevel?: 1 | 2 | 3 | 4
|
|
19
|
+
/** Options for the ellipsis menu beside the title. Omitted → no menu. */
|
|
20
|
+
menu?: PageToolbarAction[]
|
|
21
|
+
/** Secondary actions, before the primary. */
|
|
22
|
+
secondary?: PageToolbarAction[]
|
|
23
|
+
/** The primary action — the one thing this screen is for. */
|
|
24
|
+
action?: PageToolbarAction
|
|
25
|
+
/** Anything else on the trailing edge, after the primary action. */
|
|
26
|
+
children?: JSX.Element
|
|
27
|
+
class?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The page header: a title, an optional title menu, and the actions.
|
|
32
|
+
*
|
|
33
|
+
* Small, but it is the three details inside it that were being re-decided on
|
|
34
|
+
* every screen: the title menu is an ellipsis *button* (a chevron next to a
|
|
35
|
+
* title reads as a dropdown for the title, which is exactly what it is — but
|
|
36
|
+
* the ellipsis is the convention), the primary action carries a leading `+` only
|
|
37
|
+
* when it creates something, and the header is `PageHeader` rather than a bare
|
|
38
|
+
* sticky div so it flips to the pinned mobile variant with the shell.
|
|
39
|
+
*/
|
|
40
|
+
export function PageToolbar(props: PageToolbarProps): JSX.Element {
|
|
41
|
+
return (
|
|
42
|
+
<header
|
|
43
|
+
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 ?? ''}`}
|
|
44
|
+
>
|
|
45
|
+
<div class="flex min-w-0 items-center gap-1">
|
|
46
|
+
<Dynamic
|
|
47
|
+
component={`h${props.headingLevel ?? 1}`}
|
|
48
|
+
class={cn(
|
|
49
|
+
'truncate text-ink-gray-9',
|
|
50
|
+
(props.headingLevel ?? 1) === 1 ? 'text-heading' : 'text-section',
|
|
51
|
+
)}
|
|
52
|
+
>
|
|
53
|
+
{props.title}
|
|
54
|
+
</Dynamic>
|
|
55
|
+
<Show when={props.menu}>
|
|
56
|
+
{(menu) => (
|
|
57
|
+
<Menu.Root
|
|
58
|
+
onSelect={(value) =>
|
|
59
|
+
menu()
|
|
60
|
+
.find((entry) => entry.label === value)
|
|
61
|
+
?.onClick?.()
|
|
62
|
+
}
|
|
63
|
+
>
|
|
64
|
+
<Menu.Trigger
|
|
65
|
+
aria-label="Page actions"
|
|
66
|
+
class="grid size-7 shrink-0 place-items-center rounded-3 text-ink-gray-6 transition-colors hover:bg-surface-gray-2 hover:text-ink-gray-9 focus-visible:focus-ring"
|
|
67
|
+
>
|
|
68
|
+
<Icon icon="lucide-ellipsis" class="size-4" />
|
|
69
|
+
</Menu.Trigger>
|
|
70
|
+
<Menu.Content>
|
|
71
|
+
<For each={menu()}>
|
|
72
|
+
{(entry) => (
|
|
73
|
+
<Menu.Item value={entry.label}>
|
|
74
|
+
<Show when={entry.icon}>
|
|
75
|
+
{(icon) => (
|
|
76
|
+
<Icon
|
|
77
|
+
icon={icon()}
|
|
78
|
+
class="size-4 shrink-0 text-ink-gray-6"
|
|
79
|
+
/>
|
|
80
|
+
)}
|
|
81
|
+
</Show>
|
|
82
|
+
<span>{entry.label}</span>
|
|
83
|
+
</Menu.Item>
|
|
84
|
+
)}
|
|
85
|
+
</For>
|
|
86
|
+
</Menu.Content>
|
|
87
|
+
</Menu.Root>
|
|
88
|
+
)}
|
|
89
|
+
</Show>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<div class="flex shrink-0 items-center gap-control-gap">
|
|
93
|
+
{props.children}
|
|
94
|
+
<Show when={props.secondary}>
|
|
95
|
+
<For each={props.secondary}>
|
|
96
|
+
{(action) => (
|
|
97
|
+
<Button
|
|
98
|
+
variant="outline"
|
|
99
|
+
iconLeft={action.icon}
|
|
100
|
+
loading={action.loading}
|
|
101
|
+
disabled={action.disabled}
|
|
102
|
+
onClick={action.onClick}
|
|
103
|
+
>
|
|
104
|
+
{action.label}
|
|
105
|
+
</Button>
|
|
106
|
+
)}
|
|
107
|
+
</For>
|
|
108
|
+
</Show>
|
|
109
|
+
<Show when={props.action}>
|
|
110
|
+
{(action) => (
|
|
111
|
+
<Button
|
|
112
|
+
variant="solid"
|
|
113
|
+
theme="blue"
|
|
114
|
+
iconLeft={action().icon}
|
|
115
|
+
loading={action().loading}
|
|
116
|
+
disabled={action().disabled}
|
|
117
|
+
onClick={action().onClick}
|
|
118
|
+
>
|
|
119
|
+
{action().label}
|
|
120
|
+
</Button>
|
|
121
|
+
)}
|
|
122
|
+
</Show>
|
|
123
|
+
</div>
|
|
124
|
+
</header>
|
|
125
|
+
)
|
|
126
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './PageToolbar'
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { render, screen } from '@solidjs/testing-library'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { Section } from './Section'
|
|
4
|
+
|
|
5
|
+
describe('Section', () => {
|
|
6
|
+
it('renders a default H2 and the four region slots', () => {
|
|
7
|
+
render(() => (
|
|
8
|
+
<Section
|
|
9
|
+
title="Pending expenses"
|
|
10
|
+
description="Waiting for approval"
|
|
11
|
+
actions={<button type="button">Export</button>}
|
|
12
|
+
toolbar={<div>Filters</div>}
|
|
13
|
+
footer={<span>18 records</span>}
|
|
14
|
+
>
|
|
15
|
+
<div>Body</div>
|
|
16
|
+
</Section>
|
|
17
|
+
))
|
|
18
|
+
expect(screen.getByRole('heading', { level: 2 }).textContent).toBe(
|
|
19
|
+
'Pending expenses',
|
|
20
|
+
)
|
|
21
|
+
expect(screen.getByText('Waiting for approval')).toBeTruthy()
|
|
22
|
+
expect(screen.getByRole('button', { name: 'Export' })).toBeTruthy()
|
|
23
|
+
expect(screen.getByText('Filters')).toBeTruthy()
|
|
24
|
+
expect(screen.getByText('Body')).toBeTruthy()
|
|
25
|
+
expect(screen.getByText('18 records')).toBeTruthy()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('honours the heading level and compact density', () => {
|
|
29
|
+
const { container } = render(() => (
|
|
30
|
+
<Section title="Ledger" headingLevel={3} density="compact">
|
|
31
|
+
<div />
|
|
32
|
+
</Section>
|
|
33
|
+
))
|
|
34
|
+
expect(screen.getByRole('heading', { level: 3 }).textContent).toBe('Ledger')
|
|
35
|
+
expect(
|
|
36
|
+
container
|
|
37
|
+
.querySelector('[data-slot="section"]')
|
|
38
|
+
?.getAttribute('data-density'),
|
|
39
|
+
).toBe('compact')
|
|
40
|
+
})
|
|
41
|
+
})
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { cn } from '@talelk/newui-atoms'
|
|
2
|
+
import type { JSX } from 'solid-js'
|
|
3
|
+
import { Show } from 'solid-js'
|
|
4
|
+
import { Dynamic } from 'solid-js/web'
|
|
5
|
+
|
|
6
|
+
export type SectionHeadingLevel = 1 | 2 | 3 | 4
|
|
7
|
+
|
|
8
|
+
export interface SectionProps {
|
|
9
|
+
title?: JSX.Element
|
|
10
|
+
description?: JSX.Element
|
|
11
|
+
/** Page or section actions, rendered opposite the title. */
|
|
12
|
+
actions?: JSX.Element
|
|
13
|
+
/** Controls that change the data, between the header and the body. */
|
|
14
|
+
toolbar?: JSX.Element
|
|
15
|
+
/** Pagination, totals or selection feedback, under a rule. */
|
|
16
|
+
footer?: JSX.Element
|
|
17
|
+
/** @default 2 */
|
|
18
|
+
headingLevel?: SectionHeadingLevel
|
|
19
|
+
/** Explicit id for the rendered heading. Defaults to `<id>-title`. */
|
|
20
|
+
headingId?: string
|
|
21
|
+
/** Accessible name when the section has no visible title. */
|
|
22
|
+
label?: string
|
|
23
|
+
/** Id of the element that names the region; beats `label`/the heading id. */
|
|
24
|
+
labelledBy?: string
|
|
25
|
+
/** Surface treatment. @default 'plain' */
|
|
26
|
+
surface?: 'plain' | 'card' | 'inset'
|
|
27
|
+
/** @default 'comfortable' */
|
|
28
|
+
density?: 'comfortable' | 'compact'
|
|
29
|
+
id?: string
|
|
30
|
+
class?: string
|
|
31
|
+
children: JSX.Element
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const HEADING: Record<SectionHeadingLevel, string> = {
|
|
35
|
+
1: 'text-heading',
|
|
36
|
+
2: 'text-section',
|
|
37
|
+
3: 'text-base-medium',
|
|
38
|
+
4: 'text-interface font-medium',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One page-region anatomy: header, optional toolbar, body, optional footer.
|
|
43
|
+
*
|
|
44
|
+
* Data organisms should not each invent their own title row or footer rule.
|
|
45
|
+
* `Section` owns the region; the organism owns the data surface inside it.
|
|
46
|
+
*/
|
|
47
|
+
export function Section(props: SectionProps): JSX.Element {
|
|
48
|
+
const surface = () => props.surface ?? 'plain'
|
|
49
|
+
const density = () => props.density ?? 'comfortable'
|
|
50
|
+
const hasHeader = () =>
|
|
51
|
+
Boolean(props.title || props.description || props.actions)
|
|
52
|
+
const headingId = () =>
|
|
53
|
+
props.headingId ?? (props.id ? `${props.id}-title` : undefined)
|
|
54
|
+
const labelledBy = () =>
|
|
55
|
+
props.labelledBy ?? (props.title ? headingId() : undefined)
|
|
56
|
+
return (
|
|
57
|
+
<section
|
|
58
|
+
id={props.id}
|
|
59
|
+
aria-label={props.label}
|
|
60
|
+
aria-labelledby={labelledBy()}
|
|
61
|
+
data-slot="section"
|
|
62
|
+
data-surface={surface()}
|
|
63
|
+
data-density={density()}
|
|
64
|
+
class={cn(
|
|
65
|
+
'flex min-w-0 flex-col',
|
|
66
|
+
density() === 'compact' ? 'gap-2' : 'gap-3',
|
|
67
|
+
surface() === 'card' &&
|
|
68
|
+
'rounded-card border border-outline-gray-1 bg-surface-elevation-1 p-card-pad',
|
|
69
|
+
surface() === 'inset' && 'rounded-card bg-surface-gray-1 p-card-pad',
|
|
70
|
+
props.class,
|
|
71
|
+
)}
|
|
72
|
+
>
|
|
73
|
+
<Show when={hasHeader()}>
|
|
74
|
+
<div class="flex flex-wrap items-start justify-between gap-3">
|
|
75
|
+
<div class="flex min-w-0 flex-col gap-1">
|
|
76
|
+
<Show when={props.title}>
|
|
77
|
+
<Dynamic
|
|
78
|
+
component={`h${props.headingLevel ?? 2}`}
|
|
79
|
+
id={headingId()}
|
|
80
|
+
class={cn(
|
|
81
|
+
'min-w-0 text-ink-gray-9',
|
|
82
|
+
HEADING[props.headingLevel ?? 2],
|
|
83
|
+
)}
|
|
84
|
+
>
|
|
85
|
+
{props.title}
|
|
86
|
+
</Dynamic>
|
|
87
|
+
</Show>
|
|
88
|
+
<Show when={props.description}>
|
|
89
|
+
<p class="max-w-[56ch] text-caption text-ink-gray-7">
|
|
90
|
+
{props.description}
|
|
91
|
+
</p>
|
|
92
|
+
</Show>
|
|
93
|
+
</div>
|
|
94
|
+
<Show when={props.actions}>
|
|
95
|
+
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
|
96
|
+
{props.actions}
|
|
97
|
+
</div>
|
|
98
|
+
</Show>
|
|
99
|
+
</div>
|
|
100
|
+
</Show>
|
|
101
|
+
<Show when={props.toolbar}>{props.toolbar}</Show>
|
|
102
|
+
<div class="min-w-0">{props.children}</div>
|
|
103
|
+
<Show when={props.footer}>
|
|
104
|
+
<div class="border-t border-outline-gray-1 pt-3">{props.footer}</div>
|
|
105
|
+
</Show>
|
|
106
|
+
</section>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Section, type SectionHeadingLevel, type SectionProps } from './Section'
|
|
@@ -0,0 +1,35 @@
|
|
|
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 { SelectionBar } from './SelectionBar'
|
|
5
|
+
|
|
6
|
+
describe('SelectionBar', () => {
|
|
7
|
+
it('renders nothing without a selection', () => {
|
|
8
|
+
const { container } = render(() => <SelectionBar count={0} />)
|
|
9
|
+
expect(container.querySelector('[data-slot="selection-bar"]')).toBeNull()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('shows the count, action and clear control', async () => {
|
|
13
|
+
const onAction = vi.fn()
|
|
14
|
+
const onClear = vi.fn()
|
|
15
|
+
render(() => (
|
|
16
|
+
<SelectionBar
|
|
17
|
+
count={3}
|
|
18
|
+
summary="$14,650"
|
|
19
|
+
primary={{ label: 'Approve expenses', onClick: onAction }}
|
|
20
|
+
onClear={onClear}
|
|
21
|
+
/>
|
|
22
|
+
))
|
|
23
|
+
expect(
|
|
24
|
+
screen.getByRole('region', { name: 'Selection actions' }),
|
|
25
|
+
).toBeTruthy()
|
|
26
|
+
expect(screen.getByText('3 selected')).toBeTruthy()
|
|
27
|
+
expect(screen.getByText('$14,650')).toBeTruthy()
|
|
28
|
+
await userEvent.click(
|
|
29
|
+
screen.getByRole('button', { name: 'Approve expenses' }),
|
|
30
|
+
)
|
|
31
|
+
await userEvent.click(screen.getByRole('button', { name: 'Clear' }))
|
|
32
|
+
expect(onAction).toHaveBeenCalled()
|
|
33
|
+
expect(onClear).toHaveBeenCalled()
|
|
34
|
+
})
|
|
35
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Button, cn, type IconProp } from '@talelk/newui-atoms'
|
|
2
|
+
import { type JSX, Show } from 'solid-js'
|
|
3
|
+
|
|
4
|
+
export interface SelectionPrimaryAction {
|
|
5
|
+
label: string
|
|
6
|
+
onClick: () => void
|
|
7
|
+
icon?: IconProp
|
|
8
|
+
disabled?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SelectionBarProps {
|
|
12
|
+
count: number
|
|
13
|
+
/** Context beside the count, e.g. a total or a scope. */
|
|
14
|
+
summary?: JSX.Element
|
|
15
|
+
primary?: SelectionPrimaryAction
|
|
16
|
+
secondary?: JSX.Element
|
|
17
|
+
onClear?: () => void
|
|
18
|
+
/** `sticky` fixes the bar to the bottom below `sm`. @default 'inline' */
|
|
19
|
+
placement?: 'inline' | 'sticky'
|
|
20
|
+
class?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The one bulk-action surface for a data region.
|
|
25
|
+
*
|
|
26
|
+
* Inline on desktop, fixed above the bottom edge on a phone, so selecting a
|
|
27
|
+
* row far down a card list never requires scrolling back to a toolbar.
|
|
28
|
+
*/
|
|
29
|
+
export function SelectionBar(props: SelectionBarProps): JSX.Element {
|
|
30
|
+
return (
|
|
31
|
+
<Show when={props.count > 0}>
|
|
32
|
+
<section
|
|
33
|
+
aria-label="Selection actions"
|
|
34
|
+
data-slot="selection-bar"
|
|
35
|
+
data-placement={props.placement ?? 'inline'}
|
|
36
|
+
class={cn(
|
|
37
|
+
'flex flex-wrap items-center justify-between gap-2 rounded-card border border-outline-gray-1 bg-surface-elevation-1 p-2 shadow-sm',
|
|
38
|
+
props.placement === 'sticky' &&
|
|
39
|
+
'max-sm:fixed max-sm:inset-x-4 max-sm:bottom-4 max-sm:z-top max-sm:shadow-lg',
|
|
40
|
+
props.class,
|
|
41
|
+
)}
|
|
42
|
+
>
|
|
43
|
+
<div class="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
|
44
|
+
<span class="text-interface font-medium text-ink-gray-9">
|
|
45
|
+
{props.count} selected
|
|
46
|
+
</span>
|
|
47
|
+
<Show when={props.summary}>
|
|
48
|
+
<span class="text-caption tabular-nums text-ink-gray-7">
|
|
49
|
+
{props.summary}
|
|
50
|
+
</span>
|
|
51
|
+
</Show>
|
|
52
|
+
</div>
|
|
53
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
54
|
+
{props.secondary}
|
|
55
|
+
<Show when={props.onClear}>
|
|
56
|
+
<Button
|
|
57
|
+
variant="ghost"
|
|
58
|
+
size="sm"
|
|
59
|
+
class="max-sm:h-10"
|
|
60
|
+
onClick={props.onClear}
|
|
61
|
+
>
|
|
62
|
+
Clear
|
|
63
|
+
</Button>
|
|
64
|
+
</Show>
|
|
65
|
+
<Show when={props.primary}>
|
|
66
|
+
{(primary) => (
|
|
67
|
+
<Button
|
|
68
|
+
variant="solid"
|
|
69
|
+
theme="blue"
|
|
70
|
+
size="sm"
|
|
71
|
+
class="max-sm:h-10"
|
|
72
|
+
iconLeft={primary().icon}
|
|
73
|
+
disabled={primary().disabled}
|
|
74
|
+
onClick={primary().onClick}
|
|
75
|
+
>
|
|
76
|
+
{primary().label}
|
|
77
|
+
</Button>
|
|
78
|
+
)}
|
|
79
|
+
</Show>
|
|
80
|
+
</div>
|
|
81
|
+
</section>
|
|
82
|
+
</Show>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
@@ -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
|
+
})
|