@charcuterie/ui 4.25.0 → 4.26.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 (48) hide show
  1. package/README.md +13 -0
  2. package/dist/CommandPalette/CommandPalette.d.ts +43 -0
  3. package/dist/CommandPalette/CommandPalette.d.ts.map +1 -0
  4. package/dist/CommandPalette/CommandPalette.js +53 -0
  5. package/dist/CommandPalette/CommandPalette.js.map +1 -0
  6. package/dist/CommandPalette/CommandPaletteFooter.d.ts +16 -0
  7. package/dist/CommandPalette/CommandPaletteFooter.d.ts.map +1 -0
  8. package/dist/CommandPalette/CommandPaletteFooter.js +14 -0
  9. package/dist/CommandPalette/CommandPaletteFooter.js.map +1 -0
  10. package/dist/CommandPalette/CommandPaletteOption.d.ts +44 -0
  11. package/dist/CommandPalette/CommandPaletteOption.d.ts.map +1 -0
  12. package/dist/CommandPalette/CommandPaletteOption.js +72 -0
  13. package/dist/CommandPalette/CommandPaletteOption.js.map +1 -0
  14. package/dist/CommandPalette/CommandPalettePanel.d.ts +52 -0
  15. package/dist/CommandPalette/CommandPalettePanel.d.ts.map +1 -0
  16. package/dist/CommandPalette/CommandPalettePanel.js +168 -0
  17. package/dist/CommandPalette/CommandPalettePanel.js.map +1 -0
  18. package/dist/CommandPalette/commandPaletteSections.d.ts +93 -0
  19. package/dist/CommandPalette/commandPaletteSections.d.ts.map +1 -0
  20. package/dist/CommandPalette/commandPaletteSections.js +94 -0
  21. package/dist/CommandPalette/commandPaletteSections.js.map +1 -0
  22. package/dist/CommandPalette/useCommandPalette.d.ts +32 -0
  23. package/dist/CommandPalette/useCommandPalette.d.ts.map +1 -0
  24. package/dist/CommandPalette/useCommandPalette.js +40 -0
  25. package/dist/CommandPalette/useCommandPalette.js.map +1 -0
  26. package/dist/CommandPalette/useCommandPaletteHotkeys.d.ts +35 -0
  27. package/dist/CommandPalette/useCommandPaletteHotkeys.d.ts.map +1 -0
  28. package/dist/CommandPalette/useCommandPaletteHotkeys.js +114 -0
  29. package/dist/CommandPalette/useCommandPaletteHotkeys.js.map +1 -0
  30. package/dist/index.d.ts +7 -0
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +3 -0
  33. package/dist/index.js.map +1 -1
  34. package/package.json +2 -2
  35. package/src/CommandPalette/CommandPalette.mdx +129 -0
  36. package/src/CommandPalette/CommandPalette.stories.tsx +413 -0
  37. package/src/CommandPalette/CommandPalette.test.tsx +553 -0
  38. package/src/CommandPalette/CommandPalette.tsx +91 -0
  39. package/src/CommandPalette/CommandPaletteFooter.tsx +45 -0
  40. package/src/CommandPalette/CommandPaletteOption.tsx +200 -0
  41. package/src/CommandPalette/CommandPalettePanel.tsx +411 -0
  42. package/src/CommandPalette/commandPalette.storyHelpers.tsx +248 -0
  43. package/src/CommandPalette/commandPaletteSections.test.ts +156 -0
  44. package/src/CommandPalette/commandPaletteSections.ts +219 -0
  45. package/src/CommandPalette/useCommandPalette.ts +75 -0
  46. package/src/CommandPalette/useCommandPaletteHotkeys.ts +158 -0
  47. package/src/index.ts +14 -0
  48. package/src/sourceRules.test.ts +2 -1
@@ -0,0 +1,553 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import {
3
+ expect,
4
+ userEvent,
5
+ waitFor,
6
+ within,
7
+ } from "storybook/test"
8
+ import { afterEach, test, vi } from "vitest"
9
+
10
+ import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
11
+ import { mountStory } from "../mountStory.testHelpers.ts"
12
+ import { expectAgentDrivable } from "../testing/index.ts"
13
+ import * as stories from "./CommandPalette.stories.tsx"
14
+
15
+ const {
16
+ AllStates,
17
+ Interactive,
18
+ OpenFiltered,
19
+ OpenPrefixCommand,
20
+ Playground,
21
+ Responsive,
22
+ } = composeStories(stories)
23
+
24
+ const PALETTE_NAME = "Search apps, bookmarks, and the web"
25
+
26
+ type Queries = ReturnType<typeof within>
27
+
28
+ const findPalette = (body: Queries) =>
29
+ waitFor(() =>
30
+ expectAgentDrivable(body, {
31
+ name: PALETTE_NAME,
32
+ role: "dialog",
33
+ }),
34
+ )
35
+
36
+ const getField = (palette: HTMLElement) =>
37
+ within(palette).getByRole("combobox", {
38
+ name: PALETTE_NAME,
39
+ })
40
+
41
+ const getOptionNames = (palette: HTMLElement) =>
42
+ within(palette)
43
+ .queryAllByRole("option")
44
+ .map((option) => option.getAttribute("id") ?? "")
45
+ .map(
46
+ (id) =>
47
+ document
48
+ .getElementById(id)
49
+ ?.querySelector("span:not([aria-hidden]) > span")
50
+ ?.textContent ?? "",
51
+ )
52
+
53
+ const getActiveOption = (palette: HTMLElement) => {
54
+ const id = getField(palette).getAttribute(
55
+ "aria-activedescendant",
56
+ )
57
+
58
+ return id === null ? null : document.getElementById(id)
59
+ }
60
+
61
+ const getGroupNames = (palette: HTMLElement) =>
62
+ within(palette)
63
+ .queryAllByRole("group")
64
+ .map((group) => {
65
+ const headingId =
66
+ group.getAttribute("aria-labelledby") ?? ""
67
+
68
+ return document.getElementById(headingId)?.textContent
69
+ })
70
+
71
+ afterEach(() => {
72
+ vi.restoreAllMocks()
73
+ })
74
+
75
+ test("it opens listing the suggestions before anything is typed", async () => {
76
+ const { body } = await mountStory(Responsive)
77
+
78
+ const palette = await findPalette(body)
79
+ const field = getField(palette)
80
+
81
+ await waitFor(() => {
82
+ expect(field).toHaveFocus()
83
+ })
84
+
85
+ await expect(field).toHaveValue("")
86
+ await expect(field).toHaveAttribute(
87
+ "aria-expanded",
88
+ "true",
89
+ )
90
+ await expect(field).toHaveAttribute(
91
+ "aria-autocomplete",
92
+ "list",
93
+ )
94
+
95
+ const listbox = within(palette).getByRole("listbox", {
96
+ name: PALETTE_NAME,
97
+ })
98
+
99
+ await expect(field).toHaveAttribute(
100
+ "aria-controls",
101
+ listbox.id,
102
+ )
103
+
104
+ // The owner's complaint about the old launcher was exactly the
105
+ // opposite: "nothing appears when you press it."
106
+ await expect(getGroupNames(palette)).toStrictEqual([
107
+ "Pinned",
108
+ "Recently opened",
109
+ ])
110
+ await expect(getOptionNames(palette)).toStrictEqual([
111
+ "Home Automation",
112
+ "Media Server",
113
+ "Task Board",
114
+ "Weather Forecast",
115
+ "Storage Server",
116
+ "Photo Library",
117
+ ])
118
+
119
+ // The first row is active, and the field says so.
120
+ const active = getActiveOption(palette)
121
+
122
+ await expect(active).toHaveAttribute(
123
+ "aria-selected",
124
+ "true",
125
+ )
126
+ await expect(active).toHaveTextContent("Home Automation")
127
+
128
+ await expectNoAxeViolations(palette)
129
+ })
130
+
131
+ test("typing filters by word prefix, groups the matches, and ends with the fallback", async () => {
132
+ const { body } = await mountStory(Responsive)
133
+
134
+ const palette = await findPalette(body)
135
+
136
+ await userEvent.type(getField(palette), "serv")
137
+
138
+ await waitFor(() => {
139
+ expect(getGroupNames(palette)).toStrictEqual([
140
+ "Apps",
141
+ "The web",
142
+ ])
143
+ })
144
+
145
+ // `Media Server` and `Storage Server` match on their second word;
146
+ // the higher weight comes first. `Reverse Proxy Manager` has `serv`
147
+ // inside a word and does not match.
148
+ await expect(getOptionNames(palette)).toStrictEqual([
149
+ "Media Server",
150
+ "Storage Server",
151
+ "Search the web for “serv”",
152
+ ])
153
+
154
+ const marks = palette.querySelectorAll("mark")
155
+
156
+ await expect(
157
+ [...marks].map((mark) => mark.textContent),
158
+ ).toStrictEqual(["Serv", "Serv"])
159
+
160
+ await expectNoAxeViolations(palette)
161
+ })
162
+
163
+ test("initials, folder names and keywords all match", async () => {
164
+ const { body } = await mountStory(Responsive)
165
+
166
+ const palette = await findPalette(body)
167
+ const field = getField(palette)
168
+
169
+ await userEvent.type(field, "ha")
170
+
171
+ await waitFor(() => {
172
+ expect(getOptionNames(palette)[0]).toBe(
173
+ "Home Automation",
174
+ )
175
+ })
176
+
177
+ await userEvent.clear(field)
178
+ await userEvent.type(field, "remotes")
179
+
180
+ // Folder members: the folder is the description, so the row still
181
+ // says where the item lives.
182
+ await waitFor(() => {
183
+ expect(getOptionNames(palette)).toStrictEqual([
184
+ "Living Room Remote",
185
+ "Bedroom Remote",
186
+ "Search the web for “remotes”",
187
+ ])
188
+ })
189
+
190
+ await expect(
191
+ within(palette).getByRole("option", {
192
+ name: /Living Room Remote Remotes/,
193
+ }),
194
+ ).toBeInTheDocument()
195
+
196
+ await userEvent.clear(field)
197
+ await userEvent.type(field, "parcels")
198
+
199
+ await waitFor(() => {
200
+ expect(getOptionNames(palette)[0]).toBe(
201
+ "Package Tracking",
202
+ )
203
+ })
204
+ })
205
+
206
+ test("a prefix command lists one row that sends the rest of the query to its target", async () => {
207
+ const { body } = await mountStory(OpenPrefixCommand)
208
+
209
+ const palette = await findPalette(body)
210
+
211
+ await expect(getField(palette)).toHaveValue("au lego")
212
+ await expect(getGroupNames(palette)).toStrictEqual([
213
+ "Search a site",
214
+ ])
215
+
216
+ const row = within(palette).getByRole("option", {
217
+ name: "Search Auction Site for “lego”",
218
+ })
219
+
220
+ await expect(row).toHaveAttribute(
221
+ "href",
222
+ "https://auction.example.com/search?q=lego",
223
+ )
224
+ await expect(row).toHaveAttribute("aria-selected", "true")
225
+
226
+ await expectNoAxeViolations(palette)
227
+ })
228
+
229
+ test("the arrow keys, Home and End move the active row, and the pointer does too", async () => {
230
+ const { body } = await mountStory(Responsive)
231
+
232
+ const palette = await findPalette(body)
233
+ const options = within(palette).getAllByRole("option")
234
+ const last = options.length - 1
235
+
236
+ await userEvent.keyboard("{ArrowUp}")
237
+ await expect(getActiveOption(palette)).toBe(options[0])
238
+
239
+ await userEvent.keyboard("{ArrowDown}{ArrowDown}")
240
+ await expect(getActiveOption(palette)).toBe(options[2])
241
+ await expect(options[2]).toHaveAttribute(
242
+ "aria-selected",
243
+ "true",
244
+ )
245
+ await expect(options[0]).toHaveAttribute(
246
+ "aria-selected",
247
+ "false",
248
+ )
249
+
250
+ await userEvent.keyboard("{End}")
251
+ await expect(getActiveOption(palette)).toBe(options[last])
252
+
253
+ await userEvent.keyboard("{ArrowDown}")
254
+ await expect(getActiveOption(palette)).toBe(options[last])
255
+
256
+ await userEvent.keyboard("{Home}")
257
+ await expect(getActiveOption(palette)).toBe(options[0])
258
+
259
+ const pointedAt = options[3]
260
+
261
+ if (pointedAt === undefined) {
262
+ throw new Error("The fixture has fewer than four rows")
263
+ }
264
+
265
+ await userEvent.hover(pointedAt)
266
+ await expect(getActiveOption(palette)).toBe(pointedAt)
267
+
268
+ // Focus never left the field.
269
+ await expect(getField(palette)).toHaveFocus()
270
+ })
271
+
272
+ test("Enter opens a link row the way a click would, and the palette closes", async () => {
273
+ const { body } = await mountStory(OpenFiltered)
274
+
275
+ const palette = await findPalette(body)
276
+
277
+ const clickedHrefs: string[] = []
278
+
279
+ // The rows link to `example.com`; keep the test page where it is.
280
+ const stopNavigation = (event: MouseEvent) => {
281
+ const anchor = (event.target as Element).closest("a")
282
+
283
+ if (anchor !== null) {
284
+ clickedHrefs.push(anchor.href)
285
+ event.preventDefault()
286
+ }
287
+ }
288
+
289
+ document.addEventListener("click", stopNavigation, true)
290
+
291
+ try {
292
+ await waitFor(() => {
293
+ expect(getField(palette)).toHaveFocus()
294
+ })
295
+
296
+ await userEvent.keyboard("{ArrowDown}{Enter}")
297
+
298
+ await expect(clickedHrefs).toStrictEqual([
299
+ "https://storage.example.com/",
300
+ ])
301
+
302
+ await waitFor(() => {
303
+ expect(body.queryByRole("dialog")).toBeNull()
304
+ })
305
+ } finally {
306
+ document.removeEventListener(
307
+ "click",
308
+ stopNavigation,
309
+ true,
310
+ )
311
+ }
312
+ })
313
+
314
+ test("Ctrl Enter opens a link row in a new tab", async () => {
315
+ const openSpy = vi
316
+ .spyOn(window, "open")
317
+ .mockImplementation(() => null)
318
+
319
+ const { body } = await mountStory(OpenPrefixCommand)
320
+
321
+ const palette = await findPalette(body)
322
+
323
+ await waitFor(() => {
324
+ expect(getField(palette)).toHaveFocus()
325
+ })
326
+
327
+ await userEvent.keyboard("{Control>}{Enter}{/Control}")
328
+
329
+ await expect(openSpy).toHaveBeenCalledWith(
330
+ "https://auction.example.com/search?q=lego",
331
+ "_blank",
332
+ "noopener,noreferrer",
333
+ )
334
+
335
+ await waitFor(() => {
336
+ expect(body.queryByRole("dialog")).toBeNull()
337
+ })
338
+ })
339
+
340
+ test("Enter runs an onSelect row, closes, and gives focus back", async () => {
341
+ const { body, canvas } = await mountStory(Interactive)
342
+
343
+ const trigger = expectAgentDrivable(canvas, {
344
+ name: "Search",
345
+ role: "button",
346
+ })
347
+
348
+ await userEvent.click(trigger)
349
+
350
+ const palette = await findPalette(body)
351
+
352
+ await waitFor(() => {
353
+ expect(getField(palette)).toHaveFocus()
354
+ })
355
+
356
+ await userEvent.keyboard("{ArrowDown}{Enter}")
357
+
358
+ await waitFor(() => {
359
+ expect(body.queryByRole("dialog")).toBeNull()
360
+ })
361
+
362
+ await expect(
363
+ canvas.getByText("Last chosen: Media Server"),
364
+ ).toBeInTheDocument()
365
+
366
+ await waitFor(() => {
367
+ expect(trigger).toHaveFocus()
368
+ })
369
+ })
370
+
371
+ test("a click on a row chooses it", async () => {
372
+ const { body, canvas } = await mountStory(Interactive)
373
+
374
+ await userEvent.click(
375
+ canvas.getByRole("button", { name: "Search" }),
376
+ )
377
+
378
+ const palette = await findPalette(body)
379
+
380
+ await userEvent.click(
381
+ within(palette).getByRole("option", {
382
+ name: /^Task Board/,
383
+ }),
384
+ )
385
+
386
+ await waitFor(() => {
387
+ expect(body.queryByRole("dialog")).toBeNull()
388
+ })
389
+
390
+ await expect(
391
+ canvas.getByText("Last chosen: Task Board"),
392
+ ).toBeInTheDocument()
393
+ })
394
+
395
+ test("Ctrl K, Ctrl Space and / open it empty; Escape closes it", async () => {
396
+ const { body } = await mountStory(Interactive)
397
+
398
+ for (const chord of [
399
+ "{Control>}k{/Control}",
400
+ "{Control>} {/Control}",
401
+ "/",
402
+ ]) {
403
+ await userEvent.keyboard(chord)
404
+
405
+ const palette = await findPalette(body)
406
+
407
+ await waitFor(() => {
408
+ expect(getField(palette)).toHaveFocus()
409
+ })
410
+
411
+ // The key that opened it typed nothing.
412
+ await expect(getField(palette)).toHaveValue("")
413
+
414
+ await userEvent.keyboard("{Escape}")
415
+
416
+ await waitFor(() => {
417
+ expect(body.queryByRole("dialog")).toBeNull()
418
+ })
419
+ }
420
+ })
421
+
422
+ test("typing a letter on the page opens it with that letter as the query", async () => {
423
+ const { body } = await mountStory(Interactive)
424
+
425
+ await userEvent.keyboard("m")
426
+
427
+ const palette = await findPalette(body)
428
+ const field = getField(palette)
429
+
430
+ await waitFor(() => {
431
+ expect(field).toHaveFocus()
432
+ })
433
+
434
+ await expect(field).toHaveValue("m")
435
+
436
+ // The caret is after the letter, so the next key extends the query.
437
+ await userEvent.keyboard("edia")
438
+
439
+ await expect(field).toHaveValue("media")
440
+
441
+ await waitFor(() => {
442
+ expect(getOptionNames(palette)[0]).toBe("Media Server")
443
+ })
444
+ })
445
+
446
+ test("a text field on the page keeps its keys", async () => {
447
+ const { body, canvas } = await mountStory(Interactive)
448
+
449
+ const note = canvas.getByRole("textbox", {
450
+ name: "A note on the page",
451
+ })
452
+
453
+ await userEvent.click(note)
454
+ await userEvent.keyboard("m/")
455
+
456
+ await expect(note).toHaveValue("m/")
457
+ await expect(body.queryByRole("dialog")).toBeNull()
458
+
459
+ // The chord still works from inside it: it types nothing.
460
+ await userEvent.keyboard("{Control>}k{/Control}")
461
+
462
+ await findPalette(body)
463
+ await expect(note).toHaveValue("m/")
464
+ })
465
+
466
+ test("the clear button empties the query and keeps focus in the field", async () => {
467
+ const { body, canvas } = await mountStory(Interactive)
468
+
469
+ await userEvent.click(
470
+ canvas.getByRole("button", { name: "Search" }),
471
+ )
472
+
473
+ const palette = await findPalette(body)
474
+ const field = getField(palette)
475
+
476
+ await userEvent.type(field, "zebra")
477
+
478
+ await userEvent.click(
479
+ within(palette).getByRole("button", {
480
+ name: "Clear search",
481
+ }),
482
+ )
483
+
484
+ await expect(field).toHaveValue("")
485
+ await expect(field).toHaveFocus()
486
+ await expect(getGroupNames(palette)).toStrictEqual([
487
+ "Pinned",
488
+ "Recently opened",
489
+ ])
490
+ })
491
+
492
+ test("a query with no match and no fallback says so, and has no listbox", async () => {
493
+ const { canvas } = await mountStory(AllStates)
494
+
495
+ const field = canvas.getByRole("combobox", {
496
+ name: "No matches",
497
+ })
498
+
499
+ await expect(field).toHaveAttribute(
500
+ "aria-expanded",
501
+ "false",
502
+ )
503
+ await expect(field).not.toHaveAttribute("aria-controls")
504
+
505
+ const panel = field.closest(".flex-col") as HTMLElement
506
+
507
+ await expect(
508
+ within(panel).getByText("No matches"),
509
+ ).toBeInTheDocument()
510
+ await expect(
511
+ within(panel).queryByRole("listbox"),
512
+ ).toBeNull()
513
+ })
514
+
515
+ test("it hangs from the top on a wide window and fills the Narrow View", async () => {
516
+ const { body } = await mountStory(Responsive)
517
+
518
+ const palette = await findPalette(body)
519
+ const box = palette.getBoundingClientRect()
520
+ const { innerHeight, innerWidth } = window
521
+
522
+ // The `sm` breakpoint, 40rem: the Narrow View.
523
+ if (innerWidth < 640) {
524
+ await expect(box.width).toBeGreaterThanOrEqual(
525
+ innerWidth - 20,
526
+ )
527
+ await expect(box.height).toBeGreaterThanOrEqual(
528
+ innerHeight * 0.9,
529
+ )
530
+
531
+ return
532
+ }
533
+
534
+ await expect(box.width).toBeLessThanOrEqual(720 + 1)
535
+ await expect(box.width).toBeGreaterThan(
536
+ Math.min(innerWidth - 40, 720) - 1,
537
+ )
538
+ await expect(box.top).toBeCloseTo(innerHeight * 0.12, -1)
539
+ await expect(box.bottom).toBeLessThanOrEqual(innerHeight)
540
+ })
541
+
542
+ test("the playground opens from its button", async () => {
543
+ const { body, canvas } = await mountStory(Playground)
544
+
545
+ await userEvent.click(
546
+ expectAgentDrivable(canvas, {
547
+ name: "Search",
548
+ role: "button",
549
+ }),
550
+ )
551
+
552
+ await findPalette(body)
553
+ })
@@ -0,0 +1,91 @@
1
+ import type { ReactNode } from "react"
2
+ import { useRef } from "react"
3
+
4
+ import { Modal } from "../Modal/Modal.tsx"
5
+ import { toClassName } from "../toClassName.ts"
6
+ import {
7
+ CommandPalettePanel,
8
+ type CommandPalettePanelProps,
9
+ } from "./CommandPalettePanel.tsx"
10
+
11
+ export type CommandPaletteProps = Omit<
12
+ CommandPalettePanelProps,
13
+ "inputRef" | "onChoose"
14
+ > & {
15
+ className?: string
16
+ isVisible: boolean
17
+ /** Every route out: Escape, a press outside, and a chosen row. */
18
+ onClose: () => void
19
+ }
20
+
21
+ /**
22
+ * Top-anchored on a wide window, so the list grows downward from a
23
+ * fixed field instead of re-centering as it fills; nearly the whole
24
+ * screen in the Narrow View, where a phone keyboard takes the bottom.
25
+ *
26
+ * `self-start` because `OverlayPanel` centers its child in a flex
27
+ * box. `dvh` for the reason `Dialog` gives: on a phone `vh` includes
28
+ * the address bar.
29
+ */
30
+ const PALETTE_BOX_CLASS =
31
+ "mt-[12dvh] max-h-[76dvh] w-[calc(100vw-2rem)] max-w-[45rem] self-start max-sm:mt-2 max-sm:h-[calc(100dvh-1rem)] max-sm:max-h-[calc(100dvh-1rem)] max-sm:w-[calc(100vw-1rem)]"
32
+
33
+ /**
34
+ * The launcher: a search field over a grouped list, opened over the
35
+ * page.
36
+ *
37
+ * - **It shows something before anything is typed** — the caller's
38
+ * `suggestions` ("Pinned", "Recently opened").
39
+ * - **Typing filters `items` by word prefix**: every typed word must
40
+ * start a word of the title, the description or a keyword, and the
41
+ * initials count (`ha` → `Home Automation`). Matches are grouped by
42
+ * each item's `group`.
43
+ * - **A prefix command** — `au lego` — routes the rest of the query
44
+ * to one of the caller's `prefixes`.
45
+ * - **The last row is the caller's fallback** (`getFallbackItem`), so
46
+ * a query nothing matches still goes somewhere.
47
+ *
48
+ * The library owns the shape. Every name, link and target is data the
49
+ * app passes in.
50
+ *
51
+ * ### Open state
52
+ *
53
+ * `isVisible` and `onClose`, as on `Modal`: the app holds the state,
54
+ * usually in `useCommandPalette()`, which also wires the global keys.
55
+ * The query is controlled (`query` + `onQueryChange`) or uncontrolled
56
+ * (`defaultQuery`, read each time it opens), as on `Combobox`.
57
+ *
58
+ * ### Keys
59
+ *
60
+ * Arrow keys, Home and End move the active row; Enter opens it;
61
+ * `Ctrl Enter` or `⌘ Enter` opens an `href` row in a new tab; Escape
62
+ * closes. Focus stays in the field the whole time and the active row
63
+ * is its `aria-activedescendant`. Focus is trapped while it is open and
64
+ * goes back to where it was when it closes.
65
+ */
66
+ export const CommandPalette = ({
67
+ className,
68
+ isVisible,
69
+ label = "Search",
70
+ onClose,
71
+ ...panelProps
72
+ }: CommandPaletteProps): ReactNode => {
73
+ const inputRef = useRef<HTMLInputElement>(null)
74
+
75
+ return (
76
+ <Modal
77
+ aria-label={label}
78
+ className={toClassName(PALETTE_BOX_CLASS, className)}
79
+ initialFocus={inputRef}
80
+ isVisible={isVisible}
81
+ onClose={onClose}
82
+ >
83
+ <CommandPalettePanel
84
+ {...panelProps}
85
+ inputRef={inputRef}
86
+ label={label}
87
+ onChoose={onClose}
88
+ />
89
+ </Modal>
90
+ )
91
+ }
@@ -0,0 +1,45 @@
1
+ import type { ReactNode } from "react"
2
+
3
+ import type { CommandPalettePrefix } from "./commandPaletteSections.ts"
4
+
5
+ const renderKey = (name: string): ReactNode => (
6
+ <kbd className="rounded-sm border border-border-default bg-surface-raised px-1 font-mono text-content-secondary text-xs">
7
+ {name}
8
+ </kbd>
9
+ )
10
+
11
+ /**
12
+ * The default strip under the list: the keys, and each prefix with
13
+ * its target.
14
+ *
15
+ * The keys are words — `Up`, `Down` — not arrow glyphs, because a font
16
+ * without the glyph paints nothing
17
+ * (`2026-07-29-ship-no-icons-and-no-symbol-glyphs.md`). In the Narrow
18
+ * View the key hints give way, since a phone has no arrow keys, and
19
+ * the prefixes stay: they work from a phone keyboard too.
20
+ */
21
+ export const CommandPaletteFooter = ({
22
+ prefixes,
23
+ }: {
24
+ prefixes: readonly CommandPalettePrefix[]
25
+ }): ReactNode => (
26
+ <>
27
+ <span className="max-sm:hidden">
28
+ {renderKey("Up")} {renderKey("Down")} move
29
+ </span>
30
+ <span className="max-sm:hidden">
31
+ {renderKey("Enter")} open
32
+ </span>
33
+ <span className="max-sm:hidden">
34
+ {renderKey("Ctrl")} {renderKey("Enter")} new tab
35
+ </span>
36
+ {prefixes.map(({ label, prefix }) => (
37
+ <span key={prefix}>
38
+ {renderKey(prefix)} {label}
39
+ </span>
40
+ ))}
41
+ <span className="max-sm:hidden">
42
+ {renderKey("Esc")} close
43
+ </span>
44
+ </>
45
+ )