@charcuterie/ui 4.22.1 → 4.24.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.
@@ -0,0 +1,562 @@
1
+ import { playgroundParameters } from "@charcuterie/storybook-config/story-parameters"
2
+ import type { Meta, StoryObj } from "@storybook/react"
3
+ import type { CSSProperties } from "react"
4
+
5
+ import { Badge } from "../Badge/Badge.tsx"
6
+ import { Button } from "../Button/Button.tsx"
7
+ import {
8
+ ContainerBoard,
9
+ StoryCell,
10
+ StoryGrid,
11
+ } from "../board.storyHelpers.tsx"
12
+ import { EmptyState } from "../EmptyState/EmptyState.tsx"
13
+ import type { GroupedListGroup } from "./GroupedList.tsx"
14
+ import { GroupedList } from "./GroupedList.tsx"
15
+ import { GROUPED_LIST_HEADING_SURFACE_PROPERTY } from "./groupedListStickyHeading.ts"
16
+
17
+ /**
18
+ * Fixture data only. A signage app's pages, grouped by the screen
19
+ * that shows them — the shape castkit's page picker draws.
20
+ */
21
+ const openButton = (name: string, prefix = "") => (
22
+ <Button
23
+ appearance="outline"
24
+ aria-label={`Open ${prefix}${name}`}
25
+ size="sm"
26
+ >
27
+ Open
28
+ </Button>
29
+ )
30
+
31
+ const SIGNAGE_GROUPS: GroupedListGroup[] = [
32
+ {
33
+ heading: "Screens",
34
+ items: [
35
+ {
36
+ actions: openButton("Lobby"),
37
+ badges: <Badge size="sm">PIN</Badge>,
38
+ description:
39
+ "3 views: Welcome, Weather, and Schedule",
40
+ key: "lobby",
41
+ label: "Lobby",
42
+ },
43
+ {
44
+ actions: openButton("Break Room"),
45
+ description: "1 view: Menu",
46
+ key: "break-room",
47
+ label: "Break Room",
48
+ },
49
+ ],
50
+ key: "screens",
51
+ },
52
+ {
53
+ heading: "On the Lobby screen",
54
+ items: [
55
+ {
56
+ actions: openButton("Welcome"),
57
+ description: "/views/welcome",
58
+ key: "welcome",
59
+ label: "Welcome",
60
+ },
61
+ {
62
+ actions: openButton("Weather"),
63
+ description: "/views/weather",
64
+ key: "weather",
65
+ label: "Weather",
66
+ },
67
+ {
68
+ actions: openButton("Schedule"),
69
+ description: "/views/schedule",
70
+ key: "schedule",
71
+ label: "Schedule",
72
+ },
73
+ ],
74
+ key: "screen:lobby",
75
+ },
76
+ {
77
+ heading: "Not on a screen",
78
+ items: [
79
+ {
80
+ actions: openButton("Test Pattern"),
81
+ description: "/views/test-pattern",
82
+ key: "test-pattern",
83
+ label: "Test Pattern",
84
+ },
85
+ ],
86
+ key: "views",
87
+ },
88
+ ]
89
+
90
+ /**
91
+ * Fixture data only. Chores grouped by the team that earns them,
92
+ * each with a summary, badges and an Edit button — the shape
93
+ * tally-marks' card list draws.
94
+ */
95
+ const CHORE_GROUPS: GroupedListGroup[] = [
96
+ {
97
+ heading: "Team Orion",
98
+ items: [
99
+ {
100
+ actions: (
101
+ <Button
102
+ appearance="outline"
103
+ aria-label="Edit Water the ferns"
104
+ size="sm"
105
+ >
106
+ Edit
107
+ </Button>
108
+ ),
109
+ badges: (
110
+ <>
111
+ <Badge
112
+ appearance="soft"
113
+ intent="info"
114
+ size="sm"
115
+ >
116
+ Once a day
117
+ </Badge>
118
+
119
+ <Badge
120
+ appearance="soft"
121
+ intent="warning"
122
+ size="sm"
123
+ >
124
+ after 3:00 PM
125
+ </Badge>
126
+ </>
127
+ ),
128
+ content: "Up to 5 pts a day",
129
+ description: "water-ferns",
130
+ key: "water-ferns",
131
+ label: "Water the ferns",
132
+ },
133
+ {
134
+ actions: (
135
+ <Button
136
+ appearance="outline"
137
+ aria-label="Edit Sort the recycling"
138
+ size="sm"
139
+ >
140
+ Edit
141
+ </Button>
142
+ ),
143
+ badges: (
144
+ <Badge appearance="soft" size="sm">
145
+ No tag
146
+ </Badge>
147
+ ),
148
+ content: "Up to 3 scans a day · 6 pts max",
149
+ description: "sort-recycling",
150
+ key: "sort-recycling",
151
+ label: "Sort the recycling",
152
+ },
153
+ ],
154
+ key: "orion",
155
+ summary: "2 cards · up to 11 pts a day",
156
+ },
157
+ {
158
+ heading: "Team Vega",
159
+ items: [
160
+ {
161
+ actions: (
162
+ <Button
163
+ appearance="outline"
164
+ aria-label="Edit Feed the goldfish"
165
+ size="sm"
166
+ >
167
+ Edit
168
+ </Button>
169
+ ),
170
+ badges: (
171
+ <Badge appearance="soft" intent="info" size="sm">
172
+ Once a day
173
+ </Badge>
174
+ ),
175
+ content: "Up to 2 pts a day",
176
+ description: "feed-goldfish",
177
+ key: "feed-goldfish",
178
+ label: "Feed the goldfish",
179
+ },
180
+ ],
181
+ key: "vega",
182
+ summary: "1 card · up to 2 pts a day",
183
+ },
184
+ ]
185
+
186
+ const LONG_GROUPS: GroupedListGroup[] = [
187
+ {
188
+ heading:
189
+ "On the Conference Center North Atrium Wayfinding screen",
190
+ items: [
191
+ {
192
+ actions: openButton(
193
+ "Quarterly All-Hands Countdown and Seating Chart",
194
+ ),
195
+ badges: (
196
+ <>
197
+ <Badge size="sm">PIN</Badge>
198
+
199
+ <Badge
200
+ appearance="soft"
201
+ intent="info"
202
+ size="sm"
203
+ >
204
+ Rotates every 30 s
205
+ </Badge>
206
+ </>
207
+ ),
208
+ description:
209
+ "/views/quarterly-all-hands-countdown-and-seating-chart-for-the-north-atrium",
210
+ key: "all-hands",
211
+ label:
212
+ "Quarterly All-Hands Countdown and Seating Chart",
213
+ },
214
+ {
215
+ actions: openButton("Map"),
216
+ description: "/views/map",
217
+ key: "map",
218
+ label: "Map",
219
+ },
220
+ ],
221
+ key: "atrium",
222
+ },
223
+ ]
224
+
225
+ /**
226
+ * Fixture data only. Enough screens that the pane has to scroll, so
227
+ * a stuck heading has rows to sit over.
228
+ */
229
+ const MANY_SCREEN_GROUPS: GroupedListGroup[] = [
230
+ "Lobby",
231
+ "Break Room",
232
+ "Loading Dock",
233
+ ].map((screen) => ({
234
+ heading: `On the ${screen} screen`,
235
+ items: ["Welcome", "Weather", "Schedule", "Menu"].map(
236
+ (view) => ({
237
+ actions: openButton(`${view} on ${screen}`),
238
+ description: `/views/${view.toLowerCase()}`,
239
+ key: view,
240
+ label: view,
241
+ }),
242
+ ),
243
+ key: screen,
244
+ }))
245
+
246
+ /**
247
+ * A fixed block size is what makes the pane scroll; the heading
248
+ * sticks to the pane, the nearest scrolling ancestor.
249
+ */
250
+ const SCROLL_PANE_STYLE: CSSProperties = {
251
+ blockSize: "16rem",
252
+ }
253
+
254
+ /**
255
+ * The host says which surface the list sits on. The pane is raised,
256
+ * so the stuck heading is raised too.
257
+ */
258
+ const RAISED_PANE_STYLE = {
259
+ ...SCROLL_PANE_STYLE,
260
+ [GROUPED_LIST_HEADING_SURFACE_PROPERTY]:
261
+ "var(--color-surface-raised)",
262
+ } as CSSProperties
263
+
264
+ /**
265
+ * A cap rather than a width, so the long label clips in every window
266
+ * — an ultrawide grid cell would otherwise hold it whole and the
267
+ * truncation half of the board would show nothing.
268
+ */
269
+ const TRUNCATION_CELL_STYLE = { maxInlineSize: "20rem" }
270
+
271
+ const meta = {
272
+ title: "Components/Data/GroupedList",
273
+ component: GroupedList,
274
+ parameters: { layout: "padded" },
275
+ args: {
276
+ groups: SIGNAGE_GROUPS,
277
+ headingLevel: 3,
278
+ isTextTruncated: true,
279
+ size: "md",
280
+ },
281
+ } satisfies Meta<typeof GroupedList>
282
+
283
+ export default meta
284
+
285
+ type Story = StoryObj<typeof meta>
286
+
287
+ /**
288
+ * A signage app's pages under the screen that shows them. Each row is
289
+ * a name, a muted line, an optional badge, and one action named
290
+ * after its row.
291
+ */
292
+ export const Playground: Story = {
293
+ parameters: playgroundParameters,
294
+ }
295
+
296
+ /**
297
+ * `size` and `isTextTruncated` are the two axes. `sm` is a settings
298
+ * pane or a sidebar; `md` is a page. Wrapping is for a list whose
299
+ * rows are read rather than scanned.
300
+ */
301
+ export const AllVariants: Story = {
302
+ render: () => (
303
+ <StoryGrid columns={2}>
304
+ <StoryCell align="stretch" label="md (default)">
305
+ <GroupedList groups={CHORE_GROUPS} />
306
+ </StoryCell>
307
+
308
+ <StoryCell align="stretch" label="sm">
309
+ <GroupedList groups={CHORE_GROUPS} size="sm" />
310
+ </StoryCell>
311
+
312
+ <StoryCell
313
+ align="stretch"
314
+ label="isTextTruncated (default)"
315
+ >
316
+ <div style={TRUNCATION_CELL_STYLE}>
317
+ <GroupedList groups={LONG_GROUPS} size="sm" />
318
+ </div>
319
+ </StoryCell>
320
+
321
+ <StoryCell
322
+ align="stretch"
323
+ label="isTextTruncated={false}"
324
+ >
325
+ <div style={TRUNCATION_CELL_STYLE}>
326
+ <GroupedList
327
+ groups={LONG_GROUPS}
328
+ isTextTruncated={false}
329
+ size="sm"
330
+ />
331
+ </div>
332
+ </StoryCell>
333
+ </StoryGrid>
334
+ ),
335
+ }
336
+
337
+ /**
338
+ * Every optional part of a row, added one at a time — and the two
339
+ * group states: an empty group is not drawn, and a list with no
340
+ * items at all draws its `emptyState`.
341
+ */
342
+ export const AllStates: Story = {
343
+ render: () => (
344
+ <StoryGrid columns={2}>
345
+ <StoryCell align="stretch" label="label only">
346
+ <GroupedList
347
+ groups={[
348
+ {
349
+ heading: "Label only",
350
+ items: [
351
+ { key: "alpha", label: "Alpha" },
352
+ { key: "beta", label: "Beta" },
353
+ ],
354
+ key: "plain",
355
+ },
356
+ ]}
357
+ size="sm"
358
+ />
359
+ </StoryCell>
360
+
361
+ <StoryCell
362
+ align="stretch"
363
+ label="description, badges, content, actions"
364
+ >
365
+ <GroupedList
366
+ groups={[CHORE_GROUPS[1] as GroupedListGroup]}
367
+ size="sm"
368
+ />
369
+ </StoryCell>
370
+
371
+ <StoryCell
372
+ align="stretch"
373
+ label="an empty group is not drawn"
374
+ >
375
+ <GroupedList
376
+ groups={[
377
+ {
378
+ heading: "Never drawn",
379
+ items: [],
380
+ key: "empty",
381
+ },
382
+ {
383
+ heading: "Drawn",
384
+ items: [{ key: "gamma", label: "Gamma" }],
385
+ key: "drawn",
386
+ },
387
+ ]}
388
+ size="sm"
389
+ />
390
+ </StoryCell>
391
+
392
+ <StoryCell
393
+ align="stretch"
394
+ label="every group empty: emptyState"
395
+ >
396
+ <GroupedList
397
+ emptyState={
398
+ <EmptyState
399
+ description="Try fewer words."
400
+ heading="No page matches"
401
+ headingLevel={3}
402
+ size="sm"
403
+ />
404
+ }
405
+ groups={[
406
+ {
407
+ heading: "Screens",
408
+ items: [],
409
+ key: "screens",
410
+ },
411
+ ]}
412
+ size="sm"
413
+ />
414
+ </StoryCell>
415
+ </StoryGrid>
416
+ ),
417
+ }
418
+
419
+ /**
420
+ * The text column asks for 12rem and grows. Below that, the actions
421
+ * drop under the text instead of crushing the label — decided by the
422
+ * content, with no container query and no media query.
423
+ */
424
+ export const Responsive: Story = {
425
+ render: () => (
426
+ <ContainerBoard>
427
+ <GroupedList groups={CHORE_GROUPS} size="sm" />
428
+ </ContainerBoard>
429
+ ),
430
+ }
431
+
432
+ /**
433
+ * Tab walks the actions in reading order, group by group. Nothing
434
+ * else in the list takes focus: a row is not a control, and its
435
+ * action carries its name ("Open Lobby").
436
+ */
437
+ export const Interactive: Story = {}
438
+
439
+ /**
440
+ * A long label and a long path clip to one line, and the full text
441
+ * is the tooltip while — and only while — it is clipped. The short
442
+ * row underneath has no tooltip at all.
443
+ */
444
+ export const LongText: Story = {
445
+ render: () => (
446
+ <div style={{ inlineSize: "28rem" }}>
447
+ <GroupedList groups={LONG_GROUPS} />
448
+ </div>
449
+ ),
450
+ }
451
+
452
+ /**
453
+ * Nothing to list. The app chooses the wording — "nothing yet" and
454
+ * "nothing matches" are different sentences — and passes it as
455
+ * `emptyState`.
456
+ */
457
+ export const Empty: Story = {
458
+ args: {
459
+ emptyState: (
460
+ <EmptyState
461
+ description="Add screens and views in the management page."
462
+ heading="This server has no pages"
463
+ headingLevel={3}
464
+ size="sm"
465
+ />
466
+ ),
467
+ groups: [],
468
+ },
469
+ }
470
+
471
+ /**
472
+ * The Narrow View: a phone-width window. Badges wrap under the
473
+ * label, and the actions wrap under the text.
474
+ */
475
+ export const NarrowView: Story = {
476
+ args: { groups: CHORE_GROUPS, size: "sm" },
477
+ globals: { viewport: { value: "mobile1" } },
478
+ }
479
+
480
+ /**
481
+ * `isHeadingSticky` pins each heading to the top of the scrolling
482
+ * pane while its rows scroll under it. On the left the pane is the
483
+ * page surface, which is the default heading background. On the
484
+ * right the pane is raised, and it sets
485
+ * `--charcuterie-grouped-list-heading-surface` to match.
486
+ */
487
+ export const StickyHeadings: Story = {
488
+ render: () => (
489
+ <StoryGrid columns={2}>
490
+ <StoryCell
491
+ align="stretch"
492
+ label="default: surface-base"
493
+ >
494
+ <section
495
+ aria-label="Pages on a base surface"
496
+ className="overflow-y-auto rounded-lg border border-border-subtle bg-surface-base px-3"
497
+ style={SCROLL_PANE_STYLE}
498
+ >
499
+ <GroupedList
500
+ groups={MANY_SCREEN_GROUPS}
501
+ isHeadingSticky
502
+ size="sm"
503
+ />
504
+ </section>
505
+ </StoryCell>
506
+
507
+ <StoryCell
508
+ align="stretch"
509
+ label="host sets the surface: surface-raised"
510
+ >
511
+ <section
512
+ aria-label="Pages on a raised surface"
513
+ className="overflow-y-auto rounded-lg border border-border-subtle bg-surface-raised px-3"
514
+ style={RAISED_PANE_STYLE}
515
+ >
516
+ <GroupedList
517
+ groups={MANY_SCREEN_GROUPS}
518
+ isHeadingSticky
519
+ size="sm"
520
+ />
521
+ </section>
522
+ </StoryCell>
523
+ </StoryGrid>
524
+ ),
525
+ }
526
+
527
+ /**
528
+ * Light and dark side by side, each panel scoped with its own
529
+ * `data-scheme`, so both read at once whatever the toolbar says.
530
+ */
531
+ export const ColorSchemes: Story = {
532
+ render: () => (
533
+ <StoryGrid columns={2}>
534
+ {(["light", "dark"] as const).map((scheme) => (
535
+ <StoryCell
536
+ align="stretch"
537
+ key={scheme}
538
+ label={scheme}
539
+ >
540
+ <div
541
+ className="rounded-lg bg-surface-base p-4 text-content-primary"
542
+ data-scheme={scheme}
543
+ >
544
+ <GroupedList
545
+ groups={SIGNAGE_GROUPS.map((group) => ({
546
+ ...group,
547
+ items: group.items.map((item) => ({
548
+ ...item,
549
+ actions: openButton(
550
+ item.label,
551
+ `${scheme} `,
552
+ ),
553
+ })),
554
+ }))}
555
+ size="sm"
556
+ />
557
+ </div>
558
+ </StoryCell>
559
+ ))}
560
+ </StoryGrid>
561
+ ),
562
+ }