@talelk/newui-organisms 0.0.0-stage → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +12 -2
- package/package.json +38 -3
- package/src/exports.test.ts +27 -0
- package/src/index.ts +1 -0
- package/src/organisms/AppSidebar/AppSidebar.test.tsx +136 -0
- package/src/organisms/AppSidebar/AppSidebar.tsx +259 -0
- package/src/organisms/AppSidebar/index.ts +1 -0
- package/src/organisms/AuthCard/AuthCard.a11y.test.tsx +36 -0
- package/src/organisms/AuthCard/AuthCard.test.tsx +56 -0
- package/src/organisms/AuthCard/AuthCard.tsx +89 -0
- package/src/organisms/AuthCard/index.ts +1 -0
- package/src/organisms/CommandPalette/CommandPalette.test.tsx +196 -0
- package/src/organisms/CommandPalette/CommandPalette.tsx +468 -0
- package/src/organisms/CommandPalette/index.ts +1 -0
- package/src/organisms/DataState/DataState.test.tsx +45 -0
- package/src/organisms/DataState/DataState.tsx +111 -0
- package/src/organisms/DataState/index.ts +6 -0
- package/src/organisms/DataTable/DataTable.test.tsx +161 -0
- package/src/organisms/DataTable/DataTable.tsx +612 -0
- package/src/organisms/DataTable/index.ts +1 -0
- package/src/organisms/DetailHeader/DetailHeader.test.tsx +55 -0
- package/src/organisms/DetailHeader/DetailHeader.tsx +95 -0
- package/src/organisms/DetailHeader/index.ts +1 -0
- package/src/organisms/FilterBar/FilterBar.test.tsx +152 -0
- package/src/organisms/FilterBar/FilterBar.tsx +251 -0
- package/src/organisms/FilterBar/index.ts +1 -0
- package/src/organisms/KanbanBoard/KanbanBoard.test.tsx +255 -0
- package/src/organisms/KanbanBoard/KanbanBoard.tsx +605 -0
- package/src/organisms/KanbanBoard/drag.test.ts +448 -0
- package/src/organisms/KanbanBoard/drag.ts +407 -0
- package/src/organisms/KanbanBoard/index.ts +17 -0
- package/src/organisms/MobileNav/MobileNav.browser.test.tsx +39 -0
- package/src/organisms/MobileNav/MobileNav.test.tsx +108 -0
- package/src/organisms/MobileNav/MobileNav.tsx +120 -0
- package/src/organisms/MobileNav/index.ts +6 -0
- package/src/organisms/PageToolbar/PageToolbar.a11y.test.tsx +35 -0
- package/src/organisms/PageToolbar/PageToolbar.test.tsx +42 -0
- package/src/organisms/PageToolbar/PageToolbar.tsx +126 -0
- package/src/organisms/PageToolbar/index.ts +1 -0
- package/src/organisms/Section/Section.test.tsx +41 -0
- package/src/organisms/Section/Section.tsx +108 -0
- package/src/organisms/Section/index.ts +1 -0
- package/src/organisms/SelectionBar/SelectionBar.test.tsx +35 -0
- package/src/organisms/SelectionBar/SelectionBar.tsx +84 -0
- package/src/organisms/SelectionBar/index.ts +5 -0
- package/src/organisms/SettingsDialog/SettingsDialog.a11y.test.tsx +58 -0
- package/src/organisms/SettingsDialog/SettingsDialog.browser.test.tsx +43 -0
- package/src/organisms/SettingsDialog/SettingsDialog.ssr.test.tsx +27 -0
- package/src/organisms/SettingsDialog/SettingsDialog.test.tsx +202 -0
- package/src/organisms/SettingsDialog/SettingsDialog.tsx +548 -0
- package/src/organisms/SettingsDialog/index.ts +20 -0
- package/src/organisms/Sidebar/Sidebar.browser.test.tsx +52 -0
- package/src/organisms/Sidebar/Sidebar.test.tsx +308 -0
- package/src/organisms/Sidebar/Sidebar.tsx +732 -0
- package/src/organisms/Sidebar/index.ts +22 -0
- package/src/organisms/StatGrid/StatGrid.a11y.test.tsx +40 -0
- package/src/organisms/StatGrid/StatGrid.test.tsx +95 -0
- package/src/organisms/StatGrid/StatGrid.tsx +151 -0
- package/src/organisms/StatGrid/index.ts +1 -0
- package/src/organisms/VirtualList/VirtualList.test.tsx +80 -0
- package/src/organisms/VirtualList/VirtualList.tsx +113 -0
- package/src/organisms/VirtualList/index.ts +1 -0
- package/src/organisms/index.ts +16 -0
|
@@ -0,0 +1,468 @@
|
|
|
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
|
+
/** Replaces the list with a loading row while commands register. */
|
|
125
|
+
loading?: boolean
|
|
126
|
+
class?: string
|
|
127
|
+
children: JSX.Element
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function CommandPalette(props: CommandPaletteProps): JSX.Element {
|
|
131
|
+
commandId += 1
|
|
132
|
+
const id = `command-${commandId}`
|
|
133
|
+
const [uncontrolledOpen, setUncontrolledOpen] = createSignal(
|
|
134
|
+
props.defaultOpen ?? false,
|
|
135
|
+
)
|
|
136
|
+
const isControlled = () => props.open !== undefined
|
|
137
|
+
const open = () => props.open ?? uncontrolledOpen()
|
|
138
|
+
const setOpen = (next: boolean) => {
|
|
139
|
+
if (!isControlled()) setUncontrolledOpen(next)
|
|
140
|
+
props.onOpenChange?.(next)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const [query, setQuery] = createSignal('')
|
|
144
|
+
const [highlighted, setHighlighted] = createSignal<string>()
|
|
145
|
+
/**
|
|
146
|
+
* The registry is a signal, not a plain array with a counter beside it.
|
|
147
|
+
*
|
|
148
|
+
* That is not tidiness: `isEmpty` reads the registry *first*, and with a plain
|
|
149
|
+
* array the first render short-circuits on `length === 0` before touching
|
|
150
|
+
* anything reactive — so the empty state's condition records no dependency at
|
|
151
|
+
* all and never re-runs. It renders nothing, forever, while every other read
|
|
152
|
+
* of the same state updates.
|
|
153
|
+
*/
|
|
154
|
+
const [registry, setRegistry] = createSignal<CommandRecord[]>([])
|
|
155
|
+
|
|
156
|
+
const visible = createMemo(() => {
|
|
157
|
+
const current = query()
|
|
158
|
+
return registry()
|
|
159
|
+
.filter((record) => record.matches())
|
|
160
|
+
.map((record) => ({
|
|
161
|
+
record,
|
|
162
|
+
rank: score(record.label(), record.keywords(), current) ?? 0,
|
|
163
|
+
}))
|
|
164
|
+
.sort((a, b) => a.rank - b.rank)
|
|
165
|
+
.map((entry) => entry.record)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
/** Registered but none matching: not the same as "nothing has mounted yet". */
|
|
169
|
+
const isEmpty = () => registry().length > 0 && visible().length === 0
|
|
170
|
+
|
|
171
|
+
const register = (record: CommandRecord) =>
|
|
172
|
+
setRegistry((entries) => [...entries, record])
|
|
173
|
+
const unregister = (record: CommandRecord) =>
|
|
174
|
+
setRegistry((entries) => entries.filter((entry) => entry !== record))
|
|
175
|
+
|
|
176
|
+
const move = (delta: number) => {
|
|
177
|
+
const items = visible()
|
|
178
|
+
if (items.length === 0) return
|
|
179
|
+
const current = items.findIndex((entry) => entry.value() === highlighted())
|
|
180
|
+
let index = current === -1 ? (delta > 0 ? -1 : 0) : current
|
|
181
|
+
// A disabled entry is stepped over rather than landed on: it is in the list
|
|
182
|
+
// because it explains why it cannot be used, not because it is a target.
|
|
183
|
+
for (let step = 0; step < items.length; step += 1) {
|
|
184
|
+
index = (index + delta + items.length) % items.length
|
|
185
|
+
if (!items[index]?.disabled()) break
|
|
186
|
+
}
|
|
187
|
+
setHighlighted(items[index]?.value())
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const accept = () => {
|
|
191
|
+
const items = visible()
|
|
192
|
+
const chosen =
|
|
193
|
+
items.find((entry) => entry.value() === highlighted()) ?? items[0]
|
|
194
|
+
if (!chosen || chosen.disabled()) return
|
|
195
|
+
setOpen(false)
|
|
196
|
+
chosen.select()
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// The first match is the highlighted one: a palette you can open and confirm
|
|
200
|
+
// without looking at the list is the point of having one.
|
|
201
|
+
createEffect(() => {
|
|
202
|
+
const items = visible()
|
|
203
|
+
if (!items.some((entry) => entry.value() === highlighted()))
|
|
204
|
+
setHighlighted(items.find((entry) => !entry.disabled())?.value())
|
|
205
|
+
})
|
|
206
|
+
// A fresh palette starts empty, not showing the last search.
|
|
207
|
+
createEffect(() => {
|
|
208
|
+
if (!open()) setQuery('')
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
// Zag's trap moves focus into the dialog content; this moves it one step
|
|
212
|
+
// further, into the only control there is. An effect, not the `autofocus`
|
|
213
|
+
// attribute, because the content does not exist until `open` flips.
|
|
214
|
+
createEffect(() => {
|
|
215
|
+
if (open()) field?.focus()
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
onMount(() => {
|
|
219
|
+
if (props.shortcut === false) return
|
|
220
|
+
const onKey = (event: KeyboardEvent) => {
|
|
221
|
+
if (event.key.toLowerCase() !== 'k') return
|
|
222
|
+
if (!event.metaKey && !event.ctrlKey) return
|
|
223
|
+
event.preventDefault()
|
|
224
|
+
if (!open()) setOpen(true)
|
|
225
|
+
}
|
|
226
|
+
window.addEventListener('keydown', onKey)
|
|
227
|
+
onCleanup(() => window.removeEventListener('keydown', onKey))
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
const context: CommandContextValue = {
|
|
231
|
+
query,
|
|
232
|
+
setQuery,
|
|
233
|
+
register,
|
|
234
|
+
unregister,
|
|
235
|
+
visible,
|
|
236
|
+
highlighted,
|
|
237
|
+
highlight: setHighlighted,
|
|
238
|
+
move,
|
|
239
|
+
accept,
|
|
240
|
+
itemId: (value) => `${id}-${value.replace(/\W+/g, '-')}`,
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let field: HTMLInputElement | undefined
|
|
244
|
+
const activeId = () => {
|
|
245
|
+
const value = highlighted()
|
|
246
|
+
return value === undefined ? undefined : context.itemId(value)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return (
|
|
250
|
+
<CommandContext.Provider value={context}>
|
|
251
|
+
<Dialog.Root open={open()} onOpenChange={setOpen}>
|
|
252
|
+
<Dialog.Content
|
|
253
|
+
bare
|
|
254
|
+
position="top"
|
|
255
|
+
size={props.size ?? 'md'}
|
|
256
|
+
class={cn('max-h-[60vh] overflow-hidden', props.class)}
|
|
257
|
+
>
|
|
258
|
+
{/* A modal dialog has to be named: the field carries the label for the
|
|
259
|
+
listbox, but the dialog itself is what a screen reader announces on
|
|
260
|
+
open. */}
|
|
261
|
+
<Dialog.Title class="sr-only">
|
|
262
|
+
{props.label ?? 'Commands'}
|
|
263
|
+
</Dialog.Title>
|
|
264
|
+
<div data-slot="command-palette" role="presentation">
|
|
265
|
+
<div class="flex items-center gap-2 border-b border-outline-gray-1 px-3">
|
|
266
|
+
<Icon
|
|
267
|
+
icon="lucide-search"
|
|
268
|
+
class="size-4 shrink-0 text-ink-gray-5"
|
|
269
|
+
aria-hidden="true"
|
|
270
|
+
/>
|
|
271
|
+
<input
|
|
272
|
+
ref={field}
|
|
273
|
+
type="text"
|
|
274
|
+
role="combobox"
|
|
275
|
+
aria-expanded="true"
|
|
276
|
+
aria-controls={`${id}-list`}
|
|
277
|
+
aria-activedescendant={activeId()}
|
|
278
|
+
aria-autocomplete="list"
|
|
279
|
+
aria-label={props.label ?? 'Commands'}
|
|
280
|
+
placeholder={props.placeholder ?? 'Search commands…'}
|
|
281
|
+
value={query()}
|
|
282
|
+
onInput={(event) => setQuery(event.currentTarget.value)}
|
|
283
|
+
onKeyDown={(event) => {
|
|
284
|
+
if (event.key === 'ArrowDown') {
|
|
285
|
+
event.preventDefault()
|
|
286
|
+
move(1)
|
|
287
|
+
} else if (event.key === 'ArrowUp') {
|
|
288
|
+
event.preventDefault()
|
|
289
|
+
move(-1)
|
|
290
|
+
} else if (event.key === 'Enter') {
|
|
291
|
+
event.preventDefault()
|
|
292
|
+
accept()
|
|
293
|
+
}
|
|
294
|
+
}}
|
|
295
|
+
class="h-11 min-w-0 flex-1 bg-transparent text-base text-ink-gray-9 placeholder:text-ink-gray-5 focus:outline-none"
|
|
296
|
+
/>
|
|
297
|
+
</div>
|
|
298
|
+
|
|
299
|
+
<div
|
|
300
|
+
id={`${id}-list`}
|
|
301
|
+
role="listbox"
|
|
302
|
+
aria-label={props.label ?? 'Commands'}
|
|
303
|
+
class="max-h-[50vh] overflow-y-auto p-1.5"
|
|
304
|
+
>
|
|
305
|
+
{/*
|
|
306
|
+
The items are *always* rendered, never behind a `Show` that
|
|
307
|
+
reads the filtered list: an item registers itself when it mounts,
|
|
308
|
+
so hiding the list until the registry is non-empty deadlocks —
|
|
309
|
+
no items, so no registry, so the empty state, forever.
|
|
310
|
+
*/}
|
|
311
|
+
<Show when={!props.loading}>{props.children}</Show>
|
|
312
|
+
<Show when={props.loading}>
|
|
313
|
+
<div
|
|
314
|
+
role="option"
|
|
315
|
+
aria-disabled="true"
|
|
316
|
+
class="px-2 py-6 text-center text-base text-ink-gray-6"
|
|
317
|
+
>
|
|
318
|
+
Loading commands…
|
|
319
|
+
</div>
|
|
320
|
+
</Show>
|
|
321
|
+
<Show when={!props.loading && isEmpty()}>
|
|
322
|
+
{/* A listbox may only contain options/groups: the empty
|
|
323
|
+
message is a disabled option, the one shape a screen
|
|
324
|
+
reader can address inside the list. */}
|
|
325
|
+
<div
|
|
326
|
+
role="option"
|
|
327
|
+
aria-disabled="true"
|
|
328
|
+
class="px-2 py-6 text-center text-base text-ink-gray-6"
|
|
329
|
+
>
|
|
330
|
+
{props.empty ?? 'Nothing matches.'}
|
|
331
|
+
</div>
|
|
332
|
+
</Show>
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
</Dialog.Content>
|
|
336
|
+
</Dialog.Root>
|
|
337
|
+
</CommandContext.Provider>
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export interface CommandGroupProps {
|
|
342
|
+
/** Group heading; also the group's accessible name. */
|
|
343
|
+
label: string
|
|
344
|
+
children: JSX.Element
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* A titled run of items.
|
|
349
|
+
*
|
|
350
|
+
* The heading is hidden while the query is filtering: the grouping is an aid to
|
|
351
|
+
* browsing, and once you are searching for one thing the order is relevance.
|
|
352
|
+
*/
|
|
353
|
+
export function CommandGroup(props: CommandGroupProps): JSX.Element {
|
|
354
|
+
const context = useCommand()
|
|
355
|
+
return (
|
|
356
|
+
<div role="group" aria-label={props.label}>
|
|
357
|
+
<Show when={context.query() === ''}>
|
|
358
|
+
<div
|
|
359
|
+
data-slot="command-group-label"
|
|
360
|
+
class="px-2 pt-2 pb-1 text-sm-medium text-ink-gray-6"
|
|
361
|
+
>
|
|
362
|
+
{props.label}
|
|
363
|
+
</div>
|
|
364
|
+
</Show>
|
|
365
|
+
{props.children}
|
|
366
|
+
</div>
|
|
367
|
+
)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export interface CommandItemProps {
|
|
371
|
+
/** Stable identity; also what `onSelect` reports. */
|
|
372
|
+
value: string
|
|
373
|
+
/** Extra search text for words that are not in the visible label. */
|
|
374
|
+
keywords?: string
|
|
375
|
+
icon?: IconProp
|
|
376
|
+
/** Trailing hint, e.g. `⌘B`. Purely informational. */
|
|
377
|
+
shortcut?: string
|
|
378
|
+
description?: string
|
|
379
|
+
disabled?: boolean
|
|
380
|
+
onSelect?: (value: string) => void
|
|
381
|
+
children: JSX.Element
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export function CommandItem(props: CommandItemProps): JSX.Element {
|
|
385
|
+
const context = useCommand()
|
|
386
|
+
const [element, setElement] = createSignal<HTMLDivElement>()
|
|
387
|
+
|
|
388
|
+
const label = () =>
|
|
389
|
+
typeof props.children === 'string'
|
|
390
|
+
? props.children
|
|
391
|
+
: (element()?.textContent ?? '')
|
|
392
|
+
|
|
393
|
+
const value = () => props.value
|
|
394
|
+
const record: CommandRecord = {
|
|
395
|
+
value,
|
|
396
|
+
keywords: () => props.keywords ?? '',
|
|
397
|
+
label,
|
|
398
|
+
disabled: () => props.disabled ?? false,
|
|
399
|
+
matches: () =>
|
|
400
|
+
score(label(), props.keywords ?? '', context.query()) !== null,
|
|
401
|
+
select: () => props.onSelect?.(value()),
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
onMount(() => {
|
|
405
|
+
context.register(record)
|
|
406
|
+
onCleanup(() => context.unregister(record))
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
const selected = () => context.highlighted() === props.value
|
|
410
|
+
const matches = () => record.matches()
|
|
411
|
+
|
|
412
|
+
/* The item stays mounted while it does not match — it is hidden, not removed,
|
|
413
|
+
* because unmounting it would unregister it, and the registry is how the
|
|
414
|
+
* palette tells "nothing has mounted yet" from "nothing matches". The wrapper
|
|
415
|
+
* carries the `hidden` *attribute* and no display utility, so nothing can
|
|
416
|
+
* out-rank it: a `flex` class on the same element would win in the browser
|
|
417
|
+
* while looking right in a test. */
|
|
418
|
+
return (
|
|
419
|
+
<div hidden={!matches()}>
|
|
420
|
+
<div
|
|
421
|
+
ref={setElement}
|
|
422
|
+
id={context.itemId(props.value)}
|
|
423
|
+
role="option"
|
|
424
|
+
aria-selected={selected()}
|
|
425
|
+
aria-disabled={props.disabled ? true : undefined}
|
|
426
|
+
data-slot="command-item"
|
|
427
|
+
data-selected={selected() ? '' : undefined}
|
|
428
|
+
data-disabled={props.disabled ? '' : undefined}
|
|
429
|
+
onClick={() => {
|
|
430
|
+
if (props.disabled) return
|
|
431
|
+
props.onSelect?.(props.value)
|
|
432
|
+
}}
|
|
433
|
+
onPointerMove={() => {
|
|
434
|
+
if (!props.disabled) context.highlight(props.value)
|
|
435
|
+
}}
|
|
436
|
+
class={cn(
|
|
437
|
+
'flex cursor-pointer items-center gap-2 rounded-control px-2 py-2 text-base text-ink-gray-8 transition-colors',
|
|
438
|
+
'data-[selected]:bg-surface-gray-2',
|
|
439
|
+
'data-[disabled]:cursor-not-allowed data-[disabled]:text-ink-gray-4',
|
|
440
|
+
)}
|
|
441
|
+
>
|
|
442
|
+
<Show when={props.icon}>
|
|
443
|
+
{(icon) => (
|
|
444
|
+
<Icon
|
|
445
|
+
icon={icon()}
|
|
446
|
+
class="size-4 shrink-0 text-ink-gray-6"
|
|
447
|
+
aria-hidden="true"
|
|
448
|
+
/>
|
|
449
|
+
)}
|
|
450
|
+
</Show>
|
|
451
|
+
<span class="min-w-0 flex-1 truncate">{props.children}</span>
|
|
452
|
+
<Show when={props.description}>
|
|
453
|
+
<span class="shrink-0 text-sm text-ink-gray-5">
|
|
454
|
+
{props.description}
|
|
455
|
+
</span>
|
|
456
|
+
</Show>
|
|
457
|
+
<Show when={props.shortcut}>
|
|
458
|
+
<kbd
|
|
459
|
+
data-slot="command-shortcut"
|
|
460
|
+
class="shrink-0 rounded-control border border-outline-gray-2 px-1.5 py-0.5 font-sans text-xs text-ink-gray-6"
|
|
461
|
+
>
|
|
462
|
+
{props.shortcut}
|
|
463
|
+
</kbd>
|
|
464
|
+
</Show>
|
|
465
|
+
</div>
|
|
466
|
+
</div>
|
|
467
|
+
)
|
|
468
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './CommandPalette'
|
|
@@ -0,0 +1,45 @@
|
|
|
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 { DataState } from './DataState'
|
|
5
|
+
|
|
6
|
+
describe('DataState', () => {
|
|
7
|
+
it('renders ready children unchanged', () => {
|
|
8
|
+
render(() => (
|
|
9
|
+
<DataState status="ready">
|
|
10
|
+
<p>Rows</p>
|
|
11
|
+
</DataState>
|
|
12
|
+
))
|
|
13
|
+
expect(screen.getByText('Rows')).toBeTruthy()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('announces loading and renders the requested skeleton', () => {
|
|
17
|
+
const { container } = render(() => (
|
|
18
|
+
<DataState status="loading" skeleton="cards" />
|
|
19
|
+
))
|
|
20
|
+
expect(screen.getByRole('status', { name: 'Loading' })).toBeTruthy()
|
|
21
|
+
expect(container.querySelectorAll('[data-slot="skeleton"]').length).toBe(4)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('renders an empty state action', () => {
|
|
25
|
+
render(() => (
|
|
26
|
+
<DataState
|
|
27
|
+
status="empty"
|
|
28
|
+
title="No expenses"
|
|
29
|
+
action={<button type="button">Clear filters</button>}
|
|
30
|
+
/>
|
|
31
|
+
))
|
|
32
|
+
expect(screen.getByText('No expenses')).toBeTruthy()
|
|
33
|
+
expect(screen.getByRole('button', { name: 'Clear filters' })).toBeTruthy()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('offers retry on error', async () => {
|
|
37
|
+
const onRetry = vi.fn()
|
|
38
|
+
render(() => (
|
|
39
|
+
<DataState status="error" error="The request failed" onRetry={onRetry} />
|
|
40
|
+
))
|
|
41
|
+
expect(screen.getByRole('alert')).toBeTruthy()
|
|
42
|
+
await userEvent.click(screen.getByRole('button', { name: 'Try again' }))
|
|
43
|
+
expect(onRetry).toHaveBeenCalled()
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { Button, type IconProp, Skeleton } from '@talelk/newui-atoms'
|
|
2
|
+
import { EmptyState } from '@talelk/newui-molecules'
|
|
3
|
+
import { For, type JSX, Show } from 'solid-js'
|
|
4
|
+
|
|
5
|
+
export type DataStatus = 'ready' | 'loading' | 'empty' | 'error'
|
|
6
|
+
export type DataSkeleton = 'rows' | 'cards' | 'chart'
|
|
7
|
+
|
|
8
|
+
export interface DataStateProps {
|
|
9
|
+
status: DataStatus
|
|
10
|
+
/** Skeleton shape while loading. @default 'rows' */
|
|
11
|
+
skeleton?: DataSkeleton
|
|
12
|
+
/** Skeleton row count. @default 5 */
|
|
13
|
+
rows?: number
|
|
14
|
+
title?: JSX.Element
|
|
15
|
+
description?: JSX.Element
|
|
16
|
+
action?: JSX.Element
|
|
17
|
+
/** Icon shown in the empty state. @default 'lucide-inbox' */
|
|
18
|
+
icon?: IconProp
|
|
19
|
+
error?: JSX.Element
|
|
20
|
+
onRetry?: () => void
|
|
21
|
+
children?: JSX.Element
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function Loading(props: { skeleton: DataSkeleton; rows: number }): JSX.Element {
|
|
25
|
+
return (
|
|
26
|
+
<div
|
|
27
|
+
data-slot="data-state-loading"
|
|
28
|
+
role="status"
|
|
29
|
+
aria-label="Loading"
|
|
30
|
+
class="p-card-pad"
|
|
31
|
+
>
|
|
32
|
+
<Show when={props.skeleton === 'rows'}>
|
|
33
|
+
<div class="flex flex-col gap-2">
|
|
34
|
+
<For each={Array.from({ length: props.rows })}>
|
|
35
|
+
{() => <Skeleton lines={1} class="h-9" />}
|
|
36
|
+
</For>
|
|
37
|
+
</div>
|
|
38
|
+
</Show>
|
|
39
|
+
<Show when={props.skeleton === 'cards'}>
|
|
40
|
+
<div class="grid grid-cols-1 gap-3 min-[360px]:grid-cols-2 xl:grid-cols-4">
|
|
41
|
+
<For each={Array.from({ length: 4 })}>
|
|
42
|
+
{() => <Skeleton lines={3} class="h-24" />}
|
|
43
|
+
</For>
|
|
44
|
+
</div>
|
|
45
|
+
</Show>
|
|
46
|
+
<Show when={props.skeleton === 'chart'}>
|
|
47
|
+
<Skeleton lines={4} class="h-64" />
|
|
48
|
+
</Show>
|
|
49
|
+
</div>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The one loading / empty / error contract for data organisms.
|
|
55
|
+
*
|
|
56
|
+
* It composes the existing `Skeleton` and `EmptyState` molecules, so every
|
|
57
|
+
* region uses the same tone and action placement. `ready` renders its children
|
|
58
|
+
* unchanged.
|
|
59
|
+
*/
|
|
60
|
+
export function DataState(props: DataStateProps): JSX.Element {
|
|
61
|
+
return (
|
|
62
|
+
<Show
|
|
63
|
+
when={props.status === 'ready'}
|
|
64
|
+
fallback={<StateFallback {...props} />}
|
|
65
|
+
>
|
|
66
|
+
{props.children}
|
|
67
|
+
</Show>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function StateFallback(props: DataStateProps): JSX.Element {
|
|
72
|
+
return (
|
|
73
|
+
<Show
|
|
74
|
+
when={props.status === 'loading'}
|
|
75
|
+
fallback={
|
|
76
|
+
<Show
|
|
77
|
+
when={props.status === 'error'}
|
|
78
|
+
fallback={
|
|
79
|
+
<div data-slot="data-state-empty">
|
|
80
|
+
<EmptyState
|
|
81
|
+
icon={props.icon ?? 'lucide-inbox'}
|
|
82
|
+
title={props.title ?? 'Nothing here yet'}
|
|
83
|
+
description={props.description}
|
|
84
|
+
action={props.action}
|
|
85
|
+
/>
|
|
86
|
+
</div>
|
|
87
|
+
}
|
|
88
|
+
>
|
|
89
|
+
<div data-slot="data-state-error" role="alert">
|
|
90
|
+
<EmptyState
|
|
91
|
+
icon="lucide-triangle-alert"
|
|
92
|
+
title={props.title ?? 'Could not load this'}
|
|
93
|
+
description={props.error ?? props.description}
|
|
94
|
+
action={
|
|
95
|
+
props.onRetry ? (
|
|
96
|
+
<Button variant="outline" onClick={props.onRetry}>
|
|
97
|
+
Try again
|
|
98
|
+
</Button>
|
|
99
|
+
) : (
|
|
100
|
+
props.action
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
/>
|
|
104
|
+
</div>
|
|
105
|
+
</Show>
|
|
106
|
+
}
|
|
107
|
+
>
|
|
108
|
+
<Loading skeleton={props.skeleton ?? 'rows'} rows={props.rows ?? 5} />
|
|
109
|
+
</Show>
|
|
110
|
+
)
|
|
111
|
+
}
|