@talelk/newui-organisms 1.0.0 → 2.1.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/package.json +8 -4
- package/skills/newui-organisms/SKILL.md +91 -0
- package/src/exports.test.ts +3 -0
- package/src/organisms/AppSidebar/AppSidebar.a11y.test.tsx +75 -0
- package/src/organisms/AppSidebar/AppSidebar.test.tsx +36 -0
- package/src/organisms/AppSidebar/AppSidebar.tsx +57 -12
- package/src/organisms/AuthCard/AuthCard.tsx +41 -32
- package/src/organisms/CommandPalette/CommandPalette.a11y.test.tsx +58 -0
- package/src/organisms/CommandPalette/CommandPalette.tsx +13 -2
- package/src/organisms/DataState/DataState.a11y.test.tsx +59 -0
- package/src/organisms/DataState/DataState.browser.test.tsx +52 -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.a11y.test.tsx +71 -0
- package/src/organisms/DataTable/DataTable.features.test.tsx +191 -0
- package/src/organisms/DataTable/DataTable.test.tsx +73 -1
- package/src/organisms/DataTable/DataTable.tsx +609 -149
- package/src/organisms/DetailHeader/DetailHeader.a11y.test.tsx +53 -0
- package/src/organisms/DetailHeader/DetailHeader.browser.test.tsx +50 -0
- package/src/organisms/DetailHeader/DetailHeader.tsx +9 -1
- package/src/organisms/FilterBar/FilterBar.a11y.test.tsx +58 -0
- package/src/organisms/FilterBar/FilterBar.browser.test.tsx +46 -0
- package/src/organisms/FilterBar/FilterBar.test.tsx +2 -1
- package/src/organisms/FilterBar/FilterBar.tsx +24 -5
- package/src/organisms/KanbanBoard/KanbanBoard.a11y.test.tsx +75 -0
- package/src/organisms/KanbanBoard/KanbanBoard.browser.test.tsx +84 -0
- package/src/organisms/KanbanBoard/KanbanBoard.tsx +220 -165
- package/src/organisms/MobileNav/MobileNav.a11y.test.tsx +49 -0
- package/src/organisms/MobileNav/MobileNav.tsx +1 -1
- package/src/organisms/PageToolbar/PageToolbar.tsx +63 -14
- package/src/organisms/Section/Section.a11y.test.tsx +49 -0
- package/src/organisms/Section/Section.browser.test.tsx +63 -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.a11y.test.tsx +48 -0
- package/src/organisms/SelectionBar/SelectionBar.browser.test.tsx +57 -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.tsx +13 -4
- package/src/organisms/Sidebar/Sidebar.a11y.test.tsx +71 -0
- package/src/organisms/Sidebar/Sidebar.tsx +17 -10
- package/src/organisms/StatGrid/StatGrid.test.tsx +13 -1
- package/src/organisms/StatGrid/StatGrid.tsx +98 -71
- package/src/organisms/VirtualList/VirtualList.a11y.test.tsx +109 -0
- package/src/organisms/VirtualList/VirtualList.tsx +56 -22
- package/src/organisms/index.ts +3 -0
- package/src/organisms/Sidebar/__screenshots__/Sidebar.browser.test.tsx/Sidebar--browser--real-CSS--reserves-its-declared-width-1.png +0 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { render } from '@solidjs/testing-library'
|
|
2
|
+
import axe, { type AxeResults } from 'axe-core'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
import { Section } from './Section'
|
|
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('Section accessibility', () => {
|
|
22
|
+
it('has no axe violations for the full region anatomy', async () => {
|
|
23
|
+
const { container } = render(() => (
|
|
24
|
+
<Section
|
|
25
|
+
id="expenses"
|
|
26
|
+
title="Pending expenses"
|
|
27
|
+
description="Waiting for approval"
|
|
28
|
+
actions={<button type="button">Export</button>}
|
|
29
|
+
toolbar={<div>Filters</div>}
|
|
30
|
+
footer={<span>18 records</span>}
|
|
31
|
+
>
|
|
32
|
+
<ul>
|
|
33
|
+
<li>Taxi to client</li>
|
|
34
|
+
<li>Team lunch</li>
|
|
35
|
+
</ul>
|
|
36
|
+
</Section>
|
|
37
|
+
))
|
|
38
|
+
expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('has no axe violations when named by label alone', async () => {
|
|
42
|
+
const { container } = render(() => (
|
|
43
|
+
<Section label="Recent activity" headingLevel={3} surface="card">
|
|
44
|
+
<p>Nothing today.</p>
|
|
45
|
+
</Section>
|
|
46
|
+
))
|
|
47
|
+
expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
|
|
48
|
+
})
|
|
49
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { render } from '@solidjs/testing-library'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { Section } from './Section'
|
|
4
|
+
|
|
5
|
+
describe('Section (browser, real CSS)', () => {
|
|
6
|
+
it('sits the footer under a real rule', () => {
|
|
7
|
+
const { container } = render(() => (
|
|
8
|
+
<Section title="Ledger" footer={<span>18 records</span>}>
|
|
9
|
+
<div>Body</div>
|
|
10
|
+
</Section>
|
|
11
|
+
))
|
|
12
|
+
const footer = Array.from(container.querySelectorAll('div')).find(
|
|
13
|
+
(node) => node.textContent === '18 records',
|
|
14
|
+
) as HTMLElement
|
|
15
|
+
const style = getComputedStyle(footer)
|
|
16
|
+
// "Pagination, totals or selection feedback, under a rule" — the rule is
|
|
17
|
+
// the contract; losing it strands the footer in the body.
|
|
18
|
+
expect(style.borderTopStyle).toBe('solid')
|
|
19
|
+
expect(Number.parseFloat(style.borderTopWidth)).toBeGreaterThan(0)
|
|
20
|
+
expect(Number.parseFloat(style.paddingTop)).toBeGreaterThan(0)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('paints the card surface and leaves the plain one bare', () => {
|
|
24
|
+
const card = render(() => (
|
|
25
|
+
<Section surface="card" title="Ledger">
|
|
26
|
+
<div>Body</div>
|
|
27
|
+
</Section>
|
|
28
|
+
))
|
|
29
|
+
const cardBox = card.container.querySelector(
|
|
30
|
+
'[data-slot="section"]',
|
|
31
|
+
) as HTMLElement
|
|
32
|
+
const cardStyle = getComputedStyle(cardBox)
|
|
33
|
+
expect(cardStyle.backgroundColor).not.toBe('rgba(0, 0, 0, 0)')
|
|
34
|
+
expect(Number.parseFloat(cardStyle.borderRadius)).toBeGreaterThan(0)
|
|
35
|
+
expect(Number.parseFloat(cardStyle.paddingTop)).toBeGreaterThan(0)
|
|
36
|
+
card.unmount()
|
|
37
|
+
|
|
38
|
+
const plain = render(() => (
|
|
39
|
+
<Section title="Ledger">
|
|
40
|
+
<div>Body</div>
|
|
41
|
+
</Section>
|
|
42
|
+
))
|
|
43
|
+
const plainBox = plain.container.querySelector(
|
|
44
|
+
'[data-slot="section"]',
|
|
45
|
+
) as HTMLElement
|
|
46
|
+
expect(getComputedStyle(plainBox).backgroundColor).toBe('rgba(0, 0, 0, 0)')
|
|
47
|
+
expect(getComputedStyle(plainBox).paddingTop).toBe('0px')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('renders the heading in its type scale', () => {
|
|
51
|
+
const { container } = render(() => (
|
|
52
|
+
<Section title="Pending expenses">
|
|
53
|
+
<div>Body</div>
|
|
54
|
+
</Section>
|
|
55
|
+
))
|
|
56
|
+
const heading = container.querySelector('h2') as HTMLElement
|
|
57
|
+
// `text-section` — a dropped scale utility drops the heading into body text.
|
|
58
|
+
expect(
|
|
59
|
+
Number.parseFloat(getComputedStyle(heading).fontSize),
|
|
60
|
+
).toBeGreaterThan(16)
|
|
61
|
+
expect(heading.getBoundingClientRect().height).toBeGreaterThan(0)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
@@ -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,48 @@
|
|
|
1
|
+
import { render } from '@solidjs/testing-library'
|
|
2
|
+
import axe, { type AxeResults } from 'axe-core'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
import { SelectionBar } from './SelectionBar'
|
|
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('SelectionBar accessibility', () => {
|
|
22
|
+
it('has no axe violations with a primary action and clear', async () => {
|
|
23
|
+
const { container } = render(() => (
|
|
24
|
+
<SelectionBar
|
|
25
|
+
count={3}
|
|
26
|
+
summary="$14,650"
|
|
27
|
+
primary={{ label: 'Approve expenses', onClick: () => {} }}
|
|
28
|
+
onClear={() => {}}
|
|
29
|
+
/>
|
|
30
|
+
))
|
|
31
|
+
expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('has no axe violations with a secondary control and no summary', async () => {
|
|
35
|
+
const { container } = render(() => (
|
|
36
|
+
<SelectionBar
|
|
37
|
+
count={12}
|
|
38
|
+
primary={{
|
|
39
|
+
label: 'Archive',
|
|
40
|
+
onClick: () => {},
|
|
41
|
+
disabled: true,
|
|
42
|
+
}}
|
|
43
|
+
secondary={<button type="button">Merge</button>}
|
|
44
|
+
/>
|
|
45
|
+
))
|
|
46
|
+
expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { render } from '@solidjs/testing-library'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { SelectionBar } from './SelectionBar'
|
|
4
|
+
|
|
5
|
+
describe('SelectionBar (browser, real CSS)', () => {
|
|
6
|
+
it('paints a raised card over the data', () => {
|
|
7
|
+
const { container } = render(() => <SelectionBar count={3} />)
|
|
8
|
+
const bar = container.querySelector(
|
|
9
|
+
'[data-slot="selection-bar"]',
|
|
10
|
+
) as HTMLElement
|
|
11
|
+
const style = getComputedStyle(bar)
|
|
12
|
+
expect(style.backgroundColor).not.toBe('rgba(0, 0, 0, 0)')
|
|
13
|
+
// `shadow-sm` is what lifts it off the table it covers.
|
|
14
|
+
expect(style.boxShadow).not.toBe('none')
|
|
15
|
+
expect(Number.parseFloat(style.borderRadius)).toBeGreaterThan(0)
|
|
16
|
+
expect(bar.getBoundingClientRect().height).toBeGreaterThanOrEqual(24)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('sets the count in the medium interface weight', () => {
|
|
20
|
+
const { container } = render(() => (
|
|
21
|
+
<SelectionBar count={3} summary="$14,650" />
|
|
22
|
+
))
|
|
23
|
+
const count = Array.from(container.querySelectorAll('span')).find(
|
|
24
|
+
(node) => node.textContent === '3 selected',
|
|
25
|
+
) as HTMLElement
|
|
26
|
+
expect(
|
|
27
|
+
Number.parseInt(getComputedStyle(count).fontWeight, 10),
|
|
28
|
+
).toBeGreaterThanOrEqual(500)
|
|
29
|
+
// The summary is a quieter line: it must not match the count's colour.
|
|
30
|
+
const summary = Array.from(container.querySelectorAll('span')).find(
|
|
31
|
+
(node) => node.textContent === '$14,650',
|
|
32
|
+
) as HTMLElement
|
|
33
|
+
expect(getComputedStyle(summary).color).not.toBe(
|
|
34
|
+
getComputedStyle(count).color,
|
|
35
|
+
)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('pins a sticky bar above the bottom edge on the harness phone viewport', () => {
|
|
39
|
+
// This project renders at 414px — below the `sm` breakpoint, which is
|
|
40
|
+
// exactly where `placement="sticky"` becomes `max-sm:fixed`.
|
|
41
|
+
const { container } = render(() => (
|
|
42
|
+
<SelectionBar count={3} placement="sticky" />
|
|
43
|
+
))
|
|
44
|
+
const bar = container.querySelector(
|
|
45
|
+
'[data-slot="selection-bar"]',
|
|
46
|
+
) as HTMLElement
|
|
47
|
+
expect(getComputedStyle(bar).position).toBe('fixed')
|
|
48
|
+
expect(Number.parseFloat(getComputedStyle(bar).bottom)).toBeGreaterThan(0)
|
|
49
|
+
|
|
50
|
+
// The inline default stays in flow at the same width.
|
|
51
|
+
const inline = render(() => <SelectionBar count={3} />)
|
|
52
|
+
const inlineBar = inline.container.querySelector(
|
|
53
|
+
'[data-slot="selection-bar"]',
|
|
54
|
+
) as HTMLElement
|
|
55
|
+
expect(getComputedStyle(inlineBar).position).not.toBe('fixed')
|
|
56
|
+
})
|
|
57
|
+
})
|
|
@@ -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
|
+
}
|
|
@@ -97,7 +97,8 @@ const Root: ParentComponent<SettingsDialogProps> = (props) => {
|
|
|
97
97
|
<Dialog.Content
|
|
98
98
|
class={cn(
|
|
99
99
|
'flex h-[100dvh] min-h-0 w-screen max-w-none flex-col overflow-hidden rounded-none p-0',
|
|
100
|
-
'
|
|
100
|
+
'max-sm:fixed max-sm:inset-0',
|
|
101
|
+
'sm:h-[min(860px,calc(100vh-8rem))] sm:max-h-[calc(100vh_-_8rem)] sm:min-h-[560px] sm:w-[min(56rem,calc(100vw-2rem))] sm:max-w-4xl sm:flex-row sm:rounded-overlay',
|
|
101
102
|
local.class,
|
|
102
103
|
)}
|
|
103
104
|
>
|
|
@@ -108,6 +109,10 @@ const Root: ParentComponent<SettingsDialogProps> = (props) => {
|
|
|
108
109
|
{local.description ??
|
|
109
110
|
'Manage your settings across the panels in this dialog.'}
|
|
110
111
|
</Dialog.Description>
|
|
112
|
+
<Dialog.Close class="absolute end-2 top-2 z-raised grid size-10 place-items-center rounded-control text-ink-gray-6 transition-colors hover:bg-surface-gray-2 hover:text-ink-gray-9 focus-visible:focus-ring sm:size-8">
|
|
113
|
+
<span class="sr-only">Close settings</span>
|
|
114
|
+
<span class="lucide-x size-4" aria-hidden="true" />
|
|
115
|
+
</Dialog.Close>
|
|
111
116
|
{local.children}
|
|
112
117
|
</Dialog.Content>
|
|
113
118
|
</SettingsDialogContext.Provider>
|
|
@@ -116,6 +121,8 @@ const Root: ParentComponent<SettingsDialogProps> = (props) => {
|
|
|
116
121
|
}
|
|
117
122
|
|
|
118
123
|
export interface SettingsSidebarProps {
|
|
124
|
+
/** Accessible name for the vertical tablist. @default 'Settings' */
|
|
125
|
+
label?: string
|
|
119
126
|
/** Merged into the sidebar. */
|
|
120
127
|
class?: string
|
|
121
128
|
children?: JSX.Element
|
|
@@ -128,11 +135,12 @@ export interface SettingsSidebarProps {
|
|
|
128
135
|
const Sidebar: ParentComponent<SettingsSidebarProps> = (props) => (
|
|
129
136
|
<div
|
|
130
137
|
role="tablist"
|
|
138
|
+
aria-label={props.label ?? 'Settings'}
|
|
131
139
|
aria-orientation="vertical"
|
|
132
140
|
data-slot="settings-sidebar"
|
|
133
141
|
class={cn(
|
|
134
142
|
'flex max-h-[38vh] w-full shrink-0 flex-col gap-4 overflow-y-auto border-b border-outline-gray-1 bg-surface-sidebar p-2',
|
|
135
|
-
'sm:max-h-none sm:w-[220px] sm:border-b-0 sm:border-
|
|
143
|
+
'max-sm:pe-12 sm:max-h-none sm:w-[220px] sm:border-b-0 sm:border-e',
|
|
136
144
|
props.class,
|
|
137
145
|
)}
|
|
138
146
|
>
|
|
@@ -241,8 +249,9 @@ const NavItem: ParentComponent<SettingsNavItemProps> = (props) => {
|
|
|
241
249
|
class={cn(
|
|
242
250
|
'flex min-h-8 w-full items-center gap-2 rounded-control px-2 py-1 text-start text-interface text-ink-gray-7 transition-colors motion-reduce:transition-none',
|
|
243
251
|
'focus-visible:focus-ring',
|
|
252
|
+
'min-h-10 sm:min-h-8',
|
|
244
253
|
'data-[state=inactive]:hover:bg-surface-gray-2',
|
|
245
|
-
'data-[state=active]:bg-surface-elevation-3 data-[state=active]:shadow-sm',
|
|
254
|
+
'data-[state=active]:bg-surface-elevation-3 data-[state=active]:text-ink-gray-9 data-[state=active]:shadow-sm',
|
|
246
255
|
'disabled:cursor-not-allowed disabled:text-ink-gray-4',
|
|
247
256
|
local.class,
|
|
248
257
|
)}
|
|
@@ -377,7 +386,7 @@ export interface SettingsHeaderProps {
|
|
|
377
386
|
const Header: ParentComponent<SettingsHeaderProps> = (props) => (
|
|
378
387
|
<div
|
|
379
388
|
data-slot="settings-header"
|
|
380
|
-
class={cn('shrink-0
|
|
389
|
+
class={cn('shrink-0 ps-4 pe-12 pt-6 sm:ps-6', props.class)}
|
|
381
390
|
>
|
|
382
391
|
<Show
|
|
383
392
|
when={props.children}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { render } from '@solidjs/testing-library'
|
|
2
|
+
import axe, { type AxeResults } from 'axe-core'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
import {
|
|
5
|
+
Sidebar,
|
|
6
|
+
SidebarCard,
|
|
7
|
+
SidebarHeader,
|
|
8
|
+
SidebarItem,
|
|
9
|
+
SidebarSearch,
|
|
10
|
+
SidebarSection,
|
|
11
|
+
SidebarTrigger,
|
|
12
|
+
} from './Sidebar'
|
|
13
|
+
|
|
14
|
+
const AXE_OPTIONS = {
|
|
15
|
+
rules: {
|
|
16
|
+
'color-contrast': { enabled: false },
|
|
17
|
+
region: { enabled: false },
|
|
18
|
+
},
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function summarize(results: AxeResults) {
|
|
22
|
+
return results.violations.map((violation) => ({
|
|
23
|
+
id: violation.id,
|
|
24
|
+
impact: violation.impact,
|
|
25
|
+
targets: violation.nodes.map((node) => node.target),
|
|
26
|
+
}))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('Sidebar accessibility', () => {
|
|
30
|
+
it('has no axe violations for an expanded workspace sidebar', async () => {
|
|
31
|
+
const { container } = render(() => (
|
|
32
|
+
<Sidebar ariaLabel="Main">
|
|
33
|
+
<SidebarHeader title="Acme" subtitle="Production" />
|
|
34
|
+
<SidebarTrigger />
|
|
35
|
+
<SidebarSearch placeholder="Filter pages" />
|
|
36
|
+
<SidebarItem route="/inbox" icon="lucide-inbox" active>
|
|
37
|
+
Inbox
|
|
38
|
+
</SidebarItem>
|
|
39
|
+
<SidebarItem route="/drafts" icon="lucide-file-pen">
|
|
40
|
+
Drafts
|
|
41
|
+
</SidebarItem>
|
|
42
|
+
<SidebarSection label="Projects" collapsible>
|
|
43
|
+
<SidebarItem route="/website">Website</SidebarItem>
|
|
44
|
+
<SidebarItem route="/app">App</SidebarItem>
|
|
45
|
+
</SidebarSection>
|
|
46
|
+
<SidebarCard
|
|
47
|
+
title="Trial ends soon"
|
|
48
|
+
description="Upgrade to keep your workspace."
|
|
49
|
+
dismissible
|
|
50
|
+
action={{ label: 'Upgrade' }}
|
|
51
|
+
/>
|
|
52
|
+
</Sidebar>
|
|
53
|
+
))
|
|
54
|
+
expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('has no axe violations for the collapsed rail of named icons', async () => {
|
|
58
|
+
const { container } = render(() => (
|
|
59
|
+
<Sidebar collapsed ariaLabel="Main">
|
|
60
|
+
<SidebarHeader title="Acme" logo="https://example.com/logo.png" />
|
|
61
|
+
<SidebarItem label="Inbox" route="/inbox" icon="lucide-inbox" active>
|
|
62
|
+
Inbox
|
|
63
|
+
</SidebarItem>
|
|
64
|
+
<SidebarItem label="Drafts" route="/drafts" icon="lucide-file-pen">
|
|
65
|
+
Drafts
|
|
66
|
+
</SidebarItem>
|
|
67
|
+
</Sidebar>
|
|
68
|
+
))
|
|
69
|
+
expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
|
|
70
|
+
})
|
|
71
|
+
})
|