@charcuterie/ui 4.23.0 → 4.25.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.
@@ -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
@@ -56,6 +58,73 @@ There is no container query and no media query: the content decides.
56
58
 
57
59
  <Canvas of={GroupedListStories.Responsive} />
58
60
 
61
+ ## Sticky headings
62
+
63
+ `isHeadingSticky` pins each group's heading to the top of the nearest scrolling ancestor while
64
+ that group's rows scroll under it, and the next group's heading pushes it away. Use it for a
65
+ list inside a scrolling pane, where the heading is the only thing that says which screen or
66
+ team a row belongs to once the row scrolls out of sight. It is off by default.
67
+
68
+ A stuck heading needs an opaque background, or the rows show through it. The component cannot
69
+ know which surface it sits on — a page is `surface-base`, a settings pane is
70
+ `surface-raised` — so the host says so with a custom property:
71
+
72
+ | Property | Default | Set it to |
73
+ | --- | --- | --- |
74
+ | `--charcuterie-grouped-list-heading-surface` | `var(--color-surface-base)` | The background of the pane the list sits on. |
75
+ | `--charcuterie-grouped-list-heading-offset` | `0px` | The height of anything already stuck to the top of the same scroll container, such as a sticky `Header`. |
76
+
77
+ Set either one on any ancestor of the list. The names are exported as
78
+ `GROUPED_LIST_HEADING_SURFACE_PROPERTY` and `GROUPED_LIST_HEADING_OFFSET_PROPERTY` for a host
79
+ that sets them through a `style` object.
80
+
81
+ ```css
82
+ .settings-panel {
83
+ --charcuterie-grouped-list-heading-surface: var(--color-surface-raised);
84
+ }
85
+ ```
86
+
87
+ <Canvas of={GroupedListStories.StickyHeadings} />
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
+
59
128
  ## Name a repeated action after its row
60
129
 
61
130
  Ten rows of "Open" are ten buttons an agent cannot tell apart. Give each one a name that
@@ -1,5 +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, useState } from "react"
3
4
 
4
5
  import { Badge } from "../Badge/Badge.tsx"
5
6
  import { Button } from "../Button/Button.tsx"
@@ -9,8 +10,13 @@ import {
9
10
  StoryGrid,
10
11
  } from "../board.storyHelpers.tsx"
11
12
  import { EmptyState } from "../EmptyState/EmptyState.tsx"
12
- import type { GroupedListGroup } from "./GroupedList.tsx"
13
+ import { ValuePairs } from "../ValuePairs/ValuePairs.tsx"
14
+ import type {
15
+ GroupedListGroup,
16
+ GroupedListItem,
17
+ } from "./GroupedList.tsx"
13
18
  import { GroupedList } from "./GroupedList.tsx"
19
+ import { GROUPED_LIST_HEADING_SURFACE_PROPERTY } from "./groupedListStickyHeading.ts"
14
20
 
15
21
  /**
16
22
  * Fixture data only. A signage app's pages, grouped by the screen
@@ -181,6 +187,142 @@ const CHORE_GROUPS: GroupedListGroup[] = [
181
187
  },
182
188
  ]
183
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
+
184
326
  const LONG_GROUPS: GroupedListGroup[] = [
185
327
  {
186
328
  heading:
@@ -220,6 +362,45 @@ const LONG_GROUPS: GroupedListGroup[] = [
220
362
  },
221
363
  ]
222
364
 
365
+ /**
366
+ * Fixture data only. Enough screens that the pane has to scroll, so
367
+ * a stuck heading has rows to sit over.
368
+ */
369
+ const MANY_SCREEN_GROUPS: GroupedListGroup[] = [
370
+ "Lobby",
371
+ "Break Room",
372
+ "Loading Dock",
373
+ ].map((screen) => ({
374
+ heading: `On the ${screen} screen`,
375
+ items: ["Welcome", "Weather", "Schedule", "Menu"].map(
376
+ (view) => ({
377
+ actions: openButton(`${view} on ${screen}`),
378
+ description: `/views/${view.toLowerCase()}`,
379
+ key: view,
380
+ label: view,
381
+ }),
382
+ ),
383
+ key: screen,
384
+ }))
385
+
386
+ /**
387
+ * A fixed block size is what makes the pane scroll; the heading
388
+ * sticks to the pane, the nearest scrolling ancestor.
389
+ */
390
+ const SCROLL_PANE_STYLE: CSSProperties = {
391
+ blockSize: "16rem",
392
+ }
393
+
394
+ /**
395
+ * The host says which surface the list sits on. The pane is raised,
396
+ * so the stuck heading is raised too.
397
+ */
398
+ const RAISED_PANE_STYLE = {
399
+ ...SCROLL_PANE_STYLE,
400
+ [GROUPED_LIST_HEADING_SURFACE_PROPERTY]:
401
+ "var(--color-surface-raised)",
402
+ } as CSSProperties
403
+
223
404
  /**
224
405
  * A cap rather than a width, so the long label clips in every window
225
406
  * — an ultrawide grid cell would otherwise hold it whole and the
@@ -436,6 +617,163 @@ export const NarrowView: Story = {
436
617
  globals: { viewport: { value: "mobile1" } },
437
618
  }
438
619
 
620
+ /**
621
+ * `isHeadingSticky` pins each heading to the top of the scrolling
622
+ * pane while its rows scroll under it. On the left the pane is the
623
+ * page surface, which is the default heading background. On the
624
+ * right the pane is raised, and it sets
625
+ * `--charcuterie-grouped-list-heading-surface` to match.
626
+ */
627
+ export const StickyHeadings: Story = {
628
+ render: () => (
629
+ <StoryGrid columns={2}>
630
+ <StoryCell
631
+ align="stretch"
632
+ label="default: surface-base"
633
+ >
634
+ <section
635
+ aria-label="Pages on a base surface"
636
+ className="overflow-y-auto rounded-lg border border-border-subtle bg-surface-base px-3"
637
+ style={SCROLL_PANE_STYLE}
638
+ >
639
+ <GroupedList
640
+ groups={MANY_SCREEN_GROUPS}
641
+ isHeadingSticky
642
+ size="sm"
643
+ />
644
+ </section>
645
+ </StoryCell>
646
+
647
+ <StoryCell
648
+ align="stretch"
649
+ label="host sets the surface: surface-raised"
650
+ >
651
+ <section
652
+ aria-label="Pages on a raised surface"
653
+ className="overflow-y-auto rounded-lg border border-border-subtle bg-surface-raised px-3"
654
+ style={RAISED_PANE_STYLE}
655
+ >
656
+ <GroupedList
657
+ groups={MANY_SCREEN_GROUPS}
658
+ isHeadingSticky
659
+ size="sm"
660
+ />
661
+ </section>
662
+ </StoryCell>
663
+ </StoryGrid>
664
+ ),
665
+ }
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
+
439
777
  /**
440
778
  * Light and dark side by side, each panel scoped with its own
441
779
  * `data-scheme`, so both read at once whatever the toolbar says.