@cl-light-ds/ui 0.4.0 → 0.5.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.
Files changed (91) hide show
  1. package/package.json +19 -17
  2. package/src/components/alert/Alert.astro +8 -2
  3. package/src/components/alert/CHANGELOG.md +9 -0
  4. package/src/components/button/Button.astro +78 -19
  5. package/src/components/button/CHANGELOG.md +22 -0
  6. package/src/components/button-group/ButtonGroup.astro +14 -3
  7. package/src/components/button-group/CHANGELOG.md +17 -0
  8. package/src/components/card/CHANGELOG.md +9 -0
  9. package/src/components/card/Card.astro +22 -0
  10. package/src/components/combobox/CHANGELOG.md +14 -0
  11. package/src/components/combobox/ComboboxTrigger.astro +31 -1
  12. package/src/components/command-search/CHANGELOG.md +27 -0
  13. package/src/components/command-search/CommandSearch.astro +515 -0
  14. package/src/components/command-search/CommandSearchGroup.astro +32 -0
  15. package/src/components/command-search/CommandSearchItem.astro +64 -0
  16. package/src/components/command-search/classes.ts +18 -0
  17. package/src/components/command-search/index.ts +12 -0
  18. package/src/components/command-search/nothing-matches.svg +42 -0
  19. package/src/components/dialog/CHANGELOG.md +13 -0
  20. package/src/components/dialog/DialogClose.astro +26 -12
  21. package/src/components/dropdown-menu/CHANGELOG.md +7 -0
  22. package/src/components/dropdown-menu/dropdown-menu-item-variants.ts +1 -1
  23. package/src/components/empty-state/CHANGELOG.md +18 -0
  24. package/src/components/empty-state/EmptyState.astro +157 -0
  25. package/src/components/empty-state/illustrations/AllCaughtUp.astro +52 -0
  26. package/src/components/empty-state/illustrations/EmptyWorkspaces.astro +56 -0
  27. package/src/components/empty-state/illustrations/LoadError.astro +61 -0
  28. package/src/components/empty-state/illustrations/NoResults.astro +60 -0
  29. package/src/components/empty-state/illustrations/NothingMatches.astro +66 -0
  30. package/src/components/empty-state/illustrations/index.ts +27 -0
  31. package/src/components/empty-state/index.ts +7 -0
  32. package/src/components/favorite-toggle/CHANGELOG.md +13 -0
  33. package/src/components/favorite-toggle/FavoriteToggle.astro +94 -0
  34. package/src/components/favorite-toggle/index.ts +1 -0
  35. package/src/components/input-group/CHANGELOG.md +7 -0
  36. package/src/components/input-group/InputGroup.astro +2 -0
  37. package/src/components/loading-overlay/CHANGELOG.md +25 -0
  38. package/src/components/loading-overlay/LoadingOverlay.astro +179 -0
  39. package/src/components/loading-overlay/index.ts +8 -0
  40. package/src/components/notifications-menu/CHANGELOG.md +22 -0
  41. package/src/components/notifications-menu/NotificationsMenu.astro +374 -0
  42. package/src/components/notifications-menu/NotificationsMenuItem.astro +129 -0
  43. package/src/components/notifications-menu/all-caught-up.svg +28 -0
  44. package/src/components/notifications-menu/index.ts +3 -0
  45. package/src/components/notifications-menu/types.ts +32 -0
  46. package/src/components/option-card/CHANGELOG.md +16 -0
  47. package/src/components/option-card/OptionCard.astro +135 -0
  48. package/src/components/option-card/index.ts +1 -0
  49. package/src/components/organisms/top-bar/CHANGELOG.md +21 -0
  50. package/src/components/organisms/top-bar/TopBar.astro +264 -0
  51. package/src/components/organisms/top-bar/index.ts +2 -0
  52. package/src/components/organisms/top-bar/types.ts +30 -0
  53. package/src/components/organisms/workspace/CHANGELOG.md +73 -0
  54. package/src/components/organisms/workspace/OrganisationOverview.astro +62 -0
  55. package/src/components/organisms/workspace/RecentWorkspaceCard.astro +235 -0
  56. package/src/components/organisms/workspace/RecentWorkspaces.astro +93 -0
  57. package/src/components/organisms/workspace/WorkspaceAvatar.astro +144 -0
  58. package/src/components/organisms/workspace/WorkspaceCard.astro +172 -0
  59. package/src/components/organisms/workspace/WorkspaceGlyph.astro +62 -0
  60. package/src/components/organisms/workspace/WorkspaceIcon.astro +42 -0
  61. package/src/components/organisms/workspace/WorkspaceIconPicker.astro +288 -0
  62. package/src/components/organisms/workspace/WorkspaceIconPickerGlyph.astro +44 -0
  63. package/src/components/organisms/workspace/icons.ts +98 -0
  64. package/src/components/organisms/workspace/index.ts +35 -0
  65. package/src/components/organisms/workspace/labels.ts +54 -0
  66. package/src/components/organisms/workspace/recent-card-rings-strong.png +0 -0
  67. package/src/components/organisms/workspace/types.ts +30 -0
  68. package/src/components/profile-menu/CHANGELOG.md +22 -0
  69. package/src/components/profile-menu/ProfileMenu.astro +236 -0
  70. package/src/components/profile-menu/index.ts +2 -0
  71. package/src/components/profile-menu/types.ts +27 -0
  72. package/src/components/search-field/CHANGELOG.md +14 -0
  73. package/src/components/search-field/SearchField.astro +163 -0
  74. package/src/components/search-field/index.ts +1 -0
  75. package/src/components/table/CHANGELOG.md +9 -0
  76. package/src/components/table/TableCell.astro +6 -0
  77. package/src/components/table/TableHead.astro +5 -1
  78. package/src/components/table/TableRow.astro +3 -1
  79. package/src/components/table/table-cell-variants.ts +2 -0
  80. package/src/components/toast/CHANGELOG.md +32 -0
  81. package/src/components/toast/Toast.astro +145 -0
  82. package/src/components/toast/Toaster.astro +322 -0
  83. package/src/components/toast/index.ts +9 -0
  84. package/src/components/toggle/CHANGELOG.md +7 -0
  85. package/src/lib/command-search.ts +73 -0
  86. package/src/lib/loading-overlay.ts +174 -0
  87. package/src/lib/toast.ts +127 -0
  88. package/src/lib/toggle-shared.ts +4 -1
  89. package/src/lib/workspace-palette.ts +119 -0
  90. package/src/styles/CHANGELOG.md +22 -0
  91. package/src/styles/globals.css +87 -0
@@ -0,0 +1,515 @@
1
+ ---
2
+ /**
3
+ * @component CommandSearch
4
+ * @title Command Search
5
+ * @description A "Search everything…" button that opens a command palette (also with ⌘K, or Ctrl K off a Mac): a large search field with a Search button, results in groups, keyboard hints and a "Nothing matches" state. Searches run on Enter, not as you type.
6
+ *
7
+ * @preview
8
+ *
9
+ * <CommandSearch>
10
+ * <CommandSearchGroup heading="Workspaces">
11
+ * <CommandSearchItem label="Acme Finance" href="/workspaces/finance" />
12
+ * <CommandSearchItem label="Acme HR" href="/workspaces/hr" />
13
+ * </CommandSearchGroup>
14
+ * <CommandSearchGroup heading="Recordings">
15
+ * <CommandSearchItem label="Run the monthly payroll" value="rec-1">
16
+ * <span slot="meta">in Acme HR</span>
17
+ * </CommandSearchItem>
18
+ * </CommandSearchGroup>
19
+ * </CommandSearch>
20
+ *
21
+ * @usage
22
+ *
23
+ * ```astro nocollapse
24
+ * ---
25
+ * import { CommandSearch, CommandSearchGroup, CommandSearchItem } from '@cl-light-ds/ui/components/command-search';
26
+ * ---
27
+ *
28
+ * <!-- mode="filter" (default): your items, filtered in the page when a search runs -->
29
+ * <CommandSearch id="search">
30
+ * <CommandSearchGroup heading="Workspaces">
31
+ * <CommandSearchItem label="Acme Finance" href="/workspaces/finance" />
32
+ * </CommandSearchGroup>
33
+ * </CommandSearch>
34
+ *
35
+ * <!-- mode="remote": you search and hand back the results -->
36
+ * <CommandSearch id="remote-search" mode="remote" />
37
+ * <script>
38
+ * import { setCommandSearchResults } from '@cl-light-ds/ui/lib/command-search';
39
+ * const palette = document.getElementById('remote-search');
40
+ * palette.addEventListener('command-search:search', async (e) => {
41
+ * setCommandSearchResults(palette, await search(e.detail.query), { query: e.detail.query });
42
+ * });
43
+ * </script>
44
+ * ```
45
+ *
46
+ * @api
47
+ *
48
+ * ### Events (on the CommandSearch root, the element with `id`)
49
+ *
50
+ * | Event | Detail | Description |
51
+ * |-------|--------|-------------|
52
+ * | `command-search:search` | `{ query }` | A search ran (Enter or Search). In `remote` mode, answer with `setCommandSearchResults` |
53
+ * | `command-search:results` (in) | `{ groups?, query? }` | Results for `remote` mode; what `setCommandSearchResults` sends |
54
+ * | `command-search:select` | `{ value, label, href, query }` | An item was chosen. Cancelable: `preventDefault()` keeps the palette open and skips `href` |
55
+ * | `dialog:set` (in) / `dialog:change` | `{ open }` | Open or close it; see `Dialog` |
56
+ *
57
+ * ### JS (`@cl-light-ds/ui/lib/command-search`)
58
+ *
59
+ * `openCommandSearch(target?)`, `closeCommandSearch(target?)`, `setCommandSearchResults(target, groups?, { query })`.
60
+ */
61
+ import { CornerDownLeft, Search } from "@lucide/astro";
62
+ import { cn } from "@cl-light-ds/ui/lib/utils";
63
+ import type { HTMLAttributes } from "astro/types";
64
+
65
+ import Button from "../button/Button.astro";
66
+ import Dialog from "../dialog/Dialog.astro";
67
+ import DialogContent from "../dialog/DialogContent.astro";
68
+ import DialogTitle from "../dialog/DialogTitle.astro";
69
+ import DialogTrigger from "../dialog/DialogTrigger.astro";
70
+ import InputGroup from "../input-group/InputGroup.astro";
71
+ import InputGroupAddon from "../input-group/InputGroupAddon.astro";
72
+ import InputGroupButton from "../input-group/InputGroupButton.astro";
73
+ import InputGroupInput from "../input-group/InputGroupInput.astro";
74
+ import Kbd from "../kbd/Kbd.astro";
75
+ import Separator from "../separator/Separator.astro";
76
+ import Spinner from "../spinner/Spinner.astro";
77
+ import nothingMatches from "./nothing-matches.svg";
78
+
79
+ interface Props extends HTMLAttributes<"div"> {
80
+ /**
81
+ * `filter`: the items you put in are filtered in the page (label and
82
+ * keywords). `remote`: you search on `command-search:search` and answer with
83
+ * `setCommandSearchResults`.
84
+ */
85
+ mode?: "filter" | "remote";
86
+ /** In `filter` mode, how long "Searching everything…" shows first (ms). */
87
+ delay?: number;
88
+ /** Open it with ⌘K (Mac) or Ctrl K. Only the first CommandSearch on a page takes the shortcut. */
89
+ shortcut?: boolean;
90
+ /** Render it open. */
91
+ open?: boolean;
92
+ /** Hide the trigger button (open it from your own control or the shortcut). */
93
+ hideTrigger?: boolean;
94
+ triggerLabel?: string;
95
+ /** Accessible name of the trigger. Defaults to `triggerLabel`. */
96
+ triggerAriaLabel?: string;
97
+ /** Visually hidden dialog title. */
98
+ title?: string;
99
+ placeholder?: string;
100
+ searchLabel?: string;
101
+ /** Shown before any search. */
102
+ hint?: string;
103
+ loadingText?: string;
104
+ emptyTitle?: string;
105
+ emptyDescription?: string;
106
+ /** Announced after a search; `{count}` is replaced. */
107
+ resultsAnnouncement?: string;
108
+ /** Classes for the trigger button. */
109
+ triggerClass?: string;
110
+ /** Classes for the dialog panel. */
111
+ contentClass?: string;
112
+ class?: string;
113
+ }
114
+
115
+ const {
116
+ mode = "filter",
117
+ delay = 0,
118
+ shortcut = true,
119
+ open = false,
120
+ hideTrigger = false,
121
+ triggerLabel = "Search everything…",
122
+ triggerAriaLabel,
123
+ title = "Search",
124
+ placeholder = "Search…",
125
+ searchLabel = "Search",
126
+ hint = "Type a search term and press Enter, or click Search.",
127
+ loadingText = "Searching everything…",
128
+ emptyTitle = "Nothing matches",
129
+ emptyDescription = "Check the spelling, or try another word.",
130
+ resultsAnnouncement = "{count} results",
131
+ triggerClass = "",
132
+ contentClass = "",
133
+ class: className = "",
134
+ ...rest
135
+ } = Astro.props as Props;
136
+
137
+ /** Each state's part shows only in that state (`data-status` on the palette). */
138
+ const show = {
139
+ idle: "hidden group-data-[status=idle]/command-search:flex",
140
+ loading: "hidden group-data-[status=loading]/command-search:flex",
141
+ results: "hidden group-data-[status=results]/command-search:flex",
142
+ };
143
+ ---
144
+
145
+ <Dialog
146
+ defaultOpen={open}
147
+ data-command-search
148
+ data-mode={mode}
149
+ data-delay={delay}
150
+ data-shortcut={shortcut ? "true" : "false"}
151
+ data-results-announcement={resultsAnnouncement}
152
+ data-loading-text={loadingText}
153
+ data-empty-title={emptyTitle}
154
+ class={className}
155
+ {...rest}
156
+ >
157
+ {
158
+ !hideTrigger && (
159
+ <DialogTrigger asChild>
160
+ <Button
161
+ variant="outline"
162
+ size="sm"
163
+ class={cn("text-muted-foreground w-56 justify-start", triggerClass)}
164
+ aria-label={triggerAriaLabel ?? triggerLabel}
165
+ aria-keyshortcuts={shortcut ? "Meta+K Control+K" : undefined}
166
+ >
167
+ <Search slot="lead-icon" />
168
+ <span class="flex-1 truncate text-start">{triggerLabel}</span>
169
+ {shortcut && <Kbd data-command-search-shortcut>⌘K</Kbd>}
170
+ </Button>
171
+ </DialogTrigger>
172
+ )
173
+ }
174
+
175
+ <DialogContent
176
+ showCloseButton={false}
177
+ class={cn("gap-0 p-0 sm:max-w-2xl [&>div]:gap-0 [&>div]:p-0", contentClass)}
178
+ >
179
+ <DialogTitle class="sr-only">{title}</DialogTitle>
180
+
181
+ <div
182
+ data-slot="command"
183
+ data-label={title}
184
+ data-status="idle"
185
+ data-should-filter={mode === "filter" ? "true" : "false"}
186
+ data-vim-bindings="false"
187
+ class="group/command-search flex min-w-0 flex-col"
188
+ >
189
+ <div class="p-2">
190
+ {/* D122 — searches on Enter or the Search button, not as you type. */}
191
+ <InputGroup
192
+ size="lg"
193
+ class="has-[[data-slot=command-input]:focus-visible]:border-ring has-[[data-slot=command-input]:focus-visible]:ring-ring/50 border-0 shadow-none has-[[data-slot=command-input]:focus-visible]:ring-3"
194
+ >
195
+ <InputGroupAddon align="inline-start">
196
+ <Search />
197
+ </InputGroupAddon>
198
+ <InputGroupInput
199
+ data-slot="command-input"
200
+ data-input-group-control
201
+ type="search"
202
+ placeholder={placeholder}
203
+ aria-label={placeholder}
204
+ autocomplete="off"
205
+ autofocus
206
+ enterkeyhint="search"
207
+ />
208
+ <InputGroupAddon align="inline-end">
209
+ <InputGroupButton size="sm" variant="secondary" data-command-search-submit>
210
+ {searchLabel}
211
+ </InputGroupButton>
212
+ </InputGroupAddon>
213
+ </InputGroup>
214
+ </div>
215
+
216
+ <Separator />
217
+
218
+ <div class="flex max-h-96 flex-col gap-3 overflow-y-auto p-2">
219
+ {/* D116 — before a search: a hint, no lists. */}
220
+ <p class={cn(show.idle, "text-muted-foreground px-6 py-8 text-center text-sm")}>
221
+ {hint}
222
+ </p>
223
+
224
+ {/* D142 — while a search runs. */}
225
+ <div class={cn(show.loading, "flex-col items-center gap-3 px-6 py-10 text-center")}>
226
+ <Spinner class="size-6" aria-hidden="true" role="presentation" />
227
+ <p class="text-muted-foreground text-sm">{loadingText}</p>
228
+ </div>
229
+
230
+ <div
231
+ data-slot="command-list"
232
+ aria-label={title}
233
+ class={cn(show.results, "flex-col gap-3")}
234
+ >
235
+ <slot />
236
+ {/* D187 — the designer's illustration (184×124). */}
237
+ <div
238
+ data-slot="command-empty"
239
+ hidden
240
+ class="flex flex-col items-center gap-2 px-6 py-10 text-center"
241
+ >
242
+ <img src={nothingMatches.src} alt="" width="184" height="124" class="-my-3" />
243
+ <p class="text-sm font-medium">{emptyTitle}</p>
244
+ <p class="text-muted-foreground text-xs">{emptyDescription}</p>
245
+ </div>
246
+ </div>
247
+ </div>
248
+
249
+ <p class="sr-only" role="status" aria-live="polite" data-command-search-status></p>
250
+
251
+ <Separator />
252
+
253
+ <div class="text-muted-foreground flex flex-wrap items-center gap-4 p-3 text-xs">
254
+ <slot name="footer">
255
+ <span class="flex items-center gap-1"><Kbd>↑</Kbd><Kbd>↓</Kbd> to move</span>
256
+ <span class="flex items-center gap-1">
257
+ <Kbd><CornerDownLeft class="size-3" /></Kbd> to open
258
+ </span>
259
+ <span class="flex items-center gap-1"><Kbd>esc</Kbd> to close</span>
260
+ </slot>
261
+ </div>
262
+ </div>
263
+ </DialogContent>
264
+ </Dialog>
265
+
266
+ <script>
267
+ import { createCommand } from "@data-slot/command";
268
+
269
+ import type { CommandSearchResultGroup } from "../../lib/command-search";
270
+ import {
271
+ commandSearchCountClasses,
272
+ commandSearchGroupClasses,
273
+ commandSearchHeadingClasses,
274
+ commandSearchItemClasses,
275
+ commandSearchLabelClasses,
276
+ commandSearchMetaClasses,
277
+ } from "./classes";
278
+
279
+ const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
280
+ const wait = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms));
281
+ const el = <K extends keyof HTMLElementTagNameMap>(tag: K, className: string, text?: string) => {
282
+ const node = document.createElement(tag);
283
+ node.className = className;
284
+ if (text !== undefined) node.textContent = text;
285
+ return node;
286
+ };
287
+
288
+ const openers: Array<() => void> = [];
289
+
290
+ document.querySelectorAll<HTMLElement>("[data-command-search]").forEach((root) => {
291
+ if (root.dataset.wired === "true") return;
292
+ root.dataset.wired = "true";
293
+
294
+ // Found now: the dialog's content is moved to <body> while open.
295
+ const command = root.querySelector<HTMLElement>('[data-slot="command"]');
296
+ const input = command?.querySelector<HTMLInputElement>('[data-slot="command-input"]');
297
+ const list = command?.querySelector<HTMLElement>('[data-slot="command-list"]');
298
+ const empty = list?.querySelector<HTMLElement>('[data-slot="command-empty"]');
299
+ const submit = command?.querySelector<HTMLElement>("[data-command-search-submit]");
300
+ const live = command?.querySelector<HTMLElement>("[data-command-search-status]");
301
+ if (!command || !input || !list) return;
302
+
303
+ const remote = root.dataset.mode === "remote";
304
+ const delay = Number(root.dataset.delay) || 0;
305
+
306
+ for (const key of root.querySelectorAll<HTMLElement>("[data-command-search-shortcut]"))
307
+ key.textContent = isMac ? "⌘K" : "Ctrl K";
308
+
309
+ /** The last search that ran; typing alone changes nothing (D122). */
310
+ let applied = "";
311
+ let run = 0;
312
+ const setStatus = (status: "idle" | "loading" | "results") => {
313
+ command.dataset.status = status;
314
+ };
315
+ const announce = (text: string) => {
316
+ if (live) live.textContent = text;
317
+ };
318
+ const items = () => [...list.querySelectorAll<HTMLElement>('[data-slot="command-item"]')];
319
+ const shown = () =>
320
+ items().filter((item) => !item.hidden && !item.closest<HTMLElement>('[data-slot="command-group"]')?.hidden);
321
+
322
+ // Match the label and keywords anywhere, case-insensitively, and keep the
323
+ // order the items were given in (every match ranks the same).
324
+ let labels = new Map<string, string>();
325
+ const readLabels = () => {
326
+ labels = new Map(items().map((item) => [item.dataset.value ?? "", item.dataset.label ?? item.textContent ?? ""]));
327
+ };
328
+ const filter = (value: string, search: string, keywords?: string[]) => {
329
+ const q = search.trim().toLowerCase();
330
+ if (!q) return 1;
331
+ const text = (labels.get(value) ?? value).toLowerCase();
332
+ return text.includes(q) || (keywords ?? []).some((k) => k.toLowerCase().includes(q)) ? 1 : 0;
333
+ };
334
+
335
+ // Registered before the runtime binds, so it runs first and keeps the
336
+ // runtime from filtering as you type.
337
+ input.addEventListener("input", (event) => {
338
+ event.stopImmediatePropagation();
339
+ // Emptying the field goes back to the hint at once.
340
+ if (!input.value.trim() && applied) void search();
341
+ });
342
+ // Capture on the palette, also before the runtime's own keydown handler.
343
+ command.addEventListener(
344
+ "keydown",
345
+ (event) => {
346
+ if (event.key !== "Enter" || event.isComposing) return;
347
+ const fromInput = event.target === input;
348
+ // A new term runs the search; Enter again chooses the highlighted item
349
+ // (only once results are showing).
350
+ if (fromInput && input.value.trim() !== applied) {
351
+ event.preventDefault();
352
+ event.stopImmediatePropagation();
353
+ void search();
354
+ } else if (command.dataset.status !== "results") {
355
+ event.preventDefault();
356
+ event.stopImmediatePropagation();
357
+ if (fromInput) void search();
358
+ }
359
+ },
360
+ true,
361
+ );
362
+
363
+ const choose = (value: string) => {
364
+ const item = items().find((node) => node.dataset.value === value);
365
+ const detail = { value, label: item?.dataset.label ?? value, href: item?.dataset.href, query: applied };
366
+ const proceed = root.dispatchEvent(
367
+ new CustomEvent("command-search:select", { detail, cancelable: true }),
368
+ );
369
+ if (!proceed) return;
370
+ root.dispatchEvent(new CustomEvent("dialog:set", { detail: { open: false } }));
371
+ if (detail.href) window.location.assign(detail.href);
372
+ };
373
+
374
+ const controller = createCommand(command, {
375
+ label: command.dataset.label,
376
+ shouldFilter: !remote,
377
+ vimBindings: false,
378
+ filter,
379
+ onSelect: choose,
380
+ });
381
+
382
+ const updateCounts = () => {
383
+ for (const group of list.querySelectorAll<HTMLElement>('[data-slot="command-group"]')) {
384
+ const count = group.querySelector<HTMLElement>('[data-slot="command-search-count"]');
385
+ if (!count || count.dataset.fixed === "true") continue;
386
+ const n = [...group.querySelectorAll<HTMLElement>('[data-slot="command-item"]')].filter((i) => !i.hidden).length;
387
+ if (count.textContent !== String(n)) count.textContent = String(n);
388
+ }
389
+ };
390
+
391
+ const showResults = () => {
392
+ updateCounts();
393
+ const visible = shown();
394
+ if (empty) empty.hidden = visible.length > 0;
395
+ controller.select(visible.find((item) => item.getAttribute("aria-disabled") !== "true")?.dataset.value ?? null);
396
+ setStatus("results");
397
+ const template = root.dataset.resultsAnnouncement ?? "{count} results";
398
+ announce(visible.length ? template.replace("{count}", String(visible.length)) : (root.dataset.emptyTitle ?? ""));
399
+ };
400
+
401
+ const clearRendered = () => {
402
+ for (const node of list.querySelectorAll("[data-command-search-rendered]")) node.remove();
403
+ };
404
+
405
+ const render = (groups: CommandSearchResultGroup[]) => {
406
+ clearRendered();
407
+ for (const group of groups) {
408
+ const wrap = el("div", commandSearchGroupClasses);
409
+ wrap.dataset.slot = "command-group";
410
+ wrap.dataset.commandSearchRendered = "";
411
+ const heading = el("div", commandSearchHeadingClasses, group.heading);
412
+ heading.dataset.slot = "command-group-heading";
413
+ const count = el("span", commandSearchCountClasses, String(group.count ?? group.items.length));
414
+ count.dataset.slot = "command-search-count";
415
+ count.dataset.fixed = "true";
416
+ heading.append(count);
417
+ wrap.append(heading);
418
+ for (const result of group.items) {
419
+ const row = el("div", commandSearchItemClasses);
420
+ row.dataset.slot = "command-item";
421
+ row.dataset.value = result.value;
422
+ row.dataset.label = result.label;
423
+ if (result.href) row.dataset.href = result.href;
424
+ if (result.disabled) row.dataset.disabled = "true";
425
+ row.append(el("span", commandSearchLabelClasses, result.label));
426
+ if (result.description) row.append(el("span", commandSearchMetaClasses, result.description));
427
+ wrap.append(row);
428
+ }
429
+ list.insertBefore(wrap, empty ?? null);
430
+ }
431
+ };
432
+
433
+ /** D142 — a search shows "Searching everything…", then the results. */
434
+ const search = async () => {
435
+ const query = input.value.trim();
436
+ applied = query;
437
+ const id = ++run;
438
+ if (!query) {
439
+ controller.setSearch("");
440
+ if (remote) clearRendered();
441
+ setStatus("idle");
442
+ announce("");
443
+ return;
444
+ }
445
+ setStatus("loading");
446
+ announce(root.dataset.loadingText ?? "");
447
+ root.dispatchEvent(new CustomEvent("command-search:search", { detail: { query } }));
448
+ if (remote) return; // waits for command-search:results
449
+ if (delay) await wait(delay);
450
+ if (id !== run) return; // another search started since
451
+ readLabels();
452
+ controller.setSearch(input.value);
453
+ showResults();
454
+ };
455
+
456
+ root.addEventListener("command-search:results", (event) => {
457
+ if (!remote) return;
458
+ const { groups, query } = (event as CustomEvent<{ groups?: CommandSearchResultGroup[]; query?: string }>).detail ?? {};
459
+ if (query !== undefined && query !== applied) return; // stale
460
+ if (!applied) return;
461
+ if (groups) render(groups);
462
+ // Let the runtime pick up the new rows first.
463
+ requestAnimationFrame(showResults);
464
+ });
465
+
466
+ submit?.addEventListener("click", () => {
467
+ void search();
468
+ input.focus();
469
+ });
470
+
471
+ const reset = () => {
472
+ input.value = "";
473
+ applied = "";
474
+ run += 1;
475
+ controller.setSearch("");
476
+ if (remote) clearRendered();
477
+ setStatus("idle");
478
+ announce("");
479
+ };
480
+
481
+ root.addEventListener("dialog:change", (event) => {
482
+ if (!(event as CustomEvent<{ open: boolean }>).detail.open) return;
483
+ reset();
484
+ // After the dialog's own initial focus.
485
+ requestAnimationFrame(() => input.focus());
486
+ window.setTimeout(() => {
487
+ if (!command.contains(document.activeElement) || document.activeElement === command) input.focus();
488
+ }, 50);
489
+ });
490
+
491
+ if (root.dataset.shortcut === "true") {
492
+ openers.push(() => {
493
+ // Closed, the dialog content (and so the palette) is `hidden`.
494
+ if (!command.closest("[hidden]")) {
495
+ // Already open: back to the field.
496
+ input.focus();
497
+ input.select();
498
+ return;
499
+ }
500
+ root.dispatchEvent(new CustomEvent("dialog:set", { detail: { open: true } }));
501
+ });
502
+ }
503
+ });
504
+
505
+ // D63 — ⌘K on a Mac, Ctrl K elsewhere, opens the first palette that takes the shortcut.
506
+ if (openers.length && !document.documentElement.dataset.commandSearchShortcut) {
507
+ document.documentElement.dataset.commandSearchShortcut = "true";
508
+ document.addEventListener("keydown", (event) => {
509
+ const combo = isMac ? event.metaKey : event.ctrlKey;
510
+ if (!combo || event.altKey || event.shiftKey || event.key.toLowerCase() !== "k") return;
511
+ event.preventDefault();
512
+ openers[0]?.();
513
+ });
514
+ }
515
+ </script>
@@ -0,0 +1,32 @@
1
+ ---
2
+ /**
3
+ * A group of results in a `CommandSearch`: a small caps heading with the
4
+ * number of matches beside it, then its `CommandSearchItem`s. Hidden while
5
+ * none of its items match.
6
+ */
7
+ import { cn } from "@cl-light-ds/ui/lib/utils";
8
+ import type { HTMLAttributes } from "astro/types";
9
+
10
+ import {
11
+ commandSearchCountClasses,
12
+ commandSearchGroupClasses,
13
+ commandSearchHeadingClasses,
14
+ } from "./classes";
15
+
16
+ interface Props extends HTMLAttributes<"div"> {
17
+ heading: string;
18
+ /** Show how many items match beside the heading (D67). */
19
+ showCount?: boolean;
20
+ class?: string;
21
+ }
22
+
23
+ const { heading, showCount = true, class: className = "", ...rest } = Astro.props as Props;
24
+ ---
25
+
26
+ <div data-slot="command-group" class={cn(commandSearchGroupClasses, className)} {...rest}>
27
+ <div data-slot="command-group-heading" class={commandSearchHeadingClasses}>
28
+ {heading}
29
+ {showCount && <span data-slot="command-search-count" class={commandSearchCountClasses}></span>}
30
+ </div>
31
+ <slot />
32
+ </div>
@@ -0,0 +1,64 @@
1
+ ---
2
+ /**
3
+ * One result row in a `CommandSearch`: optional media (an icon or avatar), the
4
+ * label, and optional muted text on the right (`meta` slot, e.g. "in Acme
5
+ * Finance"). Highlighted while active (arrow keys or hover); Enter or a click
6
+ * chooses it.
7
+ *
8
+ * Searching matches `label` and `keywords` (case-insensitive, anywhere in the
9
+ * text), so give every item a `label`. `value` identifies it in
10
+ * `command-search:select`; it defaults to the label, so pass one when two items
11
+ * can share a name.
12
+ */
13
+ import { cn } from "@cl-light-ds/ui/lib/utils";
14
+ import type { HTMLAttributes } from "astro/types";
15
+
16
+ import { commandSearchItemClasses, commandSearchLabelClasses, commandSearchMetaClasses } from "./classes";
17
+
18
+ interface Props extends HTMLAttributes<"div"> {
19
+ /** The text searched and shown (the default slot can replace what is shown). */
20
+ label: string;
21
+ /** Unique value, sent with `command-search:select`. Defaults to `label`. */
22
+ value?: string;
23
+ /** More terms that find this item. */
24
+ keywords?: string[];
25
+ /** Navigate here when chosen. */
26
+ href?: string;
27
+ disabled?: boolean;
28
+ class?: string;
29
+ }
30
+
31
+ const {
32
+ label,
33
+ value = label,
34
+ keywords = [],
35
+ href,
36
+ disabled = false,
37
+ class: className = "",
38
+ ...rest
39
+ } = Astro.props as Props;
40
+
41
+ const hasMedia = Astro.slots.has("media");
42
+ const hasMeta = Astro.slots.has("meta");
43
+ ---
44
+
45
+ <div
46
+ data-slot="command-item"
47
+ data-value={value}
48
+ data-label={label}
49
+ data-keywords={keywords.length ? keywords.join(",") : undefined}
50
+ data-href={href}
51
+ data-disabled={disabled ? "true" : undefined}
52
+ class={cn(commandSearchItemClasses, className)}
53
+ {...rest}
54
+ >
55
+ {hasMedia && <slot name="media" />}
56
+ <span class={commandSearchLabelClasses}><slot>{label}</slot></span>
57
+ {
58
+ hasMeta && (
59
+ <span class={commandSearchMetaClasses}>
60
+ <slot name="meta" />
61
+ </span>
62
+ )
63
+ }
64
+ </div>
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Class strings shared by the Astro parts and the results the script renders
3
+ * from `setCommandSearchResults()`, so both look the same.
4
+ */
5
+ export const commandSearchGroupClasses = "flex flex-col gap-1";
6
+
7
+ export const commandSearchHeadingClasses =
8
+ "text-muted-foreground flex items-baseline gap-2 px-2 pt-1 text-xs font-semibold tracking-wide uppercase";
9
+
10
+ export const commandSearchCountClasses = "font-normal tabular-nums";
11
+
12
+ export const commandSearchItemClasses =
13
+ "hover:bg-background-hover data-[selected=true]:bg-background-hover aria-disabled:pointer-events-none aria-disabled:opacity-50 flex cursor-pointer items-center gap-3 rounded-md px-2 py-2 text-sm transition-colors outline-none";
14
+
15
+ export const commandSearchLabelClasses = "min-w-0 flex-1 truncate";
16
+
17
+ export const commandSearchMetaClasses =
18
+ "text-muted-foreground flex max-w-56 min-w-0 shrink-0 items-center gap-1.5 truncate text-xs";
@@ -0,0 +1,12 @@
1
+ export { default as CommandSearch } from "./CommandSearch.astro";
2
+ export { default as CommandSearchGroup } from "./CommandSearchGroup.astro";
3
+ export { default as CommandSearchItem } from "./CommandSearchItem.astro";
4
+ export {
5
+ openCommandSearch,
6
+ closeCommandSearch,
7
+ setCommandSearchResults,
8
+ } from "../../lib/command-search";
9
+ export type {
10
+ CommandSearchResultGroup,
11
+ CommandSearchResultItem,
12
+ } from "../../lib/command-search";