@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,413 @@
1
+ import { playgroundParameters } from "@charcuterie/storybook-config/story-parameters"
2
+ import type { Meta, StoryObj } from "@storybook/react"
3
+ import type { ReactNode } from "react"
4
+ import { useState } from "react"
5
+
6
+ import { controlSizeArgType } from "../argTypes.storyHelpers.ts"
7
+ import { Button } from "../Button/Button.tsx"
8
+ import {
9
+ StoryCell,
10
+ StoryGrid,
11
+ StorySection,
12
+ } from "../board.storyHelpers.tsx"
13
+ import {
14
+ CommandPalette,
15
+ type CommandPaletteProps,
16
+ } from "./CommandPalette.tsx"
17
+ import {
18
+ CommandPalettePanel,
19
+ type CommandPalettePanelProps,
20
+ } from "./CommandPalettePanel.tsx"
21
+ import {
22
+ getWebSearchItem,
23
+ LAUNCHER_ITEMS,
24
+ LAUNCHER_PREFIXES,
25
+ LAUNCHER_SUGGESTIONS,
26
+ PanelFrame,
27
+ } from "./commandPalette.storyHelpers.tsx"
28
+ import type { CommandPaletteItem } from "./commandPaletteSections.ts"
29
+ import { useCommandPalette } from "./useCommandPalette.ts"
30
+
31
+ const meta = {
32
+ title: "Components/Overlays/CommandPalette",
33
+ component: CommandPalette,
34
+ parameters: { layout: "padded" },
35
+ argTypes: { itemSize: controlSizeArgType },
36
+ args: {
37
+ getFallbackItem: getWebSearchItem,
38
+ isVisible: false,
39
+ itemSize: "lg",
40
+ items: LAUNCHER_ITEMS,
41
+ label: "Search apps, bookmarks, and the web",
42
+ maxResultsPerGroup: 6,
43
+ onClose: () => {},
44
+ placeholder: "Search apps, bookmarks, or the web",
45
+ prefixes: LAUNCHER_PREFIXES,
46
+ suggestions: LAUNCHER_SUGGESTIONS,
47
+ },
48
+ } satisfies Meta<typeof CommandPalette>
49
+
50
+ export default meta
51
+
52
+ type Story = StoryObj<typeof meta>
53
+
54
+ /**
55
+ * What an app writes: `useCommandPalette()` for the open state and the
56
+ * global keys, a button for the pointer, and the palette.
57
+ */
58
+ const LauncherDemo = ({
59
+ isVisible: _isVisible,
60
+ onClose: _onClose,
61
+ ...paletteProps
62
+ }: CommandPaletteProps): ReactNode => {
63
+ const palette = useCommandPalette()
64
+
65
+ return (
66
+ <div className="flex flex-col items-start gap-3">
67
+ <Button
68
+ appearance="soft"
69
+ onClick={() => palette.open()}
70
+ size="sm"
71
+ >
72
+ Search
73
+ </Button>
74
+
75
+ <p className="m-0 text-content-secondary text-sm">
76
+ Or press Ctrl K, Ctrl Space, or /, or start typing.
77
+ </p>
78
+
79
+ <CommandPalette
80
+ {...paletteProps}
81
+ {...palette.paletteProps}
82
+ />
83
+ </div>
84
+ )
85
+ }
86
+
87
+ export const Playground: Story = {
88
+ parameters: playgroundParameters,
89
+ render: (paletteProps) => (
90
+ <LauncherDemo {...paletteProps} />
91
+ ),
92
+ }
93
+
94
+ const panelProps = (
95
+ paletteProps: CommandPaletteProps,
96
+ ): CommandPalettePanelProps => {
97
+ const {
98
+ className: _className,
99
+ isVisible: _isVisible,
100
+ onClose: _onClose,
101
+ ...rest
102
+ } = paletteProps
103
+
104
+ return rest
105
+ }
106
+
107
+ /**
108
+ * The row's parts, and the three row sizes. Each panel is the
109
+ * palette's contents laid out in place, so the boards can show every
110
+ * case side by side; in an app it is always inside the dialog.
111
+ */
112
+ export const AllVariants: Story = {
113
+ render: (paletteProps) => (
114
+ <div className="flex flex-col gap-6">
115
+ <StorySection title="Rows: an icon, a highlighted title, a description (the folder), and a trailing hint. Every part but the title is optional.">
116
+ <StoryGrid columns={2}>
117
+ <StoryCell
118
+ align="stretch"
119
+ label="icon, description, hint"
120
+ >
121
+ <PanelFrame>
122
+ <CommandPalettePanel
123
+ {...panelProps(paletteProps)}
124
+ defaultQuery="remote"
125
+ label="Rows with every part"
126
+ />
127
+ </PanelFrame>
128
+ </StoryCell>
129
+
130
+ <StoryCell
131
+ align="stretch"
132
+ label="title only, and a long title that wraps"
133
+ >
134
+ <PanelFrame>
135
+ <CommandPalettePanel
136
+ {...panelProps(paletteProps)}
137
+ defaultQuery="li"
138
+ items={[
139
+ {
140
+ group: "Notes",
141
+ key: "list",
142
+ title: "Linen closet inventory",
143
+ },
144
+ {
145
+ group: "Notes",
146
+ key: "long",
147
+ title:
148
+ "Living room lamp timer schedule for the winter months, weekdays and weekends",
149
+ },
150
+ ]}
151
+ label="Rows with a title only"
152
+ />
153
+ </PanelFrame>
154
+ </StoryCell>
155
+ </StoryGrid>
156
+ </StorySection>
157
+
158
+ <StorySection title="itemSize sm, md and lg. The search field follows the rows.">
159
+ <StoryGrid columns={3}>
160
+ {(["sm", "md", "lg"] as const).map((itemSize) => (
161
+ <StoryCell
162
+ align="stretch"
163
+ key={itemSize}
164
+ label={`itemSize=${itemSize}`}
165
+ >
166
+ <PanelFrame>
167
+ <CommandPalettePanel
168
+ {...panelProps(paletteProps)}
169
+ footer={null}
170
+ itemSize={itemSize}
171
+ label={`Search at ${itemSize}`}
172
+ />
173
+ </PanelFrame>
174
+ </StoryCell>
175
+ ))}
176
+ </StoryGrid>
177
+ </StorySection>
178
+ </div>
179
+ ),
180
+ }
181
+
182
+ /**
183
+ * Every state the list can be in. The first is the reason the
184
+ * component exists: it lists something before anything is typed.
185
+ */
186
+ export const AllStates: Story = {
187
+ render: (paletteProps) => (
188
+ <StoryGrid columns={3}>
189
+ <StoryCell
190
+ align="stretch"
191
+ label="before typing: suggestions"
192
+ >
193
+ <PanelFrame>
194
+ <CommandPalettePanel
195
+ {...panelProps(paletteProps)}
196
+ label="Before typing"
197
+ />
198
+ </PanelFrame>
199
+ </StoryCell>
200
+
201
+ <StoryCell
202
+ align="stretch"
203
+ label={'"serv": word prefixes, then the fallback'}
204
+ >
205
+ <PanelFrame>
206
+ <CommandPalettePanel
207
+ {...panelProps(paletteProps)}
208
+ defaultQuery="serv"
209
+ label="Word prefixes"
210
+ />
211
+ </PanelFrame>
212
+ </StoryCell>
213
+
214
+ <StoryCell align="stretch" label={'"ha": initials'}>
215
+ <PanelFrame>
216
+ <CommandPalettePanel
217
+ {...panelProps(paletteProps)}
218
+ defaultQuery="ha"
219
+ label="Initials"
220
+ />
221
+ </PanelFrame>
222
+ </StoryCell>
223
+
224
+ <StoryCell
225
+ align="stretch"
226
+ label={'"au lego": a prefix command'}
227
+ >
228
+ <PanelFrame>
229
+ <CommandPalettePanel
230
+ {...panelProps(paletteProps)}
231
+ defaultQuery="au lego"
232
+ label="Prefix command"
233
+ />
234
+ </PanelFrame>
235
+ </StoryCell>
236
+
237
+ <StoryCell
238
+ align="stretch"
239
+ label={'"zebra": only the fallback'}
240
+ >
241
+ <PanelFrame>
242
+ <CommandPalettePanel
243
+ {...panelProps(paletteProps)}
244
+ defaultQuery="zebra"
245
+ label="Only the fallback"
246
+ />
247
+ </PanelFrame>
248
+ </StoryCell>
249
+
250
+ <StoryCell
251
+ align="stretch"
252
+ label={'"zebra", no fallback: the empty label'}
253
+ >
254
+ <PanelFrame>
255
+ <CommandPalettePanel
256
+ {...panelProps(paletteProps)}
257
+ defaultQuery="zebra"
258
+ getFallbackItem={undefined}
259
+ label="No matches"
260
+ />
261
+ </PanelFrame>
262
+ </StoryCell>
263
+ </StoryGrid>
264
+ ),
265
+ }
266
+
267
+ /**
268
+ * Opened over the page, before anything is typed. Like `Dialog`, the
269
+ * palette is measured against the viewport, not a container, so this
270
+ * story has no `ContainerBoard`: it is the window that varies. On a
271
+ * wide window it hangs from the top at up to 45rem wide; in the Narrow
272
+ * View it takes nearly the whole screen, and the key hints in the
273
+ * footer give way to the prefix list.
274
+ */
275
+ const OpenDemo = ({
276
+ defaultQuery,
277
+ isVisible: _isVisible,
278
+ onClose: _onClose,
279
+ ...paletteProps
280
+ }: CommandPaletteProps): ReactNode => {
281
+ const [isVisible, setIsVisible] = useState(true)
282
+
283
+ return (
284
+ <div className="min-h-screen">
285
+ <Button
286
+ appearance="soft"
287
+ onClick={() => setIsVisible(true)}
288
+ size="sm"
289
+ >
290
+ Search
291
+ </Button>
292
+
293
+ <CommandPalette
294
+ {...paletteProps}
295
+ defaultQuery={defaultQuery}
296
+ isVisible={isVisible}
297
+ onClose={() => setIsVisible(false)}
298
+ />
299
+ </div>
300
+ )
301
+ }
302
+
303
+ export const Responsive: Story = {
304
+ render: (paletteProps) => <OpenDemo {...paletteProps} />,
305
+ }
306
+
307
+ /** Open with `serv` typed: matches under their groups, then the fallback row. */
308
+ export const OpenFiltered: Story = {
309
+ args: { defaultQuery: "serv" },
310
+ render: (paletteProps) => <OpenDemo {...paletteProps} />,
311
+ }
312
+
313
+ /** Open with `au lego` typed: one row, for the target that owns `au`. */
314
+ export const OpenPrefixCommand: Story = {
315
+ args: { defaultQuery: "au lego" },
316
+ render: (paletteProps) => <OpenDemo {...paletteProps} />,
317
+ }
318
+
319
+ /**
320
+ * The whole keyboard path. Ctrl K, Ctrl Space or / open it with an
321
+ * empty field; any other key opens it with that key typed. Arrow
322
+ * keys, Home and End move; Enter opens the row; Ctrl Enter opens a
323
+ * link row in a new tab; Escape closes, and focus goes back to where
324
+ * it was. A clear button appears when the app passes `clearIcon`.
325
+ */
326
+ const ClearIcon = (): ReactNode => (
327
+ <svg aria-hidden="true" fill="none" viewBox="0 0 24 24">
328
+ <path
329
+ d="m6 6 12 12M18 6 6 18"
330
+ stroke="currentColor"
331
+ strokeLinecap="round"
332
+ strokeWidth="2"
333
+ />
334
+ </svg>
335
+ )
336
+
337
+ /**
338
+ * Every row runs `onSelect` instead of navigating — the items, the
339
+ * suggestions, the prefix rows and the fallback — so the story can
340
+ * show what was chosen and never leaves the page.
341
+ */
342
+ const InteractiveDemo = (
343
+ paletteProps: CommandPaletteProps,
344
+ ): ReactNode => {
345
+ const [chosen, setChosen] = useState("nothing yet")
346
+
347
+ const toChoice = (item: CommandPaletteItem) => ({
348
+ ...item,
349
+ href: undefined,
350
+ onSelect: () => setChosen(item.title),
351
+ })
352
+
353
+ const items = paletteProps.items.map(toChoice)
354
+
355
+ const suggestions = (paletteProps.suggestions ?? []).map(
356
+ (group) => ({
357
+ ...group,
358
+ items: group.items.map(toChoice),
359
+ }),
360
+ )
361
+
362
+ const prefixes = (paletteProps.prefixes ?? []).map(
363
+ ({ getHref: _getHref, ...prefix }) => ({
364
+ ...prefix,
365
+ onSelect: (query: string) =>
366
+ setChosen(`${prefix.label}: ${query}`),
367
+ }),
368
+ )
369
+
370
+ const getFallbackItem = (query: string) => {
371
+ const item = paletteProps.getFallbackItem?.(query)
372
+
373
+ return item == null ? item : toChoice(item)
374
+ }
375
+
376
+ return (
377
+ <div className="flex flex-col items-start gap-3">
378
+ <LauncherDemo
379
+ {...paletteProps}
380
+ getFallbackItem={getFallbackItem}
381
+ items={items}
382
+ prefixes={prefixes}
383
+ suggestions={suggestions}
384
+ />
385
+
386
+ <label className="flex flex-col gap-1 text-content-secondary text-sm">
387
+ A note on the page
388
+ <input
389
+ className="rounded-md border border-border-default bg-surface-raised px-3 py-2 text-content-primary"
390
+ type="text"
391
+ />
392
+ </label>
393
+
394
+ <p className="m-0 text-content-primary text-sm">
395
+ Last chosen: {chosen}
396
+ </p>
397
+ </div>
398
+ )
399
+ }
400
+
401
+ /**
402
+ * The whole keyboard path. Ctrl K, Ctrl Space or / open it with an
403
+ * empty field; any other key opens it with that key typed. Arrow
404
+ * keys, Home and End move; Enter opens the row; Ctrl Enter opens a
405
+ * link row in a new tab; Escape closes, and focus goes back to where
406
+ * it was. A clear button appears when the app passes `clearIcon`.
407
+ */
408
+ export const Interactive: Story = {
409
+ args: { clearIcon: <ClearIcon /> },
410
+ render: (paletteProps) => (
411
+ <InteractiveDemo {...paletteProps} />
412
+ ),
413
+ }