@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.
- package/package.json +19 -17
- package/src/components/alert/Alert.astro +8 -2
- package/src/components/alert/CHANGELOG.md +9 -0
- package/src/components/button/Button.astro +78 -19
- package/src/components/button/CHANGELOG.md +22 -0
- package/src/components/button-group/ButtonGroup.astro +14 -3
- package/src/components/button-group/CHANGELOG.md +17 -0
- package/src/components/card/CHANGELOG.md +9 -0
- package/src/components/card/Card.astro +22 -0
- package/src/components/combobox/CHANGELOG.md +14 -0
- package/src/components/combobox/ComboboxTrigger.astro +31 -1
- package/src/components/command-search/CHANGELOG.md +27 -0
- package/src/components/command-search/CommandSearch.astro +515 -0
- package/src/components/command-search/CommandSearchGroup.astro +32 -0
- package/src/components/command-search/CommandSearchItem.astro +64 -0
- package/src/components/command-search/classes.ts +18 -0
- package/src/components/command-search/index.ts +12 -0
- package/src/components/command-search/nothing-matches.svg +42 -0
- package/src/components/dialog/CHANGELOG.md +13 -0
- package/src/components/dialog/DialogClose.astro +26 -12
- package/src/components/dropdown-menu/CHANGELOG.md +7 -0
- package/src/components/dropdown-menu/dropdown-menu-item-variants.ts +1 -1
- package/src/components/empty-state/CHANGELOG.md +18 -0
- package/src/components/empty-state/EmptyState.astro +157 -0
- package/src/components/empty-state/illustrations/AllCaughtUp.astro +52 -0
- package/src/components/empty-state/illustrations/EmptyWorkspaces.astro +56 -0
- package/src/components/empty-state/illustrations/LoadError.astro +61 -0
- package/src/components/empty-state/illustrations/NoResults.astro +60 -0
- package/src/components/empty-state/illustrations/NothingMatches.astro +66 -0
- package/src/components/empty-state/illustrations/index.ts +27 -0
- package/src/components/empty-state/index.ts +7 -0
- package/src/components/favorite-toggle/CHANGELOG.md +13 -0
- package/src/components/favorite-toggle/FavoriteToggle.astro +94 -0
- package/src/components/favorite-toggle/index.ts +1 -0
- package/src/components/input-group/CHANGELOG.md +7 -0
- package/src/components/input-group/InputGroup.astro +2 -0
- package/src/components/loading-overlay/CHANGELOG.md +25 -0
- package/src/components/loading-overlay/LoadingOverlay.astro +179 -0
- package/src/components/loading-overlay/index.ts +8 -0
- package/src/components/notifications-menu/CHANGELOG.md +22 -0
- package/src/components/notifications-menu/NotificationsMenu.astro +374 -0
- package/src/components/notifications-menu/NotificationsMenuItem.astro +129 -0
- package/src/components/notifications-menu/all-caught-up.svg +28 -0
- package/src/components/notifications-menu/index.ts +3 -0
- package/src/components/notifications-menu/types.ts +32 -0
- package/src/components/option-card/CHANGELOG.md +16 -0
- package/src/components/option-card/OptionCard.astro +135 -0
- package/src/components/option-card/index.ts +1 -0
- package/src/components/organisms/top-bar/CHANGELOG.md +21 -0
- package/src/components/organisms/top-bar/TopBar.astro +264 -0
- package/src/components/organisms/top-bar/index.ts +2 -0
- package/src/components/organisms/top-bar/types.ts +30 -0
- package/src/components/organisms/workspace/CHANGELOG.md +73 -0
- package/src/components/organisms/workspace/OrganisationOverview.astro +62 -0
- package/src/components/organisms/workspace/RecentWorkspaceCard.astro +235 -0
- package/src/components/organisms/workspace/RecentWorkspaces.astro +93 -0
- package/src/components/organisms/workspace/WorkspaceAvatar.astro +144 -0
- package/src/components/organisms/workspace/WorkspaceCard.astro +172 -0
- package/src/components/organisms/workspace/WorkspaceGlyph.astro +62 -0
- package/src/components/organisms/workspace/WorkspaceIcon.astro +42 -0
- package/src/components/organisms/workspace/WorkspaceIconPicker.astro +288 -0
- package/src/components/organisms/workspace/WorkspaceIconPickerGlyph.astro +44 -0
- package/src/components/organisms/workspace/icons.ts +98 -0
- package/src/components/organisms/workspace/index.ts +35 -0
- package/src/components/organisms/workspace/labels.ts +54 -0
- package/src/components/organisms/workspace/recent-card-rings-strong.png +0 -0
- package/src/components/organisms/workspace/types.ts +30 -0
- package/src/components/profile-menu/CHANGELOG.md +22 -0
- package/src/components/profile-menu/ProfileMenu.astro +236 -0
- package/src/components/profile-menu/index.ts +2 -0
- package/src/components/profile-menu/types.ts +27 -0
- package/src/components/search-field/CHANGELOG.md +14 -0
- package/src/components/search-field/SearchField.astro +163 -0
- package/src/components/search-field/index.ts +1 -0
- package/src/components/table/CHANGELOG.md +9 -0
- package/src/components/table/TableCell.astro +6 -0
- package/src/components/table/TableHead.astro +5 -1
- package/src/components/table/TableRow.astro +3 -1
- package/src/components/table/table-cell-variants.ts +2 -0
- package/src/components/toast/CHANGELOG.md +32 -0
- package/src/components/toast/Toast.astro +145 -0
- package/src/components/toast/Toaster.astro +322 -0
- package/src/components/toast/index.ts +9 -0
- package/src/components/toggle/CHANGELOG.md +7 -0
- package/src/lib/command-search.ts +73 -0
- package/src/lib/loading-overlay.ts +174 -0
- package/src/lib/toast.ts +127 -0
- package/src/lib/toggle-shared.ts +4 -1
- package/src/lib/workspace-palette.ts +119 -0
- package/src/styles/CHANGELOG.md +22 -0
- 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";
|