@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,457 @@
|
|
|
1
|
+
import { Icon, type IconProp } from '@talelk/newui-atoms'
|
|
2
|
+
import { cn } from '@talelk/newui-atoms/internal'
|
|
3
|
+
import { Dialog } from '@talelk/newui-molecules'
|
|
4
|
+
import {
|
|
5
|
+
type Accessor,
|
|
6
|
+
createContext,
|
|
7
|
+
createEffect,
|
|
8
|
+
createMemo,
|
|
9
|
+
createSignal,
|
|
10
|
+
type JSX,
|
|
11
|
+
onCleanup,
|
|
12
|
+
onMount,
|
|
13
|
+
Show,
|
|
14
|
+
useContext,
|
|
15
|
+
} from 'solid-js'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A command palette: ⌘K, a query, and a list of things you can do.
|
|
19
|
+
*
|
|
20
|
+
* The state is Zag's `dialog` machine (focus trap, Escape, backdrop, scroll
|
|
21
|
+
* lock) plus a filtered list. The list is *not* a Zag listbox, and that is a
|
|
22
|
+
* decision rather than a shortcut: a listbox collection is built once from a
|
|
23
|
+
* static set of items, while a palette is rebuilt on every keystroke — items
|
|
24
|
+
* come and go as the query narrows, and the highlight has to survive that. So
|
|
25
|
+
* the dialog is the machine's and the highlight is ours, expressed with
|
|
26
|
+
* `aria-activedescendant`, which is what keeps focus in the input while the
|
|
27
|
+
* arrows move down the list.
|
|
28
|
+
*
|
|
29
|
+
* Items self-register. That is what lets a consumer write groups and items as
|
|
30
|
+
* plain JSX in any nesting, with the keyboard walking one flat, filtered list
|
|
31
|
+
* across all of them.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Everything about an item is read through a function, not captured: an item is
|
|
36
|
+
* registered once and lives until it unmounts, so a plain field would freeze the
|
|
37
|
+
* first value a prop ever had.
|
|
38
|
+
*/
|
|
39
|
+
interface CommandRecord {
|
|
40
|
+
value: Accessor<string>
|
|
41
|
+
/** Extra search text: words that should match without being shown. */
|
|
42
|
+
keywords: Accessor<string>
|
|
43
|
+
label: Accessor<string>
|
|
44
|
+
disabled: Accessor<boolean>
|
|
45
|
+
matches: Accessor<boolean>
|
|
46
|
+
select: () => void
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface CommandContextValue {
|
|
50
|
+
query: Accessor<string>
|
|
51
|
+
setQuery: (value: string) => void
|
|
52
|
+
register: (record: CommandRecord) => void
|
|
53
|
+
unregister: (record: CommandRecord) => void
|
|
54
|
+
visible: Accessor<CommandRecord[]>
|
|
55
|
+
highlighted: Accessor<string | undefined>
|
|
56
|
+
highlight: (value: string | undefined) => void
|
|
57
|
+
move: (delta: number) => void
|
|
58
|
+
accept: () => void
|
|
59
|
+
itemId: (value: string) => string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const CommandContext = createContext<CommandContextValue>()
|
|
63
|
+
|
|
64
|
+
function useCommand(): CommandContextValue {
|
|
65
|
+
const context = useContext(CommandContext)
|
|
66
|
+
if (!context)
|
|
67
|
+
throw new Error('CommandPalette parts must be used within <CommandPalette>')
|
|
68
|
+
return context
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The palette is a WAI-ARIA combobox: focus stays in the field, and the option
|
|
73
|
+
* the keyboard is on is named by `aria-activedescendant`. Three lint rules assume
|
|
74
|
+
* the opposite — that focus moves onto the option — so they are off for this file
|
|
75
|
+
* rather than worked around:
|
|
76
|
+
*
|
|
77
|
+
* - `useFocusableInteractive` / `useKeyWithClickEvents`: a focusable option would
|
|
78
|
+
* pull focus out of the field the moment you arrow down, which is exactly what
|
|
79
|
+
* the pattern avoids. The field owns the keys; the click handler is for
|
|
80
|
+
* pointers.
|
|
81
|
+
* - `useSemanticElements`: the options are rich rows (icon, description,
|
|
82
|
+
* shortcut) and the collection is rebuilt on every keystroke, which no native
|
|
83
|
+
* select or datalist expresses.
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Both sides are reduced to letters and digits before comparing, so the way
|
|
88
|
+
* people actually type a compound name finds it: `date picker`, `date-picker`
|
|
89
|
+
* and `DatePicker` are one query, and no one has to know where the case
|
|
90
|
+
* boundary is.
|
|
91
|
+
*/
|
|
92
|
+
function fold(text: string): string {
|
|
93
|
+
return text.toLowerCase().replace(/[^a-z0-9]+/g, '')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Rank: a label that starts with the query beats one that merely contains it. */
|
|
97
|
+
function score(label: string, keywords: string, query: string): number | null {
|
|
98
|
+
const needle = fold(query)
|
|
99
|
+
if (needle === '') return 0
|
|
100
|
+
const index = fold(`${label} ${keywords}`).indexOf(needle)
|
|
101
|
+
if (index === -1) return null
|
|
102
|
+
return (fold(label).startsWith(needle) ? 0 : 100) + index
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let commandId = 0
|
|
106
|
+
|
|
107
|
+
export interface CommandPaletteProps {
|
|
108
|
+
open?: boolean
|
|
109
|
+
defaultOpen?: boolean
|
|
110
|
+
onOpenChange?: (open: boolean) => void
|
|
111
|
+
/**
|
|
112
|
+
* Bind ⌘K / Ctrl+K. Default `true`; the listener only opens, never closes —
|
|
113
|
+
* pressing it inside an open palette should feel like nothing happened rather
|
|
114
|
+
* than like a toggle you cannot see.
|
|
115
|
+
*/
|
|
116
|
+
shortcut?: boolean
|
|
117
|
+
placeholder?: string
|
|
118
|
+
/** Accessible name of both the dialog and the list. Default `Commands`. */
|
|
119
|
+
label?: string
|
|
120
|
+
/** Max width class. Default `md`. */
|
|
121
|
+
size?: 'sm' | 'md' | 'lg' | 'xl'
|
|
122
|
+
/** Rendered when nothing matches. */
|
|
123
|
+
empty?: JSX.Element
|
|
124
|
+
class?: string
|
|
125
|
+
children: JSX.Element
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function CommandPalette(props: CommandPaletteProps): JSX.Element {
|
|
129
|
+
commandId += 1
|
|
130
|
+
const id = `command-${commandId}`
|
|
131
|
+
const [uncontrolledOpen, setUncontrolledOpen] = createSignal(
|
|
132
|
+
props.defaultOpen ?? false,
|
|
133
|
+
)
|
|
134
|
+
const isControlled = () => props.open !== undefined
|
|
135
|
+
const open = () => props.open ?? uncontrolledOpen()
|
|
136
|
+
const setOpen = (next: boolean) => {
|
|
137
|
+
if (!isControlled()) setUncontrolledOpen(next)
|
|
138
|
+
props.onOpenChange?.(next)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const [query, setQuery] = createSignal('')
|
|
142
|
+
const [highlighted, setHighlighted] = createSignal<string>()
|
|
143
|
+
/**
|
|
144
|
+
* The registry is a signal, not a plain array with a counter beside it.
|
|
145
|
+
*
|
|
146
|
+
* That is not tidiness: `isEmpty` reads the registry *first*, and with a plain
|
|
147
|
+
* array the first render short-circuits on `length === 0` before touching
|
|
148
|
+
* anything reactive — so the empty state's condition records no dependency at
|
|
149
|
+
* all and never re-runs. It renders nothing, forever, while every other read
|
|
150
|
+
* of the same state updates.
|
|
151
|
+
*/
|
|
152
|
+
const [registry, setRegistry] = createSignal<CommandRecord[]>([])
|
|
153
|
+
|
|
154
|
+
const visible = createMemo(() => {
|
|
155
|
+
const current = query()
|
|
156
|
+
return registry()
|
|
157
|
+
.filter((record) => record.matches())
|
|
158
|
+
.map((record) => ({
|
|
159
|
+
record,
|
|
160
|
+
rank: score(record.label(), record.keywords(), current) ?? 0,
|
|
161
|
+
}))
|
|
162
|
+
.sort((a, b) => a.rank - b.rank)
|
|
163
|
+
.map((entry) => entry.record)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
/** Registered but none matching: not the same as "nothing has mounted yet". */
|
|
167
|
+
const isEmpty = () => registry().length > 0 && visible().length === 0
|
|
168
|
+
|
|
169
|
+
const register = (record: CommandRecord) =>
|
|
170
|
+
setRegistry((entries) => [...entries, record])
|
|
171
|
+
const unregister = (record: CommandRecord) =>
|
|
172
|
+
setRegistry((entries) => entries.filter((entry) => entry !== record))
|
|
173
|
+
|
|
174
|
+
const move = (delta: number) => {
|
|
175
|
+
const items = visible()
|
|
176
|
+
if (items.length === 0) return
|
|
177
|
+
const current = items.findIndex((entry) => entry.value() === highlighted())
|
|
178
|
+
let index = current === -1 ? (delta > 0 ? -1 : 0) : current
|
|
179
|
+
// A disabled entry is stepped over rather than landed on: it is in the list
|
|
180
|
+
// because it explains why it cannot be used, not because it is a target.
|
|
181
|
+
for (let step = 0; step < items.length; step += 1) {
|
|
182
|
+
index = (index + delta + items.length) % items.length
|
|
183
|
+
if (!items[index]?.disabled()) break
|
|
184
|
+
}
|
|
185
|
+
setHighlighted(items[index]?.value())
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const accept = () => {
|
|
189
|
+
const items = visible()
|
|
190
|
+
const chosen =
|
|
191
|
+
items.find((entry) => entry.value() === highlighted()) ?? items[0]
|
|
192
|
+
if (!chosen || chosen.disabled()) return
|
|
193
|
+
setOpen(false)
|
|
194
|
+
chosen.select()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// The first match is the highlighted one: a palette you can open and confirm
|
|
198
|
+
// without looking at the list is the point of having one.
|
|
199
|
+
createEffect(() => {
|
|
200
|
+
const items = visible()
|
|
201
|
+
if (!items.some((entry) => entry.value() === highlighted()))
|
|
202
|
+
setHighlighted(items.find((entry) => !entry.disabled())?.value())
|
|
203
|
+
})
|
|
204
|
+
// A fresh palette starts empty, not showing the last search.
|
|
205
|
+
createEffect(() => {
|
|
206
|
+
if (!open()) setQuery('')
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
// Zag's trap moves focus into the dialog content; this moves it one step
|
|
210
|
+
// further, into the only control there is. An effect, not the `autofocus`
|
|
211
|
+
// attribute, because the content does not exist until `open` flips.
|
|
212
|
+
createEffect(() => {
|
|
213
|
+
if (open()) field?.focus()
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
onMount(() => {
|
|
217
|
+
if (props.shortcut === false) return
|
|
218
|
+
const onKey = (event: KeyboardEvent) => {
|
|
219
|
+
if (event.key.toLowerCase() !== 'k') return
|
|
220
|
+
if (!event.metaKey && !event.ctrlKey) return
|
|
221
|
+
event.preventDefault()
|
|
222
|
+
if (!open()) setOpen(true)
|
|
223
|
+
}
|
|
224
|
+
window.addEventListener('keydown', onKey)
|
|
225
|
+
onCleanup(() => window.removeEventListener('keydown', onKey))
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
const context: CommandContextValue = {
|
|
229
|
+
query,
|
|
230
|
+
setQuery,
|
|
231
|
+
register,
|
|
232
|
+
unregister,
|
|
233
|
+
visible,
|
|
234
|
+
highlighted,
|
|
235
|
+
highlight: setHighlighted,
|
|
236
|
+
move,
|
|
237
|
+
accept,
|
|
238
|
+
itemId: (value) => `${id}-${value.replace(/\W+/g, '-')}`,
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let field: HTMLInputElement | undefined
|
|
242
|
+
const activeId = () => {
|
|
243
|
+
const value = highlighted()
|
|
244
|
+
return value === undefined ? undefined : context.itemId(value)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return (
|
|
248
|
+
<CommandContext.Provider value={context}>
|
|
249
|
+
<Dialog.Root open={open()} onOpenChange={setOpen}>
|
|
250
|
+
<Dialog.Content
|
|
251
|
+
bare
|
|
252
|
+
position="top"
|
|
253
|
+
size={props.size ?? 'md'}
|
|
254
|
+
class={cn('max-h-[60vh] overflow-hidden', props.class)}
|
|
255
|
+
>
|
|
256
|
+
{/* A modal dialog has to be named: the field carries the label for the
|
|
257
|
+
listbox, but the dialog itself is what a screen reader announces on
|
|
258
|
+
open. */}
|
|
259
|
+
<Dialog.Title class="sr-only">
|
|
260
|
+
{props.label ?? 'Commands'}
|
|
261
|
+
</Dialog.Title>
|
|
262
|
+
<div data-slot="command-palette" role="presentation">
|
|
263
|
+
<div class="flex items-center gap-2 border-b border-outline-gray-1 px-3">
|
|
264
|
+
<Icon
|
|
265
|
+
icon="lucide-search"
|
|
266
|
+
class="size-4 shrink-0 text-ink-gray-5"
|
|
267
|
+
aria-hidden="true"
|
|
268
|
+
/>
|
|
269
|
+
<input
|
|
270
|
+
ref={field}
|
|
271
|
+
type="text"
|
|
272
|
+
role="combobox"
|
|
273
|
+
aria-expanded="true"
|
|
274
|
+
aria-controls={`${id}-list`}
|
|
275
|
+
aria-activedescendant={activeId()}
|
|
276
|
+
aria-autocomplete="list"
|
|
277
|
+
aria-label={props.label ?? 'Commands'}
|
|
278
|
+
placeholder={props.placeholder ?? 'Search commands…'}
|
|
279
|
+
value={query()}
|
|
280
|
+
onInput={(event) => setQuery(event.currentTarget.value)}
|
|
281
|
+
onKeyDown={(event) => {
|
|
282
|
+
if (event.key === 'ArrowDown') {
|
|
283
|
+
event.preventDefault()
|
|
284
|
+
move(1)
|
|
285
|
+
} else if (event.key === 'ArrowUp') {
|
|
286
|
+
event.preventDefault()
|
|
287
|
+
move(-1)
|
|
288
|
+
} else if (event.key === 'Enter') {
|
|
289
|
+
event.preventDefault()
|
|
290
|
+
accept()
|
|
291
|
+
}
|
|
292
|
+
}}
|
|
293
|
+
class="h-11 min-w-0 flex-1 bg-transparent text-base text-ink-gray-9 placeholder:text-ink-gray-5 focus:outline-none"
|
|
294
|
+
/>
|
|
295
|
+
</div>
|
|
296
|
+
|
|
297
|
+
<div
|
|
298
|
+
id={`${id}-list`}
|
|
299
|
+
role="listbox"
|
|
300
|
+
aria-label={props.label ?? 'Commands'}
|
|
301
|
+
class="max-h-[50vh] overflow-y-auto p-1.5"
|
|
302
|
+
>
|
|
303
|
+
{/*
|
|
304
|
+
The items are *always* rendered, never behind a `Show` that
|
|
305
|
+
reads the filtered list: an item registers itself when it mounts,
|
|
306
|
+
so hiding the list until the registry is non-empty deadlocks —
|
|
307
|
+
no items, so no registry, so the empty state, forever.
|
|
308
|
+
*/}
|
|
309
|
+
{props.children}
|
|
310
|
+
<Show when={isEmpty()}>
|
|
311
|
+
{/* A listbox may only contain options/groups: the empty
|
|
312
|
+
message is a disabled option, the one shape a screen
|
|
313
|
+
reader can address inside the list. */}
|
|
314
|
+
<div
|
|
315
|
+
role="option"
|
|
316
|
+
aria-disabled="true"
|
|
317
|
+
class="px-2 py-6 text-center text-base text-ink-gray-6"
|
|
318
|
+
>
|
|
319
|
+
{props.empty ?? 'Nothing matches.'}
|
|
320
|
+
</div>
|
|
321
|
+
</Show>
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
</Dialog.Content>
|
|
325
|
+
</Dialog.Root>
|
|
326
|
+
</CommandContext.Provider>
|
|
327
|
+
)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export interface CommandGroupProps {
|
|
331
|
+
/** Group heading; also the group's accessible name. */
|
|
332
|
+
label: string
|
|
333
|
+
children: JSX.Element
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* A titled run of items.
|
|
338
|
+
*
|
|
339
|
+
* The heading is hidden while the query is filtering: the grouping is an aid to
|
|
340
|
+
* browsing, and once you are searching for one thing the order is relevance.
|
|
341
|
+
*/
|
|
342
|
+
export function CommandGroup(props: CommandGroupProps): JSX.Element {
|
|
343
|
+
const context = useCommand()
|
|
344
|
+
return (
|
|
345
|
+
<div role="group" aria-label={props.label}>
|
|
346
|
+
<Show when={context.query() === ''}>
|
|
347
|
+
<div
|
|
348
|
+
data-slot="command-group-label"
|
|
349
|
+
class="px-2 pt-2 pb-1 text-sm-medium text-ink-gray-6"
|
|
350
|
+
>
|
|
351
|
+
{props.label}
|
|
352
|
+
</div>
|
|
353
|
+
</Show>
|
|
354
|
+
{props.children}
|
|
355
|
+
</div>
|
|
356
|
+
)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export interface CommandItemProps {
|
|
360
|
+
/** Stable identity; also what `onSelect` reports. */
|
|
361
|
+
value: string
|
|
362
|
+
/** Extra search text for words that are not in the visible label. */
|
|
363
|
+
keywords?: string
|
|
364
|
+
icon?: IconProp
|
|
365
|
+
/** Trailing hint, e.g. `⌘B`. Purely informational. */
|
|
366
|
+
shortcut?: string
|
|
367
|
+
description?: string
|
|
368
|
+
disabled?: boolean
|
|
369
|
+
onSelect?: (value: string) => void
|
|
370
|
+
children: JSX.Element
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function CommandItem(props: CommandItemProps): JSX.Element {
|
|
374
|
+
const context = useCommand()
|
|
375
|
+
const [element, setElement] = createSignal<HTMLDivElement>()
|
|
376
|
+
|
|
377
|
+
const label = () =>
|
|
378
|
+
typeof props.children === 'string'
|
|
379
|
+
? props.children
|
|
380
|
+
: (element()?.textContent ?? '')
|
|
381
|
+
|
|
382
|
+
const value = () => props.value
|
|
383
|
+
const record: CommandRecord = {
|
|
384
|
+
value,
|
|
385
|
+
keywords: () => props.keywords ?? '',
|
|
386
|
+
label,
|
|
387
|
+
disabled: () => props.disabled ?? false,
|
|
388
|
+
matches: () =>
|
|
389
|
+
score(label(), props.keywords ?? '', context.query()) !== null,
|
|
390
|
+
select: () => props.onSelect?.(value()),
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
onMount(() => {
|
|
394
|
+
context.register(record)
|
|
395
|
+
onCleanup(() => context.unregister(record))
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
const selected = () => context.highlighted() === props.value
|
|
399
|
+
const matches = () => record.matches()
|
|
400
|
+
|
|
401
|
+
/* The item stays mounted while it does not match — it is hidden, not removed,
|
|
402
|
+
* because unmounting it would unregister it, and the registry is how the
|
|
403
|
+
* palette tells "nothing has mounted yet" from "nothing matches". The wrapper
|
|
404
|
+
* carries the `hidden` *attribute* and no display utility, so nothing can
|
|
405
|
+
* out-rank it: a `flex` class on the same element would win in the browser
|
|
406
|
+
* while looking right in a test. */
|
|
407
|
+
return (
|
|
408
|
+
<div hidden={!matches()}>
|
|
409
|
+
<div
|
|
410
|
+
ref={setElement}
|
|
411
|
+
id={context.itemId(props.value)}
|
|
412
|
+
role="option"
|
|
413
|
+
aria-selected={selected()}
|
|
414
|
+
aria-disabled={props.disabled ? true : undefined}
|
|
415
|
+
data-slot="command-item"
|
|
416
|
+
data-selected={selected() ? '' : undefined}
|
|
417
|
+
data-disabled={props.disabled ? '' : undefined}
|
|
418
|
+
onClick={() => {
|
|
419
|
+
if (props.disabled) return
|
|
420
|
+
props.onSelect?.(props.value)
|
|
421
|
+
}}
|
|
422
|
+
onPointerMove={() => {
|
|
423
|
+
if (!props.disabled) context.highlight(props.value)
|
|
424
|
+
}}
|
|
425
|
+
class={cn(
|
|
426
|
+
'flex cursor-pointer items-center gap-2 rounded-control px-2 py-2 text-base text-ink-gray-8 transition-colors',
|
|
427
|
+
'data-[selected]:bg-surface-gray-2',
|
|
428
|
+
'data-[disabled]:cursor-not-allowed data-[disabled]:text-ink-gray-4',
|
|
429
|
+
)}
|
|
430
|
+
>
|
|
431
|
+
<Show when={props.icon}>
|
|
432
|
+
{(icon) => (
|
|
433
|
+
<Icon
|
|
434
|
+
icon={icon()}
|
|
435
|
+
class="size-4 shrink-0 text-ink-gray-6"
|
|
436
|
+
aria-hidden="true"
|
|
437
|
+
/>
|
|
438
|
+
)}
|
|
439
|
+
</Show>
|
|
440
|
+
<span class="min-w-0 flex-1 truncate">{props.children}</span>
|
|
441
|
+
<Show when={props.description}>
|
|
442
|
+
<span class="shrink-0 text-sm text-ink-gray-5">
|
|
443
|
+
{props.description}
|
|
444
|
+
</span>
|
|
445
|
+
</Show>
|
|
446
|
+
<Show when={props.shortcut}>
|
|
447
|
+
<kbd
|
|
448
|
+
data-slot="command-shortcut"
|
|
449
|
+
class="shrink-0 rounded-control border border-outline-gray-2 px-1.5 py-0.5 font-sans text-xs text-ink-gray-6"
|
|
450
|
+
>
|
|
451
|
+
{props.shortcut}
|
|
452
|
+
</kbd>
|
|
453
|
+
</Show>
|
|
454
|
+
</div>
|
|
455
|
+
</div>
|
|
456
|
+
)
|
|
457
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './CommandPalette'
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { render, screen, waitFor } from '@solidjs/testing-library'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import { DataTable, type DataTableColumn } from './DataTable'
|
|
5
|
+
|
|
6
|
+
interface Row {
|
|
7
|
+
id: string
|
|
8
|
+
name: string
|
|
9
|
+
amount: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const ROWS: Row[] = [
|
|
13
|
+
{ id: '1', name: 'Alpha', amount: 10 },
|
|
14
|
+
{ id: '2', name: 'Beta', amount: 20 },
|
|
15
|
+
{ id: '3', name: 'Gamma', amount: 30 },
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
const COLUMNS: DataTableColumn<Row>[] = [
|
|
19
|
+
{ accessorKey: 'name', header: 'Name' },
|
|
20
|
+
{
|
|
21
|
+
accessorKey: 'amount',
|
|
22
|
+
header: 'Amount',
|
|
23
|
+
cell: (context) => `$${context.getValue()}`,
|
|
24
|
+
meta: { align: 'end' },
|
|
25
|
+
},
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
function table(props: Partial<Parameters<typeof DataTable<Row>>[0]> = {}) {
|
|
29
|
+
return render(() => (
|
|
30
|
+
<DataTable
|
|
31
|
+
title="Rows"
|
|
32
|
+
rows={ROWS}
|
|
33
|
+
columns={COLUMNS}
|
|
34
|
+
pageSize={10}
|
|
35
|
+
{...props}
|
|
36
|
+
/>
|
|
37
|
+
))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('DataTable', () => {
|
|
41
|
+
it('renders headers, cells and a labelled region', () => {
|
|
42
|
+
table()
|
|
43
|
+
expect(screen.getByRole('region', { name: 'Rows' })).toBeTruthy()
|
|
44
|
+
expect(screen.getByRole('columnheader', { name: 'Name' })).toBeTruthy()
|
|
45
|
+
expect(screen.getByText('Alpha')).toBeTruthy()
|
|
46
|
+
expect(screen.getByText('$30')).toBeTruthy()
|
|
47
|
+
// Header row + three records.
|
|
48
|
+
expect(screen.getAllByRole('row')).toHaveLength(4)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('filters as you type and reports the count', async () => {
|
|
52
|
+
table()
|
|
53
|
+
await userEvent.type(
|
|
54
|
+
screen.getByRole('searchbox', { name: 'Search rows' }),
|
|
55
|
+
'beta',
|
|
56
|
+
)
|
|
57
|
+
await waitFor(() => expect(screen.queryByText('Alpha')).toBeNull())
|
|
58
|
+
expect(screen.getByText('Beta')).toBeTruthy()
|
|
59
|
+
expect(screen.getByRole('status').textContent).toContain('1')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('selects rows and hands the ids to the bulk action', async () => {
|
|
63
|
+
const onRowsAction = vi.fn()
|
|
64
|
+
table({ onRowsAction, actionLabel: 'Archive selected' })
|
|
65
|
+
await userEvent.click(screen.getByRole('checkbox', { name: 'Select 1' }))
|
|
66
|
+
const bulk = await screen.findByRole('button', {
|
|
67
|
+
name: /Archive selected \(1\)/,
|
|
68
|
+
})
|
|
69
|
+
await userEvent.click(bulk)
|
|
70
|
+
expect(onRowsAction).toHaveBeenCalledWith(['1'])
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('sorts from the header and marks the column', async () => {
|
|
74
|
+
table()
|
|
75
|
+
const header = screen.getByRole('button', { name: 'Amount' })
|
|
76
|
+
await userEvent.click(header)
|
|
77
|
+
await waitFor(() => {
|
|
78
|
+
const cell = screen.getByRole('columnheader', { name: 'Amount' })
|
|
79
|
+
expect(['ascending', 'descending']).toContain(
|
|
80
|
+
cell.getAttribute('aria-sort'),
|
|
81
|
+
)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('can be a read-only table with no selection column', () => {
|
|
86
|
+
table({ selectable: false })
|
|
87
|
+
expect(screen.queryByRole('checkbox', { name: /Select/ })).toBeNull()
|
|
88
|
+
})
|
|
89
|
+
})
|