@charcuterie/ui 4.24.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 (63) 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/GroupedList/GroupedList.d.ts +78 -1
  31. package/dist/GroupedList/GroupedList.d.ts.map +1 -1
  32. package/dist/GroupedList/GroupedList.js +14 -0
  33. package/dist/GroupedList/GroupedList.js.map +1 -1
  34. package/dist/GroupedList/GroupedListRow.d.ts +17 -6
  35. package/dist/GroupedList/GroupedListRow.d.ts.map +1 -1
  36. package/dist/GroupedList/GroupedListRow.js +75 -8
  37. package/dist/GroupedList/GroupedListRow.js.map +1 -1
  38. package/dist/index.d.ts +7 -0
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +3 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/styles.css +36 -4
  43. package/package.json +2 -2
  44. package/src/CommandPalette/CommandPalette.mdx +129 -0
  45. package/src/CommandPalette/CommandPalette.stories.tsx +413 -0
  46. package/src/CommandPalette/CommandPalette.test.tsx +553 -0
  47. package/src/CommandPalette/CommandPalette.tsx +91 -0
  48. package/src/CommandPalette/CommandPaletteFooter.tsx +45 -0
  49. package/src/CommandPalette/CommandPaletteOption.tsx +200 -0
  50. package/src/CommandPalette/CommandPalettePanel.tsx +411 -0
  51. package/src/CommandPalette/commandPalette.storyHelpers.tsx +248 -0
  52. package/src/CommandPalette/commandPaletteSections.test.ts +156 -0
  53. package/src/CommandPalette/commandPaletteSections.ts +219 -0
  54. package/src/CommandPalette/useCommandPalette.ts +75 -0
  55. package/src/CommandPalette/useCommandPaletteHotkeys.ts +158 -0
  56. package/src/GroupedList/GroupedList.mdx +42 -1
  57. package/src/GroupedList/GroupedList.stories.tsx +252 -2
  58. package/src/GroupedList/GroupedList.test.tsx +391 -0
  59. package/src/GroupedList/GroupedList.tsx +78 -1
  60. package/src/GroupedList/GroupedListRow.tsx +135 -10
  61. package/src/index.ts +14 -0
  62. package/src/sourceRules.test.ts +2 -1
  63. package/src/styles.css +36 -4
@@ -0,0 +1,158 @@
1
+ import { useLatestRef } from "@charcuterie/logic"
2
+ import { useEffect } from "react"
3
+
4
+ export type CommandPaletteHotkeysOptions = {
5
+ /** Off while the palette is open, or on a page that should not take the keys. Default `true`. */
6
+ isEnabled?: boolean
7
+ /**
8
+ * A printable key typed outside an editable field opens the palette
9
+ * with that character as the query. Default `true`.
10
+ */
11
+ isOpenedByTyping?: boolean
12
+ /** Receives the text the palette should open with: `""`, or the character typed. */
13
+ onOpen: (initialQuery: string) => void
14
+ }
15
+
16
+ /**
17
+ * Elements that keep their keys: anything you type into, and the
18
+ * widgets with their own type-to-find (a focused `Listbox` or `Menu`
19
+ * jumps to the option you type, and stealing that key breaks it).
20
+ */
21
+ const TYPEAHEAD_OWNER_SELECTOR = [
22
+ "input",
23
+ "textarea",
24
+ "select",
25
+ "[contenteditable]:not([contenteditable='false'])",
26
+ "[role='combobox']",
27
+ "[role='grid']",
28
+ "[role='listbox']",
29
+ "[role='menu']",
30
+ "[role='menubar']",
31
+ "[role='searchbox']",
32
+ "[role='textbox']",
33
+ "[role='tree']",
34
+ "[role='treegrid']",
35
+ ].join(",")
36
+
37
+ /** Input types that take no text, so a letter typed on one is free to open the palette. */
38
+ const NON_TEXT_INPUT_TYPES = new Set([
39
+ "button",
40
+ "checkbox",
41
+ "color",
42
+ "file",
43
+ "image",
44
+ "radio",
45
+ "range",
46
+ "reset",
47
+ "submit",
48
+ ])
49
+
50
+ export const getIsTypingTarget = (
51
+ target: EventTarget | null,
52
+ ): boolean => {
53
+ if (!(target instanceof Element)) {
54
+ return false
55
+ }
56
+
57
+ if (
58
+ target instanceof HTMLInputElement &&
59
+ NON_TEXT_INPUT_TYPES.has(target.type)
60
+ ) {
61
+ return false
62
+ }
63
+
64
+ return (
65
+ (target instanceof HTMLElement &&
66
+ target.isContentEditable) ||
67
+ target.closest(TYPEAHEAD_OWNER_SELECTOR) !== null
68
+ )
69
+ }
70
+
71
+ /**
72
+ * The global keys that open a command palette, as an opt-in hook.
73
+ *
74
+ * - **`Ctrl K`, `Ctrl Space`** (or `⌘` on a Mac) open it from
75
+ * anywhere, a text field included — the chord types nothing.
76
+ * - **`/`** opens it when focus is not somewhere that types, and
77
+ * the slash itself is not typed.
78
+ * - **Any printable key** does the same and becomes the query, so
79
+ * the page is a search box without a click (`isOpenedByTyping`).
80
+ *
81
+ * It is a hook, not something `CommandPalette` does on mount,
82
+ * because a document-wide key listener is a page-level decision: an
83
+ * app with two palettes, or a page where letters already mean
84
+ * something (a board with its own shortcuts), must be able to say
85
+ * no. See
86
+ * `docs/decisions/2026-10-16-command-palette-hotkeys-are-an-opt-in-hook.md`.
87
+ *
88
+ * A key another handler already claimed (`defaultPrevented`), a key
89
+ * pressed during IME composition, and a key with `Alt` held are all
90
+ * left alone.
91
+ */
92
+ export const useCommandPaletteHotkeys = ({
93
+ isEnabled = true,
94
+ isOpenedByTyping = true,
95
+ onOpen,
96
+ }: CommandPaletteHotkeysOptions): void => {
97
+ const onOpenRef = useLatestRef(onOpen)
98
+
99
+ useEffect(() => {
100
+ if (!isEnabled) {
101
+ return
102
+ }
103
+
104
+ const handleKeyDown = (event: KeyboardEvent) => {
105
+ if (
106
+ event.defaultPrevented ||
107
+ event.isComposing ||
108
+ event.altKey
109
+ ) {
110
+ return
111
+ }
112
+
113
+ const isChord = event.ctrlKey || event.metaKey
114
+
115
+ if (isChord) {
116
+ if (
117
+ !event.shiftKey &&
118
+ (event.key.toLowerCase() === "k" ||
119
+ event.key === " ")
120
+ ) {
121
+ event.preventDefault()
122
+ onOpenRef.current("")
123
+ }
124
+
125
+ return
126
+ }
127
+
128
+ if (getIsTypingTarget(event.target)) {
129
+ return
130
+ }
131
+
132
+ if (event.key === "/") {
133
+ event.preventDefault()
134
+ onOpenRef.current("")
135
+
136
+ return
137
+ }
138
+
139
+ // `key` is the character for a printable key and a name
140
+ // ("Enter", "ArrowDown") for everything else; a space is
141
+ // printable but presses the focused button.
142
+ if (
143
+ isOpenedByTyping &&
144
+ [...event.key].length === 1 &&
145
+ /\S/u.test(event.key)
146
+ ) {
147
+ event.preventDefault()
148
+ onOpenRef.current(event.key)
149
+ }
150
+ }
151
+
152
+ document.addEventListener("keydown", handleKeyDown)
153
+
154
+ return () => {
155
+ document.removeEventListener("keydown", handleKeyDown)
156
+ }
157
+ }, [isEnabled, isOpenedByTyping, onOpenRef])
158
+ }
@@ -7,7 +7,9 @@ import * as GroupedListStories from "./GroupedList.stories";
7
7
  # GroupedList
8
8
 
9
9
  An always-open list whose rows sit under group headings. Each row is a label, an optional
10
- muted description, optional badges, optional richer content, and optional trailing actions.
10
+ muted description, optional badges, optional richer content, an optional figure beside the
11
+ text, and optional trailing actions. A row can also be the target itself, muted, or
12
+ highlighted.
11
13
 
12
14
  Three apps drew this shape by hand: castkit's page picker (pages under the screen that shows
13
15
  them), tally-marks' cards (cards under the team that earns them) and whereabouts' nested
@@ -84,6 +86,45 @@ that sets them through a `style` object.
84
86
 
85
87
  <Canvas of={GroupedListStories.StickyHeadings} />
86
88
 
89
+ ## A row can be the target
90
+
91
+ Give an item `onSelect` and the whole row becomes one native `<button>` — "open this card"
92
+ for a list whose primary action is the row itself. `selectLabel` names it (it defaults to
93
+ `label`); name the verb, as with any repeated action: `selectLabel="Edit Water the ferns"`.
94
+
95
+ - **Every state is drawn.** Hover tints the row and rings its edge (only on devices that
96
+ hover, so a tap does not leave a row lit). A press sinks the row slightly, with motion only
97
+ when the user has not asked the OS for less. Keyboard focus draws the focus ring inside the
98
+ row's edge, where the list's rounded corners cannot clip it, and one ring-width in from it,
99
+ so a highlighted row that has focus shows both rings.
100
+ - **The actions stay their own targets.** The button is drawn under the row's text and the
101
+ text lets the pointer through to it; `actions` sit above it. Pressing "Delete" never opens
102
+ the row, and Tab reaches the row, then its actions, then the next row.
103
+ - **Nothing is nested in the button.** It is empty and named by `aria-label`, so `badges`,
104
+ `content` and `aside` must not hold controls — a control belongs in `actions`.
105
+
106
+ `aside` is a figure that belongs **beside** the text — a payout, a total. It asks for at
107
+ least 11rem next to the text column's 12rem, so it sits between the text and the actions in a
108
+ wide list and drops under the text in a narrow one.
109
+
110
+ <Canvas of={GroupedListStories.SelectableRows} />
111
+
112
+ ## Muted and highlighted rows
113
+
114
+ - `isMuted` recedes a row that exists but is switched off: a sunken surface and a quieter
115
+ label. It stays fully operable, and it is a surface and a text role rather than an opacity,
116
+ because a faded row would drop below the AA contrast floor and only a disabled control may.
117
+ Say why in a badge as well ("off").
118
+ - `isHighlighted` rings the row and pulses the ring twice — "this one just changed", after a
119
+ scan or a save. `highlightIntent` picks the colour (`accent` by default). The ring stays
120
+ while the flag does, so the app decides how long it lasts, usually with a timer. **To pulse
121
+ an already-highlighted row again, change its `key`**: a new key is a new row, and a new row
122
+ starts the pulse from the beginning. Under `prefers-reduced-motion` and on ePaper the ring
123
+ does not pulse; it is simply there. The pulse lives in `@charcuterie/ui/styles.css`, which
124
+ every app already imports.
125
+
126
+ <Canvas of={GroupedListStories.RowStates} />
127
+
87
128
  ## Name a repeated action after its row
88
129
 
89
130
  Ten rows of "Open" are ten buttons an agent cannot tell apart. Give each one a name that
@@ -1,6 +1,6 @@
1
1
  import { playgroundParameters } from "@charcuterie/storybook-config/story-parameters"
2
2
  import type { Meta, StoryObj } from "@storybook/react"
3
- import type { CSSProperties } from "react"
3
+ import { type CSSProperties, useState } from "react"
4
4
 
5
5
  import { Badge } from "../Badge/Badge.tsx"
6
6
  import { Button } from "../Button/Button.tsx"
@@ -10,7 +10,11 @@ import {
10
10
  StoryGrid,
11
11
  } from "../board.storyHelpers.tsx"
12
12
  import { EmptyState } from "../EmptyState/EmptyState.tsx"
13
- import type { GroupedListGroup } from "./GroupedList.tsx"
13
+ import { ValuePairs } from "../ValuePairs/ValuePairs.tsx"
14
+ import type {
15
+ GroupedListGroup,
16
+ GroupedListItem,
17
+ } from "./GroupedList.tsx"
14
18
  import { GroupedList } from "./GroupedList.tsx"
15
19
  import { GROUPED_LIST_HEADING_SURFACE_PROPERTY } from "./groupedListStickyHeading.ts"
16
20
 
@@ -183,6 +187,142 @@ const CHORE_GROUPS: GroupedListGroup[] = [
183
187
  },
184
188
  ]
185
189
 
190
+ /**
191
+ * Fixture data only. A chore card whose whole row opens its editor:
192
+ * the shape tally-marks' Rows view draws, with the payout beside the
193
+ * text and the daily cap under it.
194
+ */
195
+ type ChoreCard = {
196
+ isActive: boolean
197
+ isHighlighted?: boolean
198
+ key: string
199
+ label: string
200
+ maxPerDay: number
201
+ points: number
202
+ }
203
+
204
+ const CHORE_CARDS: ChoreCard[] = [
205
+ {
206
+ isActive: true,
207
+ key: "water-ferns",
208
+ label: "Water the ferns",
209
+ maxPerDay: 5,
210
+ points: 5,
211
+ },
212
+ {
213
+ isActive: true,
214
+ isHighlighted: true,
215
+ key: "sort-recycling",
216
+ label: "Sort the recycling",
217
+ maxPerDay: 6,
218
+ points: 2,
219
+ },
220
+ {
221
+ isActive: false,
222
+ key: "feed-goldfish",
223
+ label: "Feed the goldfish",
224
+ maxPerDay: 2,
225
+ points: 2,
226
+ },
227
+ ]
228
+
229
+ /**
230
+ * One chore card as a row. `onSelect` opens it; the action beside it
231
+ * is a separate target. A switched-off card is muted and says so in
232
+ * a badge.
233
+ */
234
+ const toChoreRow = (
235
+ card: ChoreCard,
236
+ {
237
+ onDelete,
238
+ onSelect,
239
+ }: {
240
+ onDelete?: (label: string) => void
241
+ onSelect?: (label: string) => void
242
+ } = {},
243
+ ): GroupedListItem => ({
244
+ actions: (
245
+ <Button
246
+ appearance="ghost"
247
+ aria-label={`Delete ${card.label}`}
248
+ intent="danger"
249
+ onClick={() => {
250
+ onDelete?.(card.label)
251
+ }}
252
+ size="sm"
253
+ >
254
+ Delete
255
+ </Button>
256
+ ),
257
+ aside: (
258
+ <ValuePairs
259
+ items={[
260
+ {
261
+ id: "scan",
262
+ label: "Per scan",
263
+ value: `${card.points} pts`,
264
+ },
265
+ ]}
266
+ label="Payout"
267
+ />
268
+ ),
269
+ badges: card.isActive ? undefined : (
270
+ <Badge appearance="soft" size="sm">
271
+ off
272
+ </Badge>
273
+ ),
274
+ content: `Up to ${card.maxPerDay} pts a day`,
275
+ description: card.key,
276
+ highlightIntent: "success",
277
+ isHighlighted: card.isHighlighted,
278
+ isMuted: !card.isActive,
279
+ key: card.key,
280
+ label: card.label,
281
+ onSelect: () => {
282
+ onSelect?.(card.label)
283
+ },
284
+ selectLabel: `Edit ${card.label}`,
285
+ })
286
+
287
+ const toChoreGroups = (
288
+ handlers?: Parameters<typeof toChoreRow>[1],
289
+ ): GroupedListGroup[] => [
290
+ {
291
+ heading: "Team Orion",
292
+ items: CHORE_CARDS.map((card) =>
293
+ toChoreRow(card, handlers),
294
+ ),
295
+ key: "orion",
296
+ summary: "3 cards · up to 11 pts a day",
297
+ },
298
+ ]
299
+
300
+ /**
301
+ * Fixture data only. The same plain row in each highlight intent.
302
+ */
303
+ const HIGHLIGHT_INTENT_GROUPS: GroupedListGroup[] = [
304
+ {
305
+ heading: "Highlight intents",
306
+ items: (
307
+ [
308
+ "accent",
309
+ "success",
310
+ "info",
311
+ "warning",
312
+ "danger",
313
+ "neutral",
314
+ ] as const
315
+ ).map((intent) => ({
316
+ description: `highlightIntent="${intent}"`,
317
+ highlightIntent: intent,
318
+ isHighlighted: true,
319
+ key: intent,
320
+ label: `Highlighted ${intent}`,
321
+ })),
322
+ key: "intents",
323
+ },
324
+ ]
325
+
186
326
  const LONG_GROUPS: GroupedListGroup[] = [
187
327
  {
188
328
  heading:
@@ -524,6 +664,116 @@ export const StickyHeadings: Story = {
524
664
  ),
525
665
  }
526
666
 
667
+ /**
668
+ * A row whose whole surface is its primary action. `onSelect` draws
669
+ * one native button under the row's text, named by `selectLabel`;
670
+ * the Delete action sits above it as its own target. The status
671
+ * line under the list says which one a click reached.
672
+ *
673
+ * The second card is highlighted (a scan just paid it), and the
674
+ * third is switched off, so it is muted.
675
+ */
676
+ export const SelectableRows: Story = {
677
+ render: function SelectableRowsStory() {
678
+ const [lastEvent, setLastEvent] = useState(
679
+ "Nothing chosen yet",
680
+ )
681
+
682
+ return (
683
+ <div className="flex flex-col gap-3">
684
+ <GroupedList
685
+ groups={toChoreGroups({
686
+ onDelete: (label) => {
687
+ setLastEvent(`Deleted ${label}`)
688
+ },
689
+ onSelect: (label) => {
690
+ setLastEvent(`Opened ${label}`)
691
+ },
692
+ })}
693
+ />
694
+
695
+ <p
696
+ className="m-0 text-content-secondary text-sm"
697
+ role="status"
698
+ >
699
+ {lastEvent}
700
+ </p>
701
+ </div>
702
+ )
703
+ },
704
+ }
705
+
706
+ /**
707
+ * Every per-row state at once: hover, press and keyboard focus on
708
+ * the whole-row button (forced by the pseudo-states addon, the only
709
+ * way to review them in a static board), a muted row, and a
710
+ * highlight in each intent. The highlight's pulse is frozen here, so
711
+ * this is also its static, reduced-motion look.
712
+ */
713
+ export const RowStates: Story = {
714
+ parameters: {
715
+ pseudo: {
716
+ active: ["#active-row li:first-child > button"],
717
+ focusVisible: ["#focus-row li:first-child > button"],
718
+ hover: ["#hover-row li:first-child > button"],
719
+ },
720
+ },
721
+ render: () => (
722
+ <StoryGrid columns={2}>
723
+ {(
724
+ [
725
+ ["resting", "resting-row"],
726
+ ["hover (forced)", "hover-row"],
727
+ ["active (forced)", "active-row"],
728
+ ["focus-visible (forced)", "focus-row"],
729
+ ] as const
730
+ ).map(([label, id]) => (
731
+ <StoryCell align="stretch" key={id} label={label}>
732
+ <div id={id}>
733
+ <GroupedList
734
+ groups={[
735
+ {
736
+ heading: `Team Orion, ${label}`,
737
+ items: CHORE_CARDS.slice(0, 1).map(
738
+ (card) => toChoreRow(card),
739
+ ),
740
+ key: id,
741
+ },
742
+ ]}
743
+ size="sm"
744
+ />
745
+ </div>
746
+ </StoryCell>
747
+ ))}
748
+
749
+ <StoryCell align="stretch" label="isMuted">
750
+ <GroupedList
751
+ groups={[
752
+ {
753
+ heading: "Switched off",
754
+ items: [
755
+ toChoreRow(CHORE_CARDS[2] as ChoreCard),
756
+ ],
757
+ key: "muted",
758
+ },
759
+ ]}
760
+ size="sm"
761
+ />
762
+ </StoryCell>
763
+
764
+ <StoryCell
765
+ align="stretch"
766
+ label="isHighlighted, each highlightIntent"
767
+ >
768
+ <GroupedList
769
+ groups={HIGHLIGHT_INTENT_GROUPS}
770
+ size="sm"
771
+ />
772
+ </StoryCell>
773
+ </StoryGrid>
774
+ ),
775
+ }
776
+
527
777
  /**
528
778
  * Light and dark side by side, each panel scoped with its own
529
779
  * `data-scheme`, so both read at once whatever the toolbar says.