@charcuterie/ui 4.25.0 → 4.26.1

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 (54) 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/TableViewport/TableViewport.d.ts.map +1 -1
  31. package/dist/TableViewport/TableViewport.js +62 -10
  32. package/dist/TableViewport/TableViewport.js.map +1 -1
  33. package/dist/index.d.ts +7 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +3 -0
  36. package/dist/index.js.map +1 -1
  37. package/package.json +2 -2
  38. package/src/CommandPalette/CommandPalette.mdx +129 -0
  39. package/src/CommandPalette/CommandPalette.stories.tsx +413 -0
  40. package/src/CommandPalette/CommandPalette.test.tsx +553 -0
  41. package/src/CommandPalette/CommandPalette.tsx +91 -0
  42. package/src/CommandPalette/CommandPaletteFooter.tsx +45 -0
  43. package/src/CommandPalette/CommandPaletteOption.tsx +200 -0
  44. package/src/CommandPalette/CommandPalettePanel.tsx +411 -0
  45. package/src/CommandPalette/commandPalette.storyHelpers.tsx +248 -0
  46. package/src/CommandPalette/commandPaletteSections.test.ts +156 -0
  47. package/src/CommandPalette/commandPaletteSections.ts +219 -0
  48. package/src/CommandPalette/useCommandPalette.ts +75 -0
  49. package/src/CommandPalette/useCommandPaletteHotkeys.ts +158 -0
  50. package/src/TableViewport/TableViewport.mdx +2 -0
  51. package/src/TableViewport/TableViewport.test.tsx +89 -1
  52. package/src/TableViewport/TableViewport.tsx +82 -9
  53. package/src/index.ts +14 -0
  54. package/src/sourceRules.test.ts +2 -1
@@ -0,0 +1,129 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as CommandPaletteStories from "./CommandPalette.stories";
4
+
5
+ <Meta of={CommandPaletteStories} />
6
+
7
+ # CommandPalette
8
+
9
+ The launcher: a search field over a grouped list, opened over the page. Press **Search**
10
+ below, or press `Ctrl K`, `Ctrl Space` or `/`, or start typing anywhere on the page.
11
+
12
+ <Canvas of={CommandPaletteStories.Playground} />
13
+
14
+ ## Props
15
+
16
+ <Controls />
17
+
18
+ ```tsx
19
+ const palette = useCommandPalette()
20
+
21
+ <Button onClick={() => palette.open()}>Search</Button>
22
+
23
+ <CommandPalette
24
+ {...palette.paletteProps}
25
+ label="Search apps, bookmarks, and the web"
26
+ items={items}
27
+ suggestions={[
28
+ { key: "pinned", label: "Pinned", items: pinned },
29
+ { key: "recent", label: "Recently opened", items: recent },
30
+ ]}
31
+ prefixes={[
32
+ { prefix: "au", label: "Auction Site", getHref: (query) => auctionSearch(query) },
33
+ ]}
34
+ getFallbackItem={(query) => ({
35
+ key: "web",
36
+ group: "The web",
37
+ title: `Search the web for “${query}”`,
38
+ href: webSearch(query),
39
+ })}
40
+ />
41
+ ```
42
+
43
+ The library owns the shape. Every name, link and search target is data the app passes in.
44
+
45
+ ## It lists something before anything is typed
46
+
47
+ `suggestions` are groups shown the moment it opens — a start page passes "Pinned" and
48
+ "Recently opened". A launcher that shows nothing until you type makes you remember the
49
+ name first, which is the job it was supposed to do.
50
+
51
+ <Canvas of={CommandPaletteStories.AllStates} />
52
+
53
+ ## How typing matches
54
+
55
+ **Word prefixes.** Every typed word must start a word of the title, the `description` or a
56
+ `keyword`, in any order: `serv` finds `Media Server`, `media serv` finds it too, and `edia`
57
+ finds nothing. **Initials** count — `ha` finds `Home Automation` — and a camel-case step
58
+ starts a word, so `bit` finds `qBitTracker`. Case and accents are folded.
59
+
60
+ The tiers, best first: the title starts with the query; every word starts a title word; the
61
+ title's initials; every word starts a word anywhere. Inside a tier, a higher `weight` (an
62
+ open count, say) wins, and then the order of `items`. Matches are grouped under each item's
63
+ `group`, and the groups are ordered by their best match, so the first row — the one Enter
64
+ opens — is always the best match.
65
+
66
+ It is deliberately **not** subsequence ("fuzzy") matching, which accepts any letters in
67
+ order and lets `ha` match `Chat` and `Shared Files`. The logic is
68
+ `rankWordPrefixMatches` in `@charcuterie/logic`, for a list that wants the same rule without
69
+ a palette.
70
+
71
+ **Folders.** A folder's members are ordinary items: flatten them into `items` and put the
72
+ folder's name in `description`, so a hit inside a folder still says where it lives.
73
+
74
+ ## Prefix commands and the fallback
75
+
76
+ A short prefix, a space, and a query — `au lego` — lists one row that sends the rest of the
77
+ query to that prefix's target. A prefix with nothing after it is ordinary text. For any
78
+ other query, `getFallbackItem` supplies the last row ("Search the web for …"), so a query
79
+ nothing matches still goes somewhere.
80
+
81
+ ## Rows
82
+
83
+ An icon, the title with its matched letters highlighted, a `description`, and a trailing
84
+ `hint`; only the title is required. An item with an `href` is a real `<a href>`, routed
85
+ through `RouterLinkProvider`, so middle-click and "copy link address" work. `onSelect` runs
86
+ on choice, with or without an `href`.
87
+
88
+ <Canvas of={CommandPaletteStories.AllVariants} />
89
+
90
+ ## Keys
91
+
92
+ | Key | Does |
93
+ | --- | --- |
94
+ | `Ctrl K`, `Ctrl Space` (`⌘` on a Mac) | Opens it, from anywhere, a text field included |
95
+ | `/` | Opens it, when focus is not in a field |
96
+ | Any printable key | Opens it with that key as the query, when focus is not in a field |
97
+ | Up, Down | Move the active row |
98
+ | Home, End | First row, last row |
99
+ | Enter | Opens the active row |
100
+ | `Ctrl Enter`, `⌘ Enter` | Opens a link row in a new tab |
101
+ | Escape | Closes, and focus goes back where it was |
102
+
103
+ Focus stays in the field the whole time: the active row is its `aria-activedescendant`, the
104
+ APG combobox pattern `Combobox` uses. The pointer moves the active row too.
105
+
106
+ In this demo every row reports what it chose instead of navigating. On this page the global
107
+ keys open the first palette above; this one opens from its button. Two palettes on one page
108
+ do not both open: the first listener to take a key marks it handled, and the hook leaves a
109
+ handled key alone.
110
+
111
+ <Canvas of={CommandPaletteStories.Interactive} />
112
+
113
+ ## Open state, and the global keys
114
+
115
+ `isVisible` and `onClose` work as on `Modal`. `useCommandPalette()` holds that state and
116
+ registers the keys; `useCommandPaletteHotkeys({ onOpen })` is the keys alone, for an app
117
+ that keeps its own state. The keys are a hook, not something the palette does on mount,
118
+ because a document-wide listener is a page-level decision — a page with its own letter
119
+ shortcuts passes `isOpenedByTyping: false`, or does not call the hook at all.
120
+
121
+ The query is uncontrolled (`defaultQuery`, read each time it opens, which is how a typed
122
+ letter arrives) or controlled (`query` + `onQueryChange`), as on `Combobox`.
123
+
124
+ ## Size
125
+
126
+ Like `Dialog`, it is measured against the window, not a container. On a wide window it hangs
127
+ from the top at up to 45rem wide, so the list grows down from a field that does not move. In
128
+ the Narrow View it takes nearly the whole screen, and the footer drops the key hints and
129
+ keeps the prefixes.
@@ -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
+ }