@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,53 @@
|
|
|
1
|
+
import { render } from '@solidjs/testing-library'
|
|
2
|
+
import axe, { type AxeResults } from 'axe-core'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
import { DetailHeader } from './DetailHeader'
|
|
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('DetailHeader accessibility', () => {
|
|
22
|
+
it('has no axe violations for a record header', async () => {
|
|
23
|
+
const { container } = render(() => (
|
|
24
|
+
<DetailHeader
|
|
25
|
+
breadcrumbs={[
|
|
26
|
+
{ label: 'Files', route: '/files' },
|
|
27
|
+
{ label: 'Reports', route: '/files/reports' },
|
|
28
|
+
{ label: 'Q3' },
|
|
29
|
+
]}
|
|
30
|
+
title="Q3 report.pdf"
|
|
31
|
+
subtitle="1.2 MB · updated 2h ago"
|
|
32
|
+
status={{ label: 'Shared', theme: 'blue' }}
|
|
33
|
+
actions={[
|
|
34
|
+
{ label: 'Share', onClick: () => {} },
|
|
35
|
+
{ label: 'Download', onClick: () => {} },
|
|
36
|
+
]}
|
|
37
|
+
/>
|
|
38
|
+
))
|
|
39
|
+
expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('has no axe violations with a lower heading level', async () => {
|
|
43
|
+
const { container } = render(() => (
|
|
44
|
+
<DetailHeader
|
|
45
|
+
title="INV-1042"
|
|
46
|
+
headingLevel={2}
|
|
47
|
+
subtitle="Unpaid · due Oct 30"
|
|
48
|
+
actions={[{ label: 'Edit', onClick: () => {} }]}
|
|
49
|
+
/>
|
|
50
|
+
))
|
|
51
|
+
expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { render } from '@solidjs/testing-library'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { DetailHeader } from './DetailHeader'
|
|
4
|
+
|
|
5
|
+
describe('DetailHeader (browser, real CSS)', () => {
|
|
6
|
+
it('sets the title in the metric scale above the muted subtitle', () => {
|
|
7
|
+
const { container } = render(() => (
|
|
8
|
+
<DetailHeader title="Q3 report.pdf" subtitle="1.2 MB · updated 2h ago" />
|
|
9
|
+
))
|
|
10
|
+
const title = container.querySelector('h1') as HTMLElement
|
|
11
|
+
const subtitle = container.querySelector('p') as HTMLElement
|
|
12
|
+
// `text-metric` is 1.75rem — the largest type on a record page.
|
|
13
|
+
expect(Number.parseFloat(getComputedStyle(title).fontSize)).toBeGreaterThan(
|
|
14
|
+
24,
|
|
15
|
+
)
|
|
16
|
+
expect(Number.parseFloat(getComputedStyle(subtitle).fontSize)).toBeLessThan(
|
|
17
|
+
Number.parseFloat(getComputedStyle(title).fontSize),
|
|
18
|
+
)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('keeps the action buttons real, with the primary one painted', () => {
|
|
22
|
+
const { container } = render(() => (
|
|
23
|
+
<DetailHeader
|
|
24
|
+
title="Report"
|
|
25
|
+
actions={[{ label: 'Share' }, { label: 'Download' }]}
|
|
26
|
+
/>
|
|
27
|
+
))
|
|
28
|
+
const buttons = Array.from(container.querySelectorAll('button'))
|
|
29
|
+
const primary = buttons.at(-1) as HTMLElement
|
|
30
|
+
expect(primary.getBoundingClientRect().height).toBeGreaterThanOrEqual(24)
|
|
31
|
+
// `variant="solid"` must paint — a transparent primary is an invisible CTA.
|
|
32
|
+
expect(getComputedStyle(primary).backgroundColor).not.toBe(
|
|
33
|
+
'rgba(0, 0, 0, 0)',
|
|
34
|
+
)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('draws the status pill as a rounded chip', () => {
|
|
38
|
+
const { container } = render(() => (
|
|
39
|
+
<DetailHeader
|
|
40
|
+
title="Report"
|
|
41
|
+
status={{ label: 'Shared', theme: 'blue' }}
|
|
42
|
+
/>
|
|
43
|
+
))
|
|
44
|
+
const badge = container.querySelector('[data-slot="badge"]') as HTMLElement
|
|
45
|
+
const style = getComputedStyle(badge)
|
|
46
|
+
expect(style.borderRadius).not.toBe('0px')
|
|
47
|
+
expect(style.backgroundColor).not.toBe('rgba(0, 0, 0, 0)')
|
|
48
|
+
expect(badge.getBoundingClientRect().height).toBeGreaterThan(0)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Badge, Button, type ButtonTheme } from '@talelk/newui-atoms'
|
|
2
2
|
import { type BreadcrumbItem, Breadcrumbs } from '@talelk/newui-molecules'
|
|
3
3
|
import { For, type JSX, Show } from 'solid-js'
|
|
4
|
+
import { Dynamic } from 'solid-js/web'
|
|
4
5
|
|
|
5
6
|
export interface DetailAction {
|
|
6
7
|
label: string
|
|
@@ -11,6 +12,8 @@ export interface DetailHeaderProps {
|
|
|
11
12
|
/** The trail above the title. Omitted → no breadcrumbs. */
|
|
12
13
|
breadcrumbs?: BreadcrumbItem[]
|
|
13
14
|
title: JSX.Element
|
|
15
|
+
/** Semantic level of the title. @default 1 */
|
|
16
|
+
headingLevel?: 1 | 2 | 3 | 4
|
|
14
17
|
/** Muted line under the title — a record id, an owner, a timestamp. */
|
|
15
18
|
subtitle?: JSX.Element
|
|
16
19
|
/** Status pill beside the title. */
|
|
@@ -49,7 +52,12 @@ export function DetailHeader(props: DetailHeaderProps): JSX.Element {
|
|
|
49
52
|
<div class="flex flex-wrap items-start justify-between gap-card-gap">
|
|
50
53
|
<div class="flex min-w-0 flex-col gap-1">
|
|
51
54
|
<div class="flex min-w-0 items-center gap-control-gap">
|
|
52
|
-
<
|
|
55
|
+
<Dynamic
|
|
56
|
+
component={`h${props.headingLevel ?? 1}`}
|
|
57
|
+
class="min-w-0 text-metric text-ink-gray-9 [overflow-wrap:anywhere]"
|
|
58
|
+
>
|
|
59
|
+
{props.title}
|
|
60
|
+
</Dynamic>
|
|
53
61
|
<Show when={props.status}>
|
|
54
62
|
{(status) => (
|
|
55
63
|
<Badge
|
|
@@ -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 { FilterBar } from './FilterBar'
|
|
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('FilterBar accessibility', () => {
|
|
22
|
+
it('has no axe violations for a fully loaded bar', async () => {
|
|
23
|
+
const { container } = render(() => (
|
|
24
|
+
<FilterBar
|
|
25
|
+
query={{ placeholder: 'Search expenses', value: 'taxi' }}
|
|
26
|
+
selects={[
|
|
27
|
+
{
|
|
28
|
+
label: 'Owner',
|
|
29
|
+
value: 'me',
|
|
30
|
+
options: [
|
|
31
|
+
{ label: 'Anyone', value: 'any' },
|
|
32
|
+
{ label: 'Me', value: 'me' },
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
]}
|
|
36
|
+
tabs={{
|
|
37
|
+
label: 'View',
|
|
38
|
+
value: 'list',
|
|
39
|
+
options: [
|
|
40
|
+
{ label: 'List', value: 'list' },
|
|
41
|
+
{ label: 'Board', value: 'board' },
|
|
42
|
+
],
|
|
43
|
+
}}
|
|
44
|
+
chips={[{ label: 'Status: Open', onRemove: () => {} }]}
|
|
45
|
+
resultCount={24}
|
|
46
|
+
onClearAll={() => {}}
|
|
47
|
+
/>
|
|
48
|
+
))
|
|
49
|
+
expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('has no axe violations for a bare bar with only a search box', async () => {
|
|
53
|
+
const { container } = render(() => (
|
|
54
|
+
<FilterBar surface="bare" query={{ placeholder: 'Search tickets' }} />
|
|
55
|
+
))
|
|
56
|
+
expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { render } from '@solidjs/testing-library'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { FilterBar } from './FilterBar'
|
|
4
|
+
|
|
5
|
+
describe('FilterBar (browser, real CSS)', () => {
|
|
6
|
+
it('puts the controls on one painted card surface, or none when bare', () => {
|
|
7
|
+
const card = render(() => <FilterBar query={{ placeholder: 'Search' }} />)
|
|
8
|
+
const bar = card.container.querySelector(
|
|
9
|
+
'[data-slot="filter-bar"]',
|
|
10
|
+
) as HTMLElement
|
|
11
|
+
const style = getComputedStyle(bar)
|
|
12
|
+
expect(style.backgroundColor).not.toBe('rgba(0, 0, 0, 0)')
|
|
13
|
+
expect(Number.parseFloat(style.borderTopWidth)).toBeGreaterThan(0)
|
|
14
|
+
expect(Number.parseFloat(style.borderRadius)).toBeGreaterThan(0)
|
|
15
|
+
expect(style.paddingTop).not.toBe('0px')
|
|
16
|
+
card.unmount()
|
|
17
|
+
|
|
18
|
+
const bare = render(() => (
|
|
19
|
+
<FilterBar surface="bare" query={{ placeholder: 'Search' }} />
|
|
20
|
+
))
|
|
21
|
+
const bareBar = bare.container.querySelector(
|
|
22
|
+
'[data-slot="filter-bar"]',
|
|
23
|
+
) as HTMLElement
|
|
24
|
+
// `bare` is composed into a Section, so it must not paint its own card.
|
|
25
|
+
expect(getComputedStyle(bareBar).backgroundColor).toBe('rgba(0, 0, 0, 0)')
|
|
26
|
+
expect(getComputedStyle(bareBar).paddingTop).toBe('0px')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('paints the / hint on the search box', () => {
|
|
30
|
+
const { container } = render(() => (
|
|
31
|
+
<FilterBar query={{ placeholder: 'Search tickets' }} />
|
|
32
|
+
))
|
|
33
|
+
const hint = container.querySelector('kbd') as HTMLElement
|
|
34
|
+
expect(hint.getBoundingClientRect().width).toBeGreaterThan(0)
|
|
35
|
+
expect(getComputedStyle(hint).backgroundColor).not.toBe('rgba(0, 0, 0, 0)')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('states the result count in tabular figures', () => {
|
|
39
|
+
const { container } = render(() => <FilterBar resultCount={24} />)
|
|
40
|
+
const count = container.querySelector(
|
|
41
|
+
'[data-slot="filter-bar-meta"] span',
|
|
42
|
+
) as HTMLElement
|
|
43
|
+
// `tabular-nums` keeps 24 and 8 aligned as filters change the number.
|
|
44
|
+
expect(getComputedStyle(count).fontVariantNumeric).toContain('tabular-nums')
|
|
45
|
+
})
|
|
46
|
+
})
|
|
@@ -135,6 +135,7 @@ describe('FilterBar', () => {
|
|
|
135
135
|
render(() => (
|
|
136
136
|
<FilterBar
|
|
137
137
|
tabs={{
|
|
138
|
+
label: 'View',
|
|
138
139
|
value: 'list',
|
|
139
140
|
onValueChange,
|
|
140
141
|
options: [
|
|
@@ -145,7 +146,7 @@ describe('FilterBar', () => {
|
|
|
145
146
|
/>
|
|
146
147
|
))
|
|
147
148
|
|
|
148
|
-
await userEvent.click(screen.getByRole('
|
|
149
|
+
await userEvent.click(screen.getByRole('radio', { name: 'Board' }))
|
|
149
150
|
expect(onValueChange).toHaveBeenCalledWith('board')
|
|
150
151
|
})
|
|
151
152
|
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Button, Icon, TextInput } from '@talelk/newui-atoms'
|
|
1
|
+
import { Button, cn, Icon, TextInput } from '@talelk/newui-atoms'
|
|
2
2
|
import { Select, type SelectOption, TabButtons } from '@talelk/newui-molecules'
|
|
3
3
|
import { For, type JSX, onCleanup, onMount, Show } from 'solid-js'
|
|
4
4
|
|
|
@@ -35,6 +35,8 @@ export interface FilterBarProps {
|
|
|
35
35
|
selects?: FilterSelect[]
|
|
36
36
|
/** A segmented control on the trailing edge — the view switcher. */
|
|
37
37
|
tabs?: {
|
|
38
|
+
/** Accessible name for the radiogroup. */
|
|
39
|
+
label: string
|
|
38
40
|
options: FilterOption[]
|
|
39
41
|
value?: string
|
|
40
42
|
onValueChange?: (value: string) => void
|
|
@@ -51,6 +53,8 @@ export interface FilterBarProps {
|
|
|
51
53
|
resultCount?: number
|
|
52
54
|
/** Anything else on the trailing edge, after the tabs. */
|
|
53
55
|
children?: JSX.Element
|
|
56
|
+
/** `bare` removes the card surface for a toolbar composed into a Section. */
|
|
57
|
+
surface?: 'card' | 'bare'
|
|
54
58
|
class?: string
|
|
55
59
|
}
|
|
56
60
|
|
|
@@ -106,6 +110,8 @@ export function FilterBar(props: FilterBarProps): JSX.Element {
|
|
|
106
110
|
(props.chips?.length ?? 0) +
|
|
107
111
|
(props.selects ?? []).filter((select) => Boolean(select.value)).length +
|
|
108
112
|
(props.query?.value ? 1 : 0)
|
|
113
|
+
const hasLeading = () =>
|
|
114
|
+
Boolean(props.query) || (props.selects?.length ?? 0) > 0
|
|
109
115
|
const showClearAll = () => props.onClearAll !== undefined && activeCount() > 0
|
|
110
116
|
const showMetaRow = () =>
|
|
111
117
|
(props.chips?.length ?? 0) > 0 ||
|
|
@@ -115,9 +121,16 @@ export function FilterBar(props: FilterBarProps): JSX.Element {
|
|
|
115
121
|
return (
|
|
116
122
|
<div
|
|
117
123
|
data-slot="filter-bar"
|
|
118
|
-
|
|
124
|
+
data-surface={props.surface ?? 'card'}
|
|
125
|
+
class={cn(
|
|
126
|
+
'flex max-w-full flex-col gap-control-gap',
|
|
127
|
+
(props.surface ?? 'card') === 'card'
|
|
128
|
+
? 'rounded-card border border-outline-gray-1 bg-surface-elevation-1 p-control-pad'
|
|
129
|
+
: 'p-0',
|
|
130
|
+
props.class,
|
|
131
|
+
)}
|
|
119
132
|
>
|
|
120
|
-
<div class="grid max-w-full grid-cols-1 items-center gap-control-gap
|
|
133
|
+
<div class="grid max-w-full grid-cols-1 items-center gap-control-gap min-[360px]:grid-cols-2 xl:flex xl:flex-wrap">
|
|
121
134
|
<Show when={props.query}>
|
|
122
135
|
{(query) => (
|
|
123
136
|
<span
|
|
@@ -163,10 +176,16 @@ export function FilterBar(props: FilterBarProps): JSX.Element {
|
|
|
163
176
|
)}
|
|
164
177
|
</For>
|
|
165
178
|
|
|
166
|
-
<div
|
|
179
|
+
<div
|
|
180
|
+
class={cn(
|
|
181
|
+
'col-span-full flex min-w-0 max-w-full flex-wrap items-center gap-control-gap',
|
|
182
|
+
hasLeading() ? 'sm:justify-end xl:ms-auto' : 'sm:justify-start',
|
|
183
|
+
)}
|
|
184
|
+
>
|
|
167
185
|
<Show when={props.tabs}>
|
|
168
186
|
{(tabs) => (
|
|
169
187
|
<TabButtons
|
|
188
|
+
label={tabs().label}
|
|
170
189
|
options={tabs().options}
|
|
171
190
|
value={tabs().value}
|
|
172
191
|
onValueChange={tabs().onValueChange}
|
|
@@ -203,7 +222,7 @@ export function FilterBar(props: FilterBarProps): JSX.Element {
|
|
|
203
222
|
<button
|
|
204
223
|
type="button"
|
|
205
224
|
aria-label={`Remove filter: ${chip.label}`}
|
|
206
|
-
class="grid size-
|
|
225
|
+
class="grid size-6 place-items-center rounded-full text-ink-gray-6 transition-colors hover:bg-surface-gray-2 hover:text-ink-gray-9 focus-visible:focus-ring motion-reduce:transition-none"
|
|
207
226
|
onClick={() => onRemove()()}
|
|
208
227
|
>
|
|
209
228
|
<Icon icon="lucide-x" class="size-3" />
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { render } from '@solidjs/testing-library'
|
|
2
|
+
import axe, { type AxeResults } from 'axe-core'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
import { KanbanBoard, type KanbanColumn } from './KanbanBoard'
|
|
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
|
+
interface Deal {
|
|
22
|
+
id: string
|
|
23
|
+
name: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const deals: Deal[] = [
|
|
27
|
+
{ id: 'D-1', name: 'Globex' },
|
|
28
|
+
{ id: 'D-2', name: 'Stark' },
|
|
29
|
+
{ id: 'D-3', name: 'Wayne' },
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
function stages(): KanbanColumn<Deal>[] {
|
|
33
|
+
return [
|
|
34
|
+
{ id: 'open', name: 'Open', theme: 'gray', items: deals },
|
|
35
|
+
{ id: 'won', name: 'Won', theme: 'green', items: [] },
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const card = (deal: Deal) => <span>{deal.name}</span>
|
|
40
|
+
|
|
41
|
+
describe('KanbanBoard accessibility', () => {
|
|
42
|
+
it('has no axe violations for a board with cards and an empty column', async () => {
|
|
43
|
+
const { container } = render(() => (
|
|
44
|
+
<KanbanBoard
|
|
45
|
+
columns={stages()}
|
|
46
|
+
label="Deals"
|
|
47
|
+
itemLabel={(deal) => deal.name}
|
|
48
|
+
>
|
|
49
|
+
{card}
|
|
50
|
+
</KanbanBoard>
|
|
51
|
+
))
|
|
52
|
+
expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('has no axe violations with a column summary, action and empty copy', async () => {
|
|
56
|
+
const { container } = render(() => (
|
|
57
|
+
<KanbanBoard
|
|
58
|
+
label="Deals"
|
|
59
|
+
columns={[
|
|
60
|
+
{
|
|
61
|
+
id: 'open',
|
|
62
|
+
name: 'Open',
|
|
63
|
+
items: [],
|
|
64
|
+
summary: <span>$210,000</span>,
|
|
65
|
+
action: { label: 'Add deal', onClick: () => {} },
|
|
66
|
+
},
|
|
67
|
+
]}
|
|
68
|
+
emptyColumn={() => <span>Nothing open.</span>}
|
|
69
|
+
>
|
|
70
|
+
{card}
|
|
71
|
+
</KanbanBoard>
|
|
72
|
+
))
|
|
73
|
+
expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
|
|
74
|
+
})
|
|
75
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { render } from '@solidjs/testing-library'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { KanbanBoard, type KanbanColumn } from './KanbanBoard'
|
|
4
|
+
|
|
5
|
+
interface Deal {
|
|
6
|
+
id: string
|
|
7
|
+
name: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const deals: Deal[] = [
|
|
11
|
+
{ id: 'D-1', name: 'Globex' },
|
|
12
|
+
{ id: 'D-2', name: 'Stark' },
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
function stages(): KanbanColumn<Deal>[] {
|
|
16
|
+
return [
|
|
17
|
+
{ id: 'open', name: 'Open', theme: 'gray', items: deals },
|
|
18
|
+
{ id: 'won', name: 'Won', theme: 'green', items: [] },
|
|
19
|
+
]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const card = (deal: Deal) => <span>{deal.name}</span>
|
|
23
|
+
|
|
24
|
+
describe('KanbanBoard (browser, real CSS)', () => {
|
|
25
|
+
it('reserves a real column width on a tinted, bordered surface', () => {
|
|
26
|
+
const { container } = render(() => (
|
|
27
|
+
<KanbanBoard columns={stages()} label="Deals">
|
|
28
|
+
{card}
|
|
29
|
+
</KanbanBoard>
|
|
30
|
+
))
|
|
31
|
+
const column = container.querySelector(
|
|
32
|
+
'[data-kanban-column="open"]',
|
|
33
|
+
) as HTMLElement
|
|
34
|
+
// `w-72` (288px) below `min-[1440px]` — the board is a panner, columns do
|
|
35
|
+
// not shrink to fit.
|
|
36
|
+
const width = column.getBoundingClientRect().width
|
|
37
|
+
expect(width).toBeGreaterThanOrEqual(256)
|
|
38
|
+
expect(width).toBeLessThanOrEqual(300)
|
|
39
|
+
const style = getComputedStyle(column)
|
|
40
|
+
expect(Number.parseFloat(style.borderTopWidth)).toBeGreaterThan(0)
|
|
41
|
+
expect(style.backgroundColor).not.toBe('rgba(0, 0, 0, 0)')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('paints the status dot in the column theme', () => {
|
|
45
|
+
const { container } = render(() => (
|
|
46
|
+
<KanbanBoard columns={stages()} label="Deals">
|
|
47
|
+
{card}
|
|
48
|
+
</KanbanBoard>
|
|
49
|
+
))
|
|
50
|
+
const dot = container.querySelector(
|
|
51
|
+
'[data-kanban-column="open"] span[aria-hidden="true"]',
|
|
52
|
+
) as HTMLElement
|
|
53
|
+
const rect = dot.getBoundingClientRect()
|
|
54
|
+
expect(rect.width).toBeGreaterThan(0)
|
|
55
|
+
expect(getComputedStyle(dot).backgroundColor).not.toBe('rgba(0, 0, 0, 0)')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('gives each card the grab cursor and a real box', () => {
|
|
59
|
+
const { container } = render(() => (
|
|
60
|
+
<KanbanBoard columns={stages()} label="Deals">
|
|
61
|
+
{card}
|
|
62
|
+
</KanbanBoard>
|
|
63
|
+
))
|
|
64
|
+
const cardBox = container.querySelector(
|
|
65
|
+
'[data-kanban-id="D-1"] > [data-slot="card"]',
|
|
66
|
+
) as HTMLElement
|
|
67
|
+
expect(cardBox.getBoundingClientRect().height).toBeGreaterThan(0)
|
|
68
|
+
expect(getComputedStyle(cardBox).cursor).toBe('grab')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('draws the empty column as a dashed drop hint', () => {
|
|
72
|
+
const { container } = render(() => (
|
|
73
|
+
<KanbanBoard columns={stages()} label="Deals">
|
|
74
|
+
{card}
|
|
75
|
+
</KanbanBoard>
|
|
76
|
+
))
|
|
77
|
+
const empty = container.querySelector(
|
|
78
|
+
'[data-kanban-column="won"] ul li',
|
|
79
|
+
) as HTMLElement
|
|
80
|
+
const style = getComputedStyle(empty)
|
|
81
|
+
expect(style.borderTopStyle).toBe('dashed')
|
|
82
|
+
expect(Number.parseFloat(style.borderTopWidth)).toBeGreaterThan(0)
|
|
83
|
+
})
|
|
84
|
+
})
|