@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.
- package/package.json +19 -17
- package/src/components/accordion/AccordionTrigger.astro +5 -1
- package/src/components/accordion/CHANGELOG.md +9 -0
- package/src/components/alert/Alert.astro +42 -3
- package/src/components/alert/CHANGELOG.md +19 -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/collapsible/CHANGELOG.md +9 -0
- package/src/components/collapsible/CollapsibleTrigger.astro +5 -1
- package/src/components/combobox/CHANGELOG.md +27 -0
- package/src/components/combobox/Combobox.astro +18 -1
- package/src/components/combobox/ComboboxTrigger.astro +32 -2
- package/src/components/command-search/CHANGELOG.md +57 -0
- package/src/components/command-search/CommandSearch.astro +594 -0
- package/src/components/command-search/CommandSearchGroup.astro +43 -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 +50 -0
- package/src/components/dialog/Dialog.astro +14 -3
- package/src/components/dialog/DialogClose.astro +28 -12
- package/src/components/dialog/DialogContent.astro +19 -1
- package/src/components/dialog/DialogTrigger.astro +5 -1
- package/src/components/dropdown-menu/CHANGELOG.md +32 -0
- package/src/components/dropdown-menu/DropdownMenu.astro +13 -3
- package/src/components/dropdown-menu/DropdownMenuRadioGroup.astro +17 -2
- package/src/components/dropdown-menu/DropdownMenuTrigger.astro +7 -1
- 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/field/CHANGELOG.md +18 -0
- package/src/components/field/Field.astro +18 -0
- package/src/components/field/FieldError.astro +13 -1
- package/src/components/field/FieldGroup.astro +12 -3
- package/src/components/hover-card/CHANGELOG.md +9 -0
- package/src/components/hover-card/HoverCardTrigger.astro +5 -1
- 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 +29 -0
- package/src/components/notifications-menu/NotificationsMenu.astro +375 -0
- package/src/components/notifications-menu/NotificationsMenuItem.astro +130 -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 +26 -0
- package/src/components/option-card/OptionCard.astro +143 -0
- package/src/components/option-card/index.ts +1 -0
- package/src/components/organisms/top-bar/CHANGELOG.md +27 -0
- package/src/components/organisms/top-bar/TopBar.astro +257 -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 +80 -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/popover/CHANGELOG.md +14 -0
- package/src/components/popover/PopoverClose.astro +2 -1
- package/src/components/popover/PopoverTrigger.astro +7 -1
- 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/select/CHANGELOG.md +8 -0
- package/src/components/select/SelectTrigger.astro +1 -1
- package/src/components/table/CHANGELOG.md +15 -0
- package/src/components/table/TableCell.astro +6 -0
- package/src/components/table/TableEditableCell.astro +2 -2
- package/src/components/table/TableHead.astro +5 -1
- package/src/components/table/TablePagination.astro +4 -4
- package/src/components/table/TableRow.astro +3 -1
- package/src/components/table/table-cell-variants.ts +2 -0
- package/src/components/tabs/CHANGELOG.md +16 -0
- package/src/components/tabs/Tabs.astro +26 -0
- package/src/components/tabs/TabsList.astro +30 -3
- 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/components/toggle-group/CHANGELOG.md +11 -0
- package/src/components/toggle-group/ToggleGroup.astro +19 -2
- package/src/components/toggle-group/ToggleGroupItem.astro +10 -0
- package/src/components/tooltip/CHANGELOG.md +26 -0
- package/src/components/tooltip/Tooltip.astro +31 -3
- package/src/components/tooltip/TooltipTrigger.astro +5 -1
- package/src/lib/command-search.ts +78 -0
- package/src/lib/dialog-focus.ts +73 -0
- package/src/lib/dropdown-menu-radio-groups.ts +79 -0
- package/src/lib/loading-overlay.ts +174 -0
- package/src/lib/scroll-lock.ts +97 -0
- package/src/lib/toast.ts +127 -0
- package/src/lib/toggle-shared.ts +9 -1
- package/src/lib/workspace-palette.ts +119 -0
- package/src/styles/CHANGELOG.md +35 -0
- 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>
|