@cl-light-ds/ui 0.4.0 → 0.6.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 (127) hide show
  1. package/package.json +19 -17
  2. package/src/components/accordion/AccordionTrigger.astro +5 -1
  3. package/src/components/accordion/CHANGELOG.md +9 -0
  4. package/src/components/alert/Alert.astro +42 -3
  5. package/src/components/alert/CHANGELOG.md +19 -0
  6. package/src/components/button/Button.astro +78 -19
  7. package/src/components/button/CHANGELOG.md +22 -0
  8. package/src/components/button-group/ButtonGroup.astro +14 -3
  9. package/src/components/button-group/CHANGELOG.md +17 -0
  10. package/src/components/card/CHANGELOG.md +9 -0
  11. package/src/components/card/Card.astro +22 -0
  12. package/src/components/collapsible/CHANGELOG.md +9 -0
  13. package/src/components/collapsible/CollapsibleTrigger.astro +5 -1
  14. package/src/components/combobox/CHANGELOG.md +27 -0
  15. package/src/components/combobox/Combobox.astro +18 -1
  16. package/src/components/combobox/ComboboxTrigger.astro +32 -2
  17. package/src/components/command-search/CHANGELOG.md +57 -0
  18. package/src/components/command-search/CommandSearch.astro +594 -0
  19. package/src/components/command-search/CommandSearchGroup.astro +43 -0
  20. package/src/components/command-search/CommandSearchItem.astro +64 -0
  21. package/src/components/command-search/classes.ts +18 -0
  22. package/src/components/command-search/index.ts +12 -0
  23. package/src/components/command-search/nothing-matches.svg +42 -0
  24. package/src/components/dialog/CHANGELOG.md +50 -0
  25. package/src/components/dialog/Dialog.astro +14 -3
  26. package/src/components/dialog/DialogClose.astro +28 -12
  27. package/src/components/dialog/DialogContent.astro +19 -1
  28. package/src/components/dialog/DialogTrigger.astro +5 -1
  29. package/src/components/dropdown-menu/CHANGELOG.md +32 -0
  30. package/src/components/dropdown-menu/DropdownMenu.astro +13 -3
  31. package/src/components/dropdown-menu/DropdownMenuRadioGroup.astro +17 -2
  32. package/src/components/dropdown-menu/DropdownMenuTrigger.astro +7 -1
  33. package/src/components/dropdown-menu/dropdown-menu-item-variants.ts +1 -1
  34. package/src/components/empty-state/CHANGELOG.md +18 -0
  35. package/src/components/empty-state/EmptyState.astro +157 -0
  36. package/src/components/empty-state/illustrations/AllCaughtUp.astro +52 -0
  37. package/src/components/empty-state/illustrations/EmptyWorkspaces.astro +56 -0
  38. package/src/components/empty-state/illustrations/LoadError.astro +61 -0
  39. package/src/components/empty-state/illustrations/NoResults.astro +60 -0
  40. package/src/components/empty-state/illustrations/NothingMatches.astro +66 -0
  41. package/src/components/empty-state/illustrations/index.ts +27 -0
  42. package/src/components/empty-state/index.ts +7 -0
  43. package/src/components/favorite-toggle/CHANGELOG.md +13 -0
  44. package/src/components/favorite-toggle/FavoriteToggle.astro +94 -0
  45. package/src/components/favorite-toggle/index.ts +1 -0
  46. package/src/components/field/CHANGELOG.md +18 -0
  47. package/src/components/field/Field.astro +18 -0
  48. package/src/components/field/FieldError.astro +13 -1
  49. package/src/components/field/FieldGroup.astro +12 -3
  50. package/src/components/hover-card/CHANGELOG.md +9 -0
  51. package/src/components/hover-card/HoverCardTrigger.astro +5 -1
  52. package/src/components/input-group/CHANGELOG.md +7 -0
  53. package/src/components/input-group/InputGroup.astro +2 -0
  54. package/src/components/loading-overlay/CHANGELOG.md +25 -0
  55. package/src/components/loading-overlay/LoadingOverlay.astro +179 -0
  56. package/src/components/loading-overlay/index.ts +8 -0
  57. package/src/components/notifications-menu/CHANGELOG.md +29 -0
  58. package/src/components/notifications-menu/NotificationsMenu.astro +375 -0
  59. package/src/components/notifications-menu/NotificationsMenuItem.astro +130 -0
  60. package/src/components/notifications-menu/all-caught-up.svg +28 -0
  61. package/src/components/notifications-menu/index.ts +3 -0
  62. package/src/components/notifications-menu/types.ts +32 -0
  63. package/src/components/option-card/CHANGELOG.md +26 -0
  64. package/src/components/option-card/OptionCard.astro +143 -0
  65. package/src/components/option-card/index.ts +1 -0
  66. package/src/components/organisms/top-bar/CHANGELOG.md +27 -0
  67. package/src/components/organisms/top-bar/TopBar.astro +257 -0
  68. package/src/components/organisms/top-bar/index.ts +2 -0
  69. package/src/components/organisms/top-bar/types.ts +30 -0
  70. package/src/components/organisms/workspace/CHANGELOG.md +80 -0
  71. package/src/components/organisms/workspace/OrganisationOverview.astro +62 -0
  72. package/src/components/organisms/workspace/RecentWorkspaceCard.astro +235 -0
  73. package/src/components/organisms/workspace/RecentWorkspaces.astro +93 -0
  74. package/src/components/organisms/workspace/WorkspaceAvatar.astro +144 -0
  75. package/src/components/organisms/workspace/WorkspaceCard.astro +172 -0
  76. package/src/components/organisms/workspace/WorkspaceGlyph.astro +62 -0
  77. package/src/components/organisms/workspace/WorkspaceIcon.astro +42 -0
  78. package/src/components/organisms/workspace/WorkspaceIconPicker.astro +288 -0
  79. package/src/components/organisms/workspace/WorkspaceIconPickerGlyph.astro +44 -0
  80. package/src/components/organisms/workspace/icons.ts +98 -0
  81. package/src/components/organisms/workspace/index.ts +35 -0
  82. package/src/components/organisms/workspace/labels.ts +54 -0
  83. package/src/components/organisms/workspace/recent-card-rings-strong.png +0 -0
  84. package/src/components/organisms/workspace/types.ts +30 -0
  85. package/src/components/popover/CHANGELOG.md +14 -0
  86. package/src/components/popover/PopoverClose.astro +2 -1
  87. package/src/components/popover/PopoverTrigger.astro +7 -1
  88. package/src/components/profile-menu/CHANGELOG.md +22 -0
  89. package/src/components/profile-menu/ProfileMenu.astro +236 -0
  90. package/src/components/profile-menu/index.ts +2 -0
  91. package/src/components/profile-menu/types.ts +27 -0
  92. package/src/components/search-field/CHANGELOG.md +14 -0
  93. package/src/components/search-field/SearchField.astro +163 -0
  94. package/src/components/search-field/index.ts +1 -0
  95. package/src/components/select/CHANGELOG.md +8 -0
  96. package/src/components/select/SelectTrigger.astro +1 -1
  97. package/src/components/table/CHANGELOG.md +15 -0
  98. package/src/components/table/TableCell.astro +6 -0
  99. package/src/components/table/TableEditableCell.astro +2 -2
  100. package/src/components/table/TableHead.astro +5 -1
  101. package/src/components/table/TablePagination.astro +4 -4
  102. package/src/components/table/TableRow.astro +3 -1
  103. package/src/components/table/table-cell-variants.ts +2 -0
  104. package/src/components/tabs/CHANGELOG.md +16 -0
  105. package/src/components/tabs/Tabs.astro +26 -0
  106. package/src/components/tabs/TabsList.astro +30 -3
  107. package/src/components/toast/CHANGELOG.md +32 -0
  108. package/src/components/toast/Toast.astro +145 -0
  109. package/src/components/toast/Toaster.astro +322 -0
  110. package/src/components/toast/index.ts +9 -0
  111. package/src/components/toggle/CHANGELOG.md +7 -0
  112. package/src/components/toggle-group/CHANGELOG.md +11 -0
  113. package/src/components/toggle-group/ToggleGroup.astro +19 -2
  114. package/src/components/toggle-group/ToggleGroupItem.astro +10 -0
  115. package/src/components/tooltip/CHANGELOG.md +26 -0
  116. package/src/components/tooltip/Tooltip.astro +31 -3
  117. package/src/components/tooltip/TooltipTrigger.astro +5 -1
  118. package/src/lib/command-search.ts +78 -0
  119. package/src/lib/dialog-focus.ts +73 -0
  120. package/src/lib/dropdown-menu-radio-groups.ts +79 -0
  121. package/src/lib/loading-overlay.ts +174 -0
  122. package/src/lib/scroll-lock.ts +97 -0
  123. package/src/lib/toast.ts +127 -0
  124. package/src/lib/toggle-shared.ts +9 -1
  125. package/src/lib/workspace-palette.ts +119 -0
  126. package/src/styles/CHANGELOG.md +35 -0
  127. package/src/styles/globals.css +109 -0
@@ -0,0 +1,594 @@
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 the number of matches (all of them, not only the rows a `limit` shows). */
107
+ resultsAnnouncement?: string;
108
+ /**
109
+ * Show at most this many matches in each group (D67: 5); the count beside
110
+ * the heading still says how many match. A group's own `limit` wins. No
111
+ * limit when unset or 0.
112
+ */
113
+ limit?: number;
114
+ /** Classes for the trigger button. */
115
+ triggerClass?: string;
116
+ /** Classes for the dialog panel. */
117
+ contentClass?: string;
118
+ class?: string;
119
+ }
120
+
121
+ const {
122
+ mode = "filter",
123
+ delay = 0,
124
+ shortcut = true,
125
+ open = false,
126
+ hideTrigger = false,
127
+ triggerLabel = "Search everything…",
128
+ triggerAriaLabel,
129
+ title = "Search",
130
+ placeholder = "Search…",
131
+ searchLabel = "Search",
132
+ hint = "Type a search term and press Enter, or click Search.",
133
+ loadingText = "Searching everything…",
134
+ emptyTitle = "Nothing matches",
135
+ emptyDescription = "Check the spelling, or try another word.",
136
+ resultsAnnouncement = "{count} results",
137
+ limit,
138
+ triggerClass = "",
139
+ contentClass = "",
140
+ class: className = "",
141
+ ...rest
142
+ } = Astro.props as Props;
143
+
144
+ /** Each state's part shows only in that state (`data-status` on the palette). */
145
+ const show = {
146
+ idle: "hidden group-data-[status=idle]/command-search:flex",
147
+ loading: "hidden group-data-[status=loading]/command-search:flex",
148
+ results: "hidden group-data-[status=results]/command-search:flex",
149
+ };
150
+ ---
151
+
152
+ <Dialog
153
+ defaultOpen={open}
154
+ data-command-search
155
+ data-mode={mode}
156
+ data-delay={delay}
157
+ data-shortcut={shortcut ? "true" : "false"}
158
+ data-results-announcement={resultsAnnouncement}
159
+ data-limit={limit && limit > 0 ? limit : undefined}
160
+ data-loading-text={loadingText}
161
+ data-empty-title={emptyTitle}
162
+ class={className}
163
+ {...rest}
164
+ >
165
+ {
166
+ !hideTrigger && (
167
+ <DialogTrigger asChild>
168
+ <Button
169
+ type="button"
170
+ variant="outline"
171
+ size="sm"
172
+ class={cn("text-muted-foreground w-56 justify-start", triggerClass)}
173
+ aria-label={triggerAriaLabel ?? triggerLabel}
174
+ aria-keyshortcuts={shortcut ? "Meta+K Control+K" : undefined}
175
+ >
176
+ <Search slot="lead-icon" />
177
+ <span class="flex-1 truncate text-start">{triggerLabel}</span>
178
+ {shortcut && <Kbd data-command-search-shortcut>⌘K</Kbd>}
179
+ </Button>
180
+ </DialogTrigger>
181
+ )
182
+ }
183
+
184
+ <DialogContent
185
+ showCloseButton={false}
186
+ class={cn("gap-0 p-0 sm:max-w-2xl [&>div]:gap-0 [&>div]:p-0", contentClass)}
187
+ >
188
+ <DialogTitle class="sr-only">{title}</DialogTitle>
189
+
190
+ <div
191
+ data-slot="command"
192
+ data-label={title}
193
+ data-status="idle"
194
+ data-should-filter={mode === "filter" ? "true" : "false"}
195
+ data-vim-bindings="false"
196
+ class="group/command-search flex min-w-0 flex-col"
197
+ >
198
+ <div class="p-2">
199
+ {/* D122 — searches on Enter or the Search button, not as you type. */}
200
+ <InputGroup
201
+ size="lg"
202
+ 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"
203
+ >
204
+ <InputGroupAddon align="inline-start">
205
+ <Search />
206
+ </InputGroupAddon>
207
+ <InputGroupInput
208
+ data-slot="command-input"
209
+ data-input-group-control
210
+ type="search"
211
+ placeholder={placeholder}
212
+ aria-label={placeholder}
213
+ autocomplete="off"
214
+ autofocus
215
+ enterkeyhint="search"
216
+ />
217
+ <InputGroupAddon align="inline-end">
218
+ <InputGroupButton size="sm" variant="secondary" data-command-search-submit>
219
+ {searchLabel}
220
+ </InputGroupButton>
221
+ </InputGroupAddon>
222
+ </InputGroup>
223
+ </div>
224
+
225
+ <Separator />
226
+
227
+ <div class="flex max-h-96 flex-col gap-3 overflow-y-auto p-2">
228
+ {/* D116 — before a search: a hint, no lists. */}
229
+ <p class={cn(show.idle, "text-muted-foreground px-6 py-8 text-center text-sm")}>
230
+ {hint}
231
+ </p>
232
+
233
+ {/* D142 — while a search runs. */}
234
+ <div class={cn(show.loading, "flex-col items-center gap-3 px-6 py-10 text-center")}>
235
+ <Spinner class="size-6" aria-hidden="true" role="presentation" />
236
+ <p class="text-muted-foreground text-sm">{loadingText}</p>
237
+ </div>
238
+
239
+ <div
240
+ data-slot="command-list"
241
+ aria-label={title}
242
+ class={cn(show.results, "flex-col gap-3")}
243
+ >
244
+ <slot />
245
+ {/* D187 — the designer's illustration (184×124). */}
246
+ <div
247
+ data-slot="command-empty"
248
+ hidden
249
+ class="flex flex-col items-center gap-2 px-6 py-10 text-center"
250
+ >
251
+ <img src={nothingMatches.src} alt="" width="184" height="124" class="-my-3" />
252
+ <p class="text-sm font-medium">{emptyTitle}</p>
253
+ <p class="text-muted-foreground text-xs">{emptyDescription}</p>
254
+ </div>
255
+ </div>
256
+ </div>
257
+
258
+ <p class="sr-only" role="status" aria-live="polite" data-command-search-status></p>
259
+
260
+ <Separator />
261
+
262
+ <div class="text-muted-foreground flex flex-wrap items-center gap-4 p-3 text-xs">
263
+ <slot name="footer">
264
+ <span class="flex items-center gap-1"><Kbd>↑</Kbd><Kbd>↓</Kbd> to move</span>
265
+ <span class="flex items-center gap-1">
266
+ <Kbd><CornerDownLeft class="size-3" /></Kbd> to open
267
+ </span>
268
+ <span class="flex items-center gap-1"><Kbd>esc</Kbd> to close</span>
269
+ </slot>
270
+ </div>
271
+ </div>
272
+ </DialogContent>
273
+ </Dialog>
274
+
275
+ <script>
276
+ import { createCommand } from "@data-slot/command";
277
+
278
+ import type { CommandSearchResultGroup } from "../../lib/command-search";
279
+ import {
280
+ commandSearchCountClasses,
281
+ commandSearchGroupClasses,
282
+ commandSearchHeadingClasses,
283
+ commandSearchItemClasses,
284
+ commandSearchLabelClasses,
285
+ commandSearchMetaClasses,
286
+ } from "./classes";
287
+
288
+ const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
289
+ const wait = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms));
290
+ const el = <K extends keyof HTMLElementTagNameMap>(tag: K, className: string, text?: string) => {
291
+ const node = document.createElement(tag);
292
+ node.className = className;
293
+ if (text !== undefined) node.textContent = text;
294
+ return node;
295
+ };
296
+
297
+ const openers: Array<() => void> = [];
298
+
299
+ document.querySelectorAll<HTMLElement>("[data-command-search]").forEach((root) => {
300
+ if (root.dataset.wired === "true") return;
301
+ root.dataset.wired = "true";
302
+
303
+ // Found now: the dialog's content is moved to <body> while open.
304
+ const command = root.querySelector<HTMLElement>('[data-slot="command"]');
305
+ const input = command?.querySelector<HTMLInputElement>('[data-slot="command-input"]');
306
+ const list = command?.querySelector<HTMLElement>('[data-slot="command-list"]');
307
+ const empty = list?.querySelector<HTMLElement>('[data-slot="command-empty"]');
308
+ const submit = command?.querySelector<HTMLElement>("[data-command-search-submit]");
309
+ const live = command?.querySelector<HTMLElement>("[data-command-search-status]");
310
+ if (!command || !input || !list) return;
311
+
312
+ const remote = root.dataset.mode === "remote";
313
+ const delay = Number(root.dataset.delay) || 0;
314
+ const defaultLimit = Number(root.dataset.limit) || 0;
315
+
316
+ for (const key of root.querySelectorAll<HTMLElement>("[data-command-search-shortcut]"))
317
+ key.textContent = isMac ? "⌘K" : "Ctrl K";
318
+
319
+ /** The last search that ran; typing alone changes nothing (D122). */
320
+ let applied = "";
321
+ let run = 0;
322
+ const setStatus = (status: "idle" | "loading" | "results") => {
323
+ command.dataset.status = status;
324
+ };
325
+ const announce = (text: string) => {
326
+ if (live) live.textContent = text;
327
+ };
328
+ const items = () => [...list.querySelectorAll<HTMLElement>('[data-slot="command-item"]')];
329
+ const shown = () =>
330
+ items().filter((item) => !item.hidden && !item.closest<HTMLElement>('[data-slot="command-group"]')?.hidden);
331
+
332
+ // Match the label and keywords anywhere, case-insensitively, and keep the
333
+ // order the items were given in (every match ranks the same).
334
+ let labels = new Map<string, string>();
335
+ const readLabels = () => {
336
+ labels = new Map(items().map((item) => [item.dataset.value ?? "", item.dataset.label ?? item.textContent ?? ""]));
337
+ };
338
+ const filter = (value: string, search: string, keywords?: string[]) => {
339
+ const q = search.trim().toLowerCase();
340
+ if (!q) return 1;
341
+ // Rows added since the search ran (the runtime re-filters them) aren't read yet.
342
+ if (!labels.has(value)) readLabels();
343
+ const text = (labels.get(value) ?? value).toLowerCase();
344
+ return text.includes(q) || (keywords ?? []).some((k) => k.toLowerCase().includes(q)) ? 1 : 0;
345
+ };
346
+
347
+ // Registered before the runtime binds, so it runs first and keeps the
348
+ // runtime from filtering as you type.
349
+ input.addEventListener("input", (event) => {
350
+ event.stopImmediatePropagation();
351
+ // Emptying the field goes back to the hint at once.
352
+ if (!input.value.trim() && applied) void search();
353
+ });
354
+ // Capture on the palette, also before the runtime's own keydown handler.
355
+ command.addEventListener(
356
+ "keydown",
357
+ (event) => {
358
+ if (event.key !== "Enter" || event.isComposing) return;
359
+ const fromInput = event.target === input;
360
+ // A new term runs the search; Enter again chooses the highlighted item
361
+ // (only once results are showing).
362
+ if (fromInput && input.value.trim() !== applied) {
363
+ event.preventDefault();
364
+ event.stopImmediatePropagation();
365
+ void search();
366
+ } else if (command.dataset.status !== "results") {
367
+ event.preventDefault();
368
+ event.stopImmediatePropagation();
369
+ if (fromInput) void search();
370
+ }
371
+ },
372
+ true,
373
+ );
374
+
375
+ const choose = (value: string) => {
376
+ const item = items().find((node) => node.dataset.value === value);
377
+ const detail = { value, label: item?.dataset.label ?? value, href: item?.dataset.href, query: applied };
378
+ const proceed = root.dispatchEvent(
379
+ new CustomEvent("command-search:select", { detail, cancelable: true }),
380
+ );
381
+ if (!proceed) return;
382
+ root.dispatchEvent(new CustomEvent("dialog:set", { detail: { open: false } }));
383
+ if (detail.href) window.location.assign(detail.href);
384
+ };
385
+
386
+ const controller = createCommand(command, {
387
+ label: command.dataset.label,
388
+ shouldFilter: !remote,
389
+ vimBindings: false,
390
+ filter,
391
+ onSelect: choose,
392
+ });
393
+
394
+ // Per-group limit (D67, C30). The runtime decides what matches and sets
395
+ // `hidden` on every item each time it filters, which happens again
396
+ // whenever the list's content changes (its own MutationObserver), so a
397
+ // cap applied once would be undone. Instead the cap is applied after
398
+ // every filter pass: an observer on `hidden` re-applies it (it only
399
+ // writes what differs, so it settles at once). Capped items are `hidden`,
400
+ // so arrow keys, hover and Enter skip them like any non-match.
401
+ const groups = () => [...list.querySelectorAll<HTMLElement>('[data-slot="command-group"]')];
402
+ const groupItems = (group: HTMLElement) =>
403
+ [...group.querySelectorAll<HTMLElement>('[data-slot="command-item"]')];
404
+ const limitOf = (group: HTMLElement) => {
405
+ const own = group.dataset.limit;
406
+ return own !== undefined && own !== "" ? Number(own) || 0 : defaultLimit;
407
+ };
408
+ const keywordsOf = (item: HTMLElement) =>
409
+ (item.dataset.keywords ?? "").split(/[,\n]/).map((k) => k.trim()).filter(Boolean);
410
+ /** Whether the runtime shows this item for the current search (same rule it uses). */
411
+ const matches = (item: HTMLElement) => {
412
+ if (remote || item.dataset.forceMount === "true") return true;
413
+ const query = controller.search;
414
+ if (!query.trim()) return true;
415
+ return filter(item.dataset.value ?? item.dataset.label ?? "", query, keywordsOf(item)) > 0;
416
+ };
417
+ const applyLimit = () => {
418
+ for (const group of groups()) {
419
+ const limit = limitOf(group);
420
+ let shownCount = 0;
421
+ for (const item of groupItems(group)) {
422
+ const over = item.hasAttribute("data-over-limit");
423
+ if (!matches(item)) {
424
+ if (over) item.removeAttribute("data-over-limit"); // the runtime hides it
425
+ continue;
426
+ }
427
+ shownCount += 1;
428
+ if (limit > 0 && shownCount > limit) {
429
+ if (!over) item.setAttribute("data-over-limit", "");
430
+ if (!item.hidden) item.hidden = true;
431
+ } else if (over) {
432
+ item.removeAttribute("data-over-limit");
433
+ item.hidden = false;
434
+ }
435
+ }
436
+ }
437
+ // Keep the highlight on something you can see.
438
+ const active = controller.value;
439
+ const visible = shown();
440
+ if (active !== null && !visible.some((item) => item.dataset.value === active))
441
+ controller.select(visible.find((item) => item.getAttribute("aria-disabled") !== "true")?.dataset.value ?? null);
442
+ };
443
+
444
+ const updateCounts = () => {
445
+ for (const group of groups()) {
446
+ const count = group.querySelector<HTMLElement>('[data-slot="command-search-count"]');
447
+ if (!count || count.dataset.fixed === "true") continue;
448
+ // Every match, including those past the limit.
449
+ const n = groupItems(group).filter(matches).length;
450
+ if (count.textContent !== String(n)) count.textContent = String(n);
451
+ }
452
+ };
453
+
454
+ new MutationObserver(() => {
455
+ if (command.dataset.status !== "results") return;
456
+ applyLimit();
457
+ updateCounts();
458
+ }).observe(list, { subtree: true, attributes: true, attributeFilter: ["hidden"] });
459
+
460
+ const showResults = () => {
461
+ applyLimit();
462
+ updateCounts();
463
+ const visible = shown();
464
+ if (empty) empty.hidden = visible.length > 0;
465
+ controller.select(visible.find((item) => item.getAttribute("aria-disabled") !== "true")?.dataset.value ?? null);
466
+ setStatus("results");
467
+ // Announce every match, not just the rows a per-group limit leaves showing:
468
+ // the sum of the group counts (which include a remote search's own counts).
469
+ const counted = groups().reduce((sum, group) => {
470
+ const label = group.querySelector('[data-slot="command-search-count"]')?.textContent;
471
+ const n = label ? Number(label) : NaN;
472
+ return sum + (Number.isFinite(n) ? n : groupItems(group).filter(matches).length);
473
+ }, 0);
474
+ const total = Math.max(counted, visible.length);
475
+ const template = root.dataset.resultsAnnouncement ?? "{count} results";
476
+ announce(visible.length ? template.replace("{count}", String(total)) : (root.dataset.emptyTitle ?? ""));
477
+ };
478
+
479
+ const clearRendered = () => {
480
+ for (const node of list.querySelectorAll("[data-command-search-rendered]")) node.remove();
481
+ };
482
+
483
+ const render = (groups: CommandSearchResultGroup[]) => {
484
+ clearRendered();
485
+ for (const group of groups) {
486
+ const wrap = el("div", commandSearchGroupClasses);
487
+ wrap.dataset.slot = "command-group";
488
+ wrap.dataset.commandSearchRendered = "";
489
+ if (group.limit !== undefined) wrap.dataset.limit = String(group.limit);
490
+ const heading = el("div", commandSearchHeadingClasses, group.heading);
491
+ heading.dataset.slot = "command-group-heading";
492
+ const count = el("span", commandSearchCountClasses, String(group.count ?? group.items.length));
493
+ count.dataset.slot = "command-search-count";
494
+ count.dataset.fixed = "true";
495
+ heading.append(count);
496
+ wrap.append(heading);
497
+ for (const result of group.items) {
498
+ const row = el("div", commandSearchItemClasses);
499
+ row.dataset.slot = "command-item";
500
+ row.dataset.value = result.value;
501
+ row.dataset.label = result.label;
502
+ if (result.href) row.dataset.href = result.href;
503
+ if (result.disabled) row.dataset.disabled = "true";
504
+ row.append(el("span", commandSearchLabelClasses, result.label));
505
+ if (result.description) row.append(el("span", commandSearchMetaClasses, result.description));
506
+ wrap.append(row);
507
+ }
508
+ list.insertBefore(wrap, empty ?? null);
509
+ }
510
+ };
511
+
512
+ /** D142 — a search shows "Searching everything…", then the results. */
513
+ const search = async () => {
514
+ const query = input.value.trim();
515
+ applied = query;
516
+ const id = ++run;
517
+ if (!query) {
518
+ controller.setSearch("");
519
+ if (remote) clearRendered();
520
+ setStatus("idle");
521
+ announce("");
522
+ return;
523
+ }
524
+ setStatus("loading");
525
+ announce(root.dataset.loadingText ?? "");
526
+ root.dispatchEvent(new CustomEvent("command-search:search", { detail: { query } }));
527
+ if (remote) return; // waits for command-search:results
528
+ if (delay) await wait(delay);
529
+ if (id !== run) return; // another search started since
530
+ readLabels();
531
+ controller.setSearch(input.value);
532
+ showResults();
533
+ };
534
+
535
+ root.addEventListener("command-search:results", (event) => {
536
+ if (!remote) return;
537
+ const { groups, query } = (event as CustomEvent<{ groups?: CommandSearchResultGroup[]; query?: string }>).detail ?? {};
538
+ if (query !== undefined && query !== applied) return; // stale
539
+ if (!applied) return;
540
+ if (groups) render(groups);
541
+ // Let the runtime pick up the new rows first.
542
+ requestAnimationFrame(showResults);
543
+ });
544
+
545
+ submit?.addEventListener("click", () => {
546
+ void search();
547
+ input.focus();
548
+ });
549
+
550
+ const reset = () => {
551
+ input.value = "";
552
+ applied = "";
553
+ run += 1;
554
+ controller.setSearch("");
555
+ if (remote) clearRendered();
556
+ setStatus("idle");
557
+ announce("");
558
+ };
559
+
560
+ root.addEventListener("dialog:change", (event) => {
561
+ if (!(event as CustomEvent<{ open: boolean }>).detail.open) return;
562
+ reset();
563
+ // After the dialog's own initial focus.
564
+ requestAnimationFrame(() => input.focus());
565
+ window.setTimeout(() => {
566
+ if (!command.contains(document.activeElement) || document.activeElement === command) input.focus();
567
+ }, 50);
568
+ });
569
+
570
+ if (root.dataset.shortcut === "true") {
571
+ openers.push(() => {
572
+ // Closed, the dialog content (and so the palette) is `hidden`.
573
+ if (!command.closest("[hidden]")) {
574
+ // Already open: back to the field.
575
+ input.focus();
576
+ input.select();
577
+ return;
578
+ }
579
+ root.dispatchEvent(new CustomEvent("dialog:set", { detail: { open: true } }));
580
+ });
581
+ }
582
+ });
583
+
584
+ // D63 — ⌘K on a Mac, Ctrl K elsewhere, opens the first palette that takes the shortcut.
585
+ if (openers.length && !document.documentElement.dataset.commandSearchShortcut) {
586
+ document.documentElement.dataset.commandSearchShortcut = "true";
587
+ document.addEventListener("keydown", (event) => {
588
+ const combo = isMac ? event.metaKey : event.ctrlKey;
589
+ if (!combo || event.altKey || event.shiftKey || event.key.toLowerCase() !== "k") return;
590
+ event.preventDefault();
591
+ openers[0]?.();
592
+ });
593
+ }
594
+ </script>
@@ -0,0 +1,43 @@
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. With `limit`, only the first matches show; the
6
+ * count beside the heading still counts them all.
7
+ */
8
+ import { cn } from "@cl-light-ds/ui/lib/utils";
9
+ import type { HTMLAttributes } from "astro/types";
10
+
11
+ import {
12
+ commandSearchCountClasses,
13
+ commandSearchGroupClasses,
14
+ commandSearchHeadingClasses,
15
+ } from "./classes";
16
+
17
+ interface Props extends HTMLAttributes<"div"> {
18
+ heading: string;
19
+ /** Show how many items match beside the heading (D67). */
20
+ showCount?: boolean;
21
+ /**
22
+ * Show at most this many matches (D67: 5); the count still says how many
23
+ * match. Overrides `CommandSearch`'s `limit`; 0 for no limit.
24
+ */
25
+ limit?: number;
26
+ class?: string;
27
+ }
28
+
29
+ const { heading, showCount = true, limit, class: className = "", ...rest } = Astro.props as Props;
30
+ ---
31
+
32
+ <div
33
+ data-slot="command-group"
34
+ data-limit={limit !== undefined ? limit : undefined}
35
+ class={cn(commandSearchGroupClasses, className)}
36
+ {...rest}
37
+ >
38
+ <div data-slot="command-group-heading" class={commandSearchHeadingClasses}>
39
+ {heading}
40
+ {showCount && <span data-slot="command-search-count" class={commandSearchCountClasses}></span>}
41
+ </div>
42
+ <slot />
43
+ </div>