@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.
- package/README.md +13 -0
- package/dist/CommandPalette/CommandPalette.d.ts +43 -0
- package/dist/CommandPalette/CommandPalette.d.ts.map +1 -0
- package/dist/CommandPalette/CommandPalette.js +53 -0
- package/dist/CommandPalette/CommandPalette.js.map +1 -0
- package/dist/CommandPalette/CommandPaletteFooter.d.ts +16 -0
- package/dist/CommandPalette/CommandPaletteFooter.d.ts.map +1 -0
- package/dist/CommandPalette/CommandPaletteFooter.js +14 -0
- package/dist/CommandPalette/CommandPaletteFooter.js.map +1 -0
- package/dist/CommandPalette/CommandPaletteOption.d.ts +44 -0
- package/dist/CommandPalette/CommandPaletteOption.d.ts.map +1 -0
- package/dist/CommandPalette/CommandPaletteOption.js +72 -0
- package/dist/CommandPalette/CommandPaletteOption.js.map +1 -0
- package/dist/CommandPalette/CommandPalettePanel.d.ts +52 -0
- package/dist/CommandPalette/CommandPalettePanel.d.ts.map +1 -0
- package/dist/CommandPalette/CommandPalettePanel.js +168 -0
- package/dist/CommandPalette/CommandPalettePanel.js.map +1 -0
- package/dist/CommandPalette/commandPaletteSections.d.ts +93 -0
- package/dist/CommandPalette/commandPaletteSections.d.ts.map +1 -0
- package/dist/CommandPalette/commandPaletteSections.js +94 -0
- package/dist/CommandPalette/commandPaletteSections.js.map +1 -0
- package/dist/CommandPalette/useCommandPalette.d.ts +32 -0
- package/dist/CommandPalette/useCommandPalette.d.ts.map +1 -0
- package/dist/CommandPalette/useCommandPalette.js +40 -0
- package/dist/CommandPalette/useCommandPalette.js.map +1 -0
- package/dist/CommandPalette/useCommandPaletteHotkeys.d.ts +35 -0
- package/dist/CommandPalette/useCommandPaletteHotkeys.d.ts.map +1 -0
- package/dist/CommandPalette/useCommandPaletteHotkeys.js +114 -0
- package/dist/CommandPalette/useCommandPaletteHotkeys.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/CommandPalette/CommandPalette.mdx +129 -0
- package/src/CommandPalette/CommandPalette.stories.tsx +413 -0
- package/src/CommandPalette/CommandPalette.test.tsx +553 -0
- package/src/CommandPalette/CommandPalette.tsx +91 -0
- package/src/CommandPalette/CommandPaletteFooter.tsx +45 -0
- package/src/CommandPalette/CommandPaletteOption.tsx +200 -0
- package/src/CommandPalette/CommandPalettePanel.tsx +411 -0
- package/src/CommandPalette/commandPalette.storyHelpers.tsx +248 -0
- package/src/CommandPalette/commandPaletteSections.test.ts +156 -0
- package/src/CommandPalette/commandPaletteSections.ts +219 -0
- package/src/CommandPalette/useCommandPalette.ts +75 -0
- package/src/CommandPalette/useCommandPaletteHotkeys.ts +158 -0
- package/src/index.ts +14 -0
- 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
|
+
)
|