@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,232 @@
|
|
|
1
|
+
import { Button, Icon, TextInput } from '@talelk/newui-atoms'
|
|
2
|
+
import { Select, type SelectOption, TabButtons } from '@talelk/newui-molecules'
|
|
3
|
+
import { For, type JSX, onCleanup, onMount, Show } from 'solid-js'
|
|
4
|
+
|
|
5
|
+
export interface FilterOption {
|
|
6
|
+
label: string
|
|
7
|
+
value: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface FilterSelect {
|
|
11
|
+
/** Accessible name; also the placeholder when nothing is chosen. */
|
|
12
|
+
label: string
|
|
13
|
+
options: SelectOption[]
|
|
14
|
+
value?: string
|
|
15
|
+
onValueChange?: (value: string) => void
|
|
16
|
+
width?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface FilterChip {
|
|
20
|
+
/** The active filter as the reader states it, e.g. `Status: Open`. */
|
|
21
|
+
label: string
|
|
22
|
+
/** When set, the chip carries a remove button that calls this. */
|
|
23
|
+
onRemove?: () => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface FilterBarProps {
|
|
27
|
+
/** Rendered as a search input. Omit for a bar with no query box. */
|
|
28
|
+
query?: {
|
|
29
|
+
value?: string
|
|
30
|
+
placeholder?: string
|
|
31
|
+
onInput?: (value: string) => void
|
|
32
|
+
width?: string
|
|
33
|
+
}
|
|
34
|
+
/** One or more selects, in order. */
|
|
35
|
+
selects?: FilterSelect[]
|
|
36
|
+
/** A segmented control on the trailing edge — the view switcher. */
|
|
37
|
+
tabs?: {
|
|
38
|
+
options: FilterOption[]
|
|
39
|
+
value?: string
|
|
40
|
+
onValueChange?: (value: string) => void
|
|
41
|
+
}
|
|
42
|
+
/** Active filters restated as removable pills under the bar. */
|
|
43
|
+
chips?: FilterChip[]
|
|
44
|
+
/**
|
|
45
|
+
* Renders a "Clear all" button while a filter is active (a chip, a filled
|
|
46
|
+
* select, or a non-empty query). The bar never clears anything itself —
|
|
47
|
+
* it reports the request and the caller resets its own state.
|
|
48
|
+
*/
|
|
49
|
+
onClearAll?: () => void
|
|
50
|
+
/** Rows the current filters match, stated under the bar (`24 results`). */
|
|
51
|
+
resultCount?: number
|
|
52
|
+
/** Anything else on the trailing edge, after the tabs. */
|
|
53
|
+
children?: JSX.Element
|
|
54
|
+
class?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** True when a keystroke is landing in something the user is already editing. */
|
|
58
|
+
function isEditable(target: EventTarget | null): boolean {
|
|
59
|
+
if (!(target instanceof HTMLElement)) return false
|
|
60
|
+
return (
|
|
61
|
+
target.isContentEditable ||
|
|
62
|
+
target instanceof HTMLInputElement ||
|
|
63
|
+
target instanceof HTMLTextAreaElement ||
|
|
64
|
+
target instanceof HTMLSelectElement
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The row of controls above a list: search, one or two selects, and the view
|
|
70
|
+
* switcher, on one quiet surface so the controls read as a single toolbar
|
|
71
|
+
* rather than loose fields above the data.
|
|
72
|
+
*
|
|
73
|
+
* Worth a block because the shape is a decision — search leads and is the widest
|
|
74
|
+
* target, selects are fixed-width so their labels do not jump as values change,
|
|
75
|
+
* and the view switcher is pushed to the far end because it changes how the data
|
|
76
|
+
* below is *drawn* rather than what is in it. A filled select takes the filled
|
|
77
|
+
* (`subtle`) surface, so what is filtering the list is visible without opening
|
|
78
|
+
* anything; the active set can also be restated as removable chips with a
|
|
79
|
+
* result count and a clear-all, which is the point where a row of controls
|
|
80
|
+
* becomes an answer to "why am I seeing these rows".
|
|
81
|
+
*
|
|
82
|
+
* The filter bar gives each Select an `aria-label` from its `label` and uses
|
|
83
|
+
* it as the placeholder, rather than rendering a form label above a toolbar.
|
|
84
|
+
* The search box carries a `/` hint and honours it: pressing `/` anywhere
|
|
85
|
+
* outside another editing surface focuses the box.
|
|
86
|
+
*/
|
|
87
|
+
export function FilterBar(props: FilterBarProps): JSX.Element {
|
|
88
|
+
let searchBox: HTMLSpanElement | undefined
|
|
89
|
+
|
|
90
|
+
onMount(() => {
|
|
91
|
+
if (!props.query) return
|
|
92
|
+
const onKey = (event: KeyboardEvent) => {
|
|
93
|
+
if (event.key !== '/' || event.metaKey || event.ctrlKey || event.altKey)
|
|
94
|
+
return
|
|
95
|
+
if (isEditable(event.target)) return
|
|
96
|
+
const input = searchBox?.querySelector('input')
|
|
97
|
+
if (!input) return
|
|
98
|
+
event.preventDefault()
|
|
99
|
+
input.focus()
|
|
100
|
+
}
|
|
101
|
+
window.addEventListener('keydown', onKey)
|
|
102
|
+
onCleanup(() => window.removeEventListener('keydown', onKey))
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
const activeCount = () =>
|
|
106
|
+
(props.chips?.length ?? 0) +
|
|
107
|
+
(props.selects ?? []).filter((select) => Boolean(select.value)).length +
|
|
108
|
+
(props.query?.value ? 1 : 0)
|
|
109
|
+
const showClearAll = () => props.onClearAll !== undefined && activeCount() > 0
|
|
110
|
+
const showMetaRow = () =>
|
|
111
|
+
(props.chips?.length ?? 0) > 0 ||
|
|
112
|
+
props.resultCount !== undefined ||
|
|
113
|
+
showClearAll()
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div
|
|
117
|
+
data-slot="filter-bar"
|
|
118
|
+
class={`flex max-w-full flex-col gap-control-gap rounded-card border border-outline-gray-1 bg-surface-elevation-1 p-control-pad ${props.class ?? ''}`}
|
|
119
|
+
>
|
|
120
|
+
<div class="grid max-w-full grid-cols-1 items-center gap-control-gap sm:grid-cols-2 xl:flex xl:flex-wrap">
|
|
121
|
+
<Show when={props.query}>
|
|
122
|
+
{(query) => (
|
|
123
|
+
<span
|
|
124
|
+
ref={searchBox}
|
|
125
|
+
class="col-span-full min-w-0 max-w-full xl:flex-1"
|
|
126
|
+
>
|
|
127
|
+
<TextInput
|
|
128
|
+
size="sm"
|
|
129
|
+
type="search"
|
|
130
|
+
value={query().value}
|
|
131
|
+
placeholder={query().placeholder ?? 'Search…'}
|
|
132
|
+
aria-label={query().placeholder ?? 'Search'}
|
|
133
|
+
class={`w-full ${query().width ?? ''}`}
|
|
134
|
+
prefix={<Icon icon="lucide-search" class="size-4" />}
|
|
135
|
+
suffix={
|
|
136
|
+
<kbd class="rounded-2 border border-outline-gray-2 bg-surface-gray-1 px-1 text-caption text-ink-gray-7">
|
|
137
|
+
/
|
|
138
|
+
</kbd>
|
|
139
|
+
}
|
|
140
|
+
onInput={(event) =>
|
|
141
|
+
query().onInput?.(event.currentTarget.value)
|
|
142
|
+
}
|
|
143
|
+
/>
|
|
144
|
+
</span>
|
|
145
|
+
)}
|
|
146
|
+
</Show>
|
|
147
|
+
|
|
148
|
+
<For each={props.selects ?? []}>
|
|
149
|
+
{(select) => (
|
|
150
|
+
<div
|
|
151
|
+
class={`min-w-0 max-w-full sm:w-full xl:w-40 ${select.width ?? ''}`}
|
|
152
|
+
>
|
|
153
|
+
<Select
|
|
154
|
+
size="sm"
|
|
155
|
+
variant={select.value ? 'subtle' : 'outline'}
|
|
156
|
+
options={select.options}
|
|
157
|
+
value={select.value || null}
|
|
158
|
+
placeholder={select.label}
|
|
159
|
+
aria-label={select.label}
|
|
160
|
+
onValueChange={(value) => select.onValueChange?.(value ?? '')}
|
|
161
|
+
/>
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
</For>
|
|
165
|
+
|
|
166
|
+
<div class="col-span-full flex min-w-0 max-w-full flex-wrap items-center gap-control-gap sm:justify-end xl:ms-auto">
|
|
167
|
+
<Show when={props.tabs}>
|
|
168
|
+
{(tabs) => (
|
|
169
|
+
<TabButtons
|
|
170
|
+
options={tabs().options}
|
|
171
|
+
value={tabs().value}
|
|
172
|
+
onValueChange={tabs().onValueChange}
|
|
173
|
+
/>
|
|
174
|
+
)}
|
|
175
|
+
</Show>
|
|
176
|
+
{props.children}
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
<Show when={showMetaRow()}>
|
|
181
|
+
<div
|
|
182
|
+
data-slot="filter-bar-meta"
|
|
183
|
+
class="flex max-w-full flex-wrap items-center gap-1.5 px-control-pad"
|
|
184
|
+
>
|
|
185
|
+
<Show when={props.resultCount !== undefined}>
|
|
186
|
+
<span
|
|
187
|
+
aria-live="polite"
|
|
188
|
+
class="me-1 text-caption tabular-nums text-ink-gray-7"
|
|
189
|
+
>
|
|
190
|
+
{props.resultCount}{' '}
|
|
191
|
+
{props.resultCount === 1 ? 'result' : 'results'}
|
|
192
|
+
</span>
|
|
193
|
+
</Show>
|
|
194
|
+
<For each={props.chips ?? []}>
|
|
195
|
+
{(chip) => (
|
|
196
|
+
<span
|
|
197
|
+
data-slot="filter-chip"
|
|
198
|
+
class={`inline-flex items-center gap-1 rounded-full border border-outline-gray-2 bg-surface-base py-0.5 ps-2 text-caption text-ink-gray-8 ${chip.onRemove ? 'pe-1' : 'pe-2'}`}
|
|
199
|
+
>
|
|
200
|
+
{chip.label}
|
|
201
|
+
<Show when={chip.onRemove}>
|
|
202
|
+
{(onRemove) => (
|
|
203
|
+
<button
|
|
204
|
+
type="button"
|
|
205
|
+
aria-label={`Remove filter: ${chip.label}`}
|
|
206
|
+
class="grid size-4 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
|
+
onClick={() => onRemove()()}
|
|
208
|
+
>
|
|
209
|
+
<Icon icon="lucide-x" class="size-3" />
|
|
210
|
+
</button>
|
|
211
|
+
)}
|
|
212
|
+
</Show>
|
|
213
|
+
</span>
|
|
214
|
+
)}
|
|
215
|
+
</For>
|
|
216
|
+
<Show when={showClearAll()}>
|
|
217
|
+
<span class="ms-auto">
|
|
218
|
+
<Button
|
|
219
|
+
variant="ghost"
|
|
220
|
+
theme="gray"
|
|
221
|
+
size="sm"
|
|
222
|
+
onClick={() => props.onClearAll?.()}
|
|
223
|
+
>
|
|
224
|
+
Clear all
|
|
225
|
+
</Button>
|
|
226
|
+
</span>
|
|
227
|
+
</Show>
|
|
228
|
+
</div>
|
|
229
|
+
</Show>
|
|
230
|
+
</div>
|
|
231
|
+
)
|
|
232
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './FilterBar'
|
|
@@ -0,0 +1,255 @@
|
|
|
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 {
|
|
5
|
+
KanbanBoard,
|
|
6
|
+
type KanbanColumn,
|
|
7
|
+
type KanbanMove,
|
|
8
|
+
placeKanbanItem,
|
|
9
|
+
} from './KanbanBoard'
|
|
10
|
+
|
|
11
|
+
interface Deal {
|
|
12
|
+
id: string
|
|
13
|
+
name: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const deals: Deal[] = [
|
|
17
|
+
{ id: 'D-1', name: 'Globex' },
|
|
18
|
+
{ id: 'D-2', name: 'Stark' },
|
|
19
|
+
{ id: 'D-3', name: 'Wayne' },
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
function stages(
|
|
23
|
+
overrides: Partial<Record<string, Deal[]>> = {},
|
|
24
|
+
): KanbanColumn<Deal>[] {
|
|
25
|
+
return [
|
|
26
|
+
{
|
|
27
|
+
id: 'open',
|
|
28
|
+
name: 'Open',
|
|
29
|
+
theme: 'gray',
|
|
30
|
+
items: overrides['open'] ?? deals,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: 'won',
|
|
34
|
+
name: 'Won',
|
|
35
|
+
theme: 'green',
|
|
36
|
+
items: overrides['won'] ?? [],
|
|
37
|
+
},
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const card = (deal: Deal) => <span>{deal.name}</span>
|
|
42
|
+
|
|
43
|
+
const idsIn = (columnId: string) =>
|
|
44
|
+
[
|
|
45
|
+
...document.querySelectorAll(
|
|
46
|
+
`[data-kanban-column="${columnId}"] [data-kanban-id]`,
|
|
47
|
+
),
|
|
48
|
+
].map((node) => node.getAttribute('data-kanban-id'))
|
|
49
|
+
|
|
50
|
+
/** Open a card's menu and choose a column. */
|
|
51
|
+
async function moveViaMenu(name: string, column: string) {
|
|
52
|
+
await userEvent.click(
|
|
53
|
+
screen.getByRole('button', { name: `Actions for ${name}` }),
|
|
54
|
+
)
|
|
55
|
+
await userEvent.click(await screen.findByRole('menuitem', { name: column }))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('placeKanbanItem', () => {
|
|
59
|
+
const columns = stages()
|
|
60
|
+
|
|
61
|
+
it('moves an item to the end of another column by index', () => {
|
|
62
|
+
const next = placeKanbanItem(columns, 'D-1', { column: 'won', index: 0 })
|
|
63
|
+
expect(next[0]?.items.map((item) => item.id)).toEqual(['D-2', 'D-3'])
|
|
64
|
+
expect(next[1]?.items.map((item) => item.id)).toEqual(['D-1'])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('counts the index over the column *without* the item being moved', () => {
|
|
68
|
+
// Dragging D-1 down one place: the index is measured after D-1 is taken out,
|
|
69
|
+
// so 1 lands it between D-2 and D-3 rather than back at the top.
|
|
70
|
+
const next = placeKanbanItem(columns, 'D-1', { column: 'open', index: 1 })
|
|
71
|
+
expect(next[0]?.items.map((item) => item.id)).toEqual(['D-2', 'D-1', 'D-3'])
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('clamps an index past the end of the column', () => {
|
|
75
|
+
const next = placeKanbanItem(columns, 'D-1', { column: 'won', index: 9 })
|
|
76
|
+
expect(next[1]?.items.map((item) => item.id)).toEqual(['D-1'])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('returns what came in when the move changes nothing', () => {
|
|
80
|
+
expect(placeKanbanItem(columns, 'D-1', { column: 'open', index: 0 })).toBe(
|
|
81
|
+
columns,
|
|
82
|
+
)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('keeps the columns it did not touch, by reference', () => {
|
|
86
|
+
const next = placeKanbanItem(columns, 'D-1', { column: 'won', index: 0 })
|
|
87
|
+
// The untouched column is the same object: keying rows on it would throw
|
|
88
|
+
// that column's DOM away, along with its scroll position.
|
|
89
|
+
expect(next[0]).not.toBe(columns[0])
|
|
90
|
+
expect(next[1]).not.toBe(columns[1])
|
|
91
|
+
expect(next[0]?.name).toBe('Open')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('ignores an item the board does not have', () => {
|
|
95
|
+
expect(placeKanbanItem(columns, 'D-9', { column: 'won', index: 0 })).toBe(
|
|
96
|
+
columns,
|
|
97
|
+
)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('KanbanBoard', () => {
|
|
102
|
+
it('renders the columns, their counts and the cards', () => {
|
|
103
|
+
render(() => (
|
|
104
|
+
<KanbanBoard columns={stages()} label="Deals">
|
|
105
|
+
{card}
|
|
106
|
+
</KanbanBoard>
|
|
107
|
+
))
|
|
108
|
+
|
|
109
|
+
expect(screen.getByRole('list', { name: 'Deals' })).toBeTruthy()
|
|
110
|
+
expect(screen.getByRole('heading', { name: 'Open', level: 2 })).toBeTruthy()
|
|
111
|
+
expect(screen.getByText('Globex')).toBeTruthy()
|
|
112
|
+
// The count is the column's, not the card's.
|
|
113
|
+
expect(screen.getByText('3')).toBeTruthy()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('renders a column action, a summary and its empty state', async () => {
|
|
117
|
+
const onAdd = vi.fn()
|
|
118
|
+
render(() => (
|
|
119
|
+
<KanbanBoard
|
|
120
|
+
columns={[
|
|
121
|
+
{
|
|
122
|
+
id: 'open',
|
|
123
|
+
name: 'Open',
|
|
124
|
+
items: [],
|
|
125
|
+
summary: <span>$210,000</span>,
|
|
126
|
+
action: { label: 'Add deal', onClick: onAdd },
|
|
127
|
+
},
|
|
128
|
+
]}
|
|
129
|
+
emptyColumn={() => <span>Nothing open.</span>}
|
|
130
|
+
>
|
|
131
|
+
{card}
|
|
132
|
+
</KanbanBoard>
|
|
133
|
+
))
|
|
134
|
+
|
|
135
|
+
expect(screen.getByText('$210,000')).toBeTruthy()
|
|
136
|
+
expect(screen.getByText('Nothing open.')).toBeTruthy()
|
|
137
|
+
await userEvent.click(screen.getByRole('button', { name: 'Add deal' }))
|
|
138
|
+
expect(onAdd).toHaveBeenCalled()
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('moves a card with the menu, and reports a single move', async () => {
|
|
142
|
+
const moves: KanbanMove<Deal>[] = []
|
|
143
|
+
render(() => (
|
|
144
|
+
<KanbanBoard
|
|
145
|
+
defaultColumns={stages()}
|
|
146
|
+
onMove={(move) => moves.push(move)}
|
|
147
|
+
itemLabel={(deal) => deal.name}
|
|
148
|
+
>
|
|
149
|
+
{card}
|
|
150
|
+
</KanbanBoard>
|
|
151
|
+
))
|
|
152
|
+
|
|
153
|
+
await moveViaMenu('Globex', 'Won')
|
|
154
|
+
|
|
155
|
+
expect(moves).toHaveLength(1)
|
|
156
|
+
expect(moves[0]?.itemId).toBe('D-1')
|
|
157
|
+
expect(moves[0]?.from).toEqual({ column: 'open', index: 0 })
|
|
158
|
+
expect(moves[0]?.to).toEqual({ column: 'won', index: 0 })
|
|
159
|
+
expect(moves[0]?.columns[1]?.items.map((item) => item.id)).toEqual(['D-1'])
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('only offers the columns the card is not already in', async () => {
|
|
163
|
+
render(() => (
|
|
164
|
+
<KanbanBoard defaultColumns={stages()} itemLabel={(deal) => deal.name}>
|
|
165
|
+
{card}
|
|
166
|
+
</KanbanBoard>
|
|
167
|
+
))
|
|
168
|
+
|
|
169
|
+
await userEvent.click(
|
|
170
|
+
screen.getByRole('button', { name: 'Actions for Globex' }),
|
|
171
|
+
)
|
|
172
|
+
const entries = await screen.findAllByRole('menuitem')
|
|
173
|
+
expect(entries.map((entry) => entry.textContent)).toEqual(['Won'])
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('owns the order when uncontrolled, and leaves it alone when controlled', async () => {
|
|
177
|
+
const uncontrolled = render(() => (
|
|
178
|
+
<KanbanBoard defaultColumns={stages()} itemLabel={(deal) => deal.name}>
|
|
179
|
+
{card}
|
|
180
|
+
</KanbanBoard>
|
|
181
|
+
))
|
|
182
|
+
expect(idsIn('won')).toEqual([])
|
|
183
|
+
await moveViaMenu('Globex', 'Won')
|
|
184
|
+
expect(idsIn('won')).toEqual(['D-1'])
|
|
185
|
+
expect(idsIn('open')).toEqual(['D-2', 'D-3'])
|
|
186
|
+
uncontrolled.unmount()
|
|
187
|
+
|
|
188
|
+
const columns = stages()
|
|
189
|
+
render(() => (
|
|
190
|
+
<KanbanBoard columns={columns} itemLabel={(deal) => deal.name}>
|
|
191
|
+
{card}
|
|
192
|
+
</KanbanBoard>
|
|
193
|
+
))
|
|
194
|
+
await moveViaMenu('Globex', 'Won')
|
|
195
|
+
// The board reported the move but did not take it: the owner decides.
|
|
196
|
+
expect(idsIn('won')).toEqual([])
|
|
197
|
+
expect(idsIn('open')).toEqual(['D-1', 'D-2', 'D-3'])
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('lets a card bring its own actions instead', async () => {
|
|
201
|
+
const onArchive = vi.fn()
|
|
202
|
+
render(() => (
|
|
203
|
+
<KanbanBoard
|
|
204
|
+
defaultColumns={stages()}
|
|
205
|
+
itemLabel={(deal) => deal.name}
|
|
206
|
+
renderActions={() => (
|
|
207
|
+
<button type="button" onClick={onArchive}>
|
|
208
|
+
Archive
|
|
209
|
+
</button>
|
|
210
|
+
)}
|
|
211
|
+
>
|
|
212
|
+
{card}
|
|
213
|
+
</KanbanBoard>
|
|
214
|
+
))
|
|
215
|
+
|
|
216
|
+
await userEvent.click(
|
|
217
|
+
screen.getAllByRole('button', { name: 'Archive' })[0] as HTMLElement,
|
|
218
|
+
)
|
|
219
|
+
expect(onArchive).toHaveBeenCalled()
|
|
220
|
+
expect(screen.queryByRole('button', { name: /Actions for/ })).toBeNull()
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('snaps the columns only where the board is a phone-width panner', () => {
|
|
224
|
+
render(() => (
|
|
225
|
+
<KanbanBoard defaultColumns={stages()} itemLabel={(deal) => deal.name}>
|
|
226
|
+
{card}
|
|
227
|
+
</KanbanBoard>
|
|
228
|
+
))
|
|
229
|
+
|
|
230
|
+
const row = screen.getByRole('list', { name: 'Board' })
|
|
231
|
+
const classes = row.className.split(/\s+/)
|
|
232
|
+
// Every snap utility is behind `max-lg:`. An unprefixed one would snap the
|
|
233
|
+
// board on a wide screen too, under the pointer, mid-drop.
|
|
234
|
+
expect(classes.filter((name) => name.includes('snap'))).toEqual([
|
|
235
|
+
'max-lg:snap-x',
|
|
236
|
+
'max-lg:snap-mandatory',
|
|
237
|
+
])
|
|
238
|
+
const column = document.querySelector('[data-kanban-column="open"]')
|
|
239
|
+
expect(column?.className).toContain('max-lg:snap-start')
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('names the board and each card for a screen reader', () => {
|
|
243
|
+
render(() => (
|
|
244
|
+
<KanbanBoard defaultColumns={stages()} itemLabel={(deal) => deal.name}>
|
|
245
|
+
{card}
|
|
246
|
+
</KanbanBoard>
|
|
247
|
+
))
|
|
248
|
+
|
|
249
|
+
expect(screen.getByRole('list', { name: 'Board' })).toBeTruthy()
|
|
250
|
+
expect(
|
|
251
|
+
screen.getByRole('button', { name: 'Actions for Globex' }),
|
|
252
|
+
).toBeTruthy()
|
|
253
|
+
expect(screen.getByRole('heading', { name: 'Won' })).toBeTruthy()
|
|
254
|
+
})
|
|
255
|
+
})
|