@charcuterie/ui 4.13.1 → 4.15.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 (54) hide show
  1. package/README.md +2 -0
  2. package/dist/Chart/Chart.d.ts +10 -0
  3. package/dist/Chart/Chart.d.ts.map +1 -0
  4. package/dist/Chart/Chart.js +42 -0
  5. package/dist/Chart/Chart.js.map +1 -0
  6. package/dist/ScheduleBoard/ScheduleBoard.d.ts +33 -0
  7. package/dist/ScheduleBoard/ScheduleBoard.d.ts.map +1 -0
  8. package/dist/ScheduleBoard/ScheduleBoard.js +30 -0
  9. package/dist/ScheduleBoard/ScheduleBoard.js.map +1 -0
  10. package/dist/ScheduleBoard/ScheduleDayCards.d.ts +9 -0
  11. package/dist/ScheduleBoard/ScheduleDayCards.d.ts.map +1 -0
  12. package/dist/ScheduleBoard/ScheduleDayCards.js +8 -0
  13. package/dist/ScheduleBoard/ScheduleDayCards.js.map +1 -0
  14. package/dist/ScheduleBoard/ScheduleHourGrid.d.ts +12 -0
  15. package/dist/ScheduleBoard/ScheduleHourGrid.d.ts.map +1 -0
  16. package/dist/ScheduleBoard/ScheduleHourGrid.js +40 -0
  17. package/dist/ScheduleBoard/ScheduleHourGrid.js.map +1 -0
  18. package/dist/ScheduleBoard/ScheduleItem.d.ts +20 -0
  19. package/dist/ScheduleBoard/ScheduleItem.d.ts.map +1 -0
  20. package/dist/ScheduleBoard/ScheduleItem.js +18 -0
  21. package/dist/ScheduleBoard/ScheduleItem.js.map +1 -0
  22. package/dist/ScheduleBoard/scheduleGeometry.d.ts +12 -0
  23. package/dist/ScheduleBoard/scheduleGeometry.d.ts.map +1 -0
  24. package/dist/ScheduleBoard/scheduleGeometry.js +35 -0
  25. package/dist/ScheduleBoard/scheduleGeometry.js.map +1 -0
  26. package/dist/WeekdayIndicator/WeekdayIndicator.d.ts +12 -0
  27. package/dist/WeekdayIndicator/WeekdayIndicator.d.ts.map +1 -0
  28. package/dist/WeekdayIndicator/WeekdayIndicator.js +18 -0
  29. package/dist/WeekdayIndicator/WeekdayIndicator.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/Chart/Chart.mdx +16 -0
  36. package/src/Chart/Chart.stories.tsx +92 -0
  37. package/src/Chart/Chart.test.tsx +37 -0
  38. package/src/Chart/Chart.tsx +129 -0
  39. package/src/ScheduleBoard/ScheduleBoard.mdx +29 -0
  40. package/src/ScheduleBoard/ScheduleBoard.stories.tsx +187 -0
  41. package/src/ScheduleBoard/ScheduleBoard.test.tsx +98 -0
  42. package/src/ScheduleBoard/ScheduleBoard.tsx +215 -0
  43. package/src/ScheduleBoard/ScheduleDayCards.tsx +44 -0
  44. package/src/ScheduleBoard/ScheduleHourGrid.tsx +146 -0
  45. package/src/ScheduleBoard/ScheduleItem.tsx +88 -0
  46. package/src/ScheduleBoard/scheduleGeometry.test.ts +23 -0
  47. package/src/ScheduleBoard/scheduleGeometry.ts +56 -0
  48. package/src/VirtualizedGrid/VirtualizedGrid.stories.tsx +6 -0
  49. package/src/WeekdayIndicator/WeekdayIndicator.mdx +29 -0
  50. package/src/WeekdayIndicator/WeekdayIndicator.stories.tsx +71 -0
  51. package/src/WeekdayIndicator/WeekdayIndicator.test.tsx +34 -0
  52. package/src/WeekdayIndicator/WeekdayIndicator.tsx +57 -0
  53. package/src/index.ts +12 -0
  54. package/src/sourceRules.test.ts +4 -1
@@ -0,0 +1,215 @@
1
+ import type { ReactNode } from "react"
2
+
3
+ import { AdaptiveGrid } from "../AdaptiveGrid/AdaptiveGrid.tsx"
4
+ import { useTrackMeasurements } from "../LaneTimeline/useTrackMeasurements.ts"
5
+ import { toClassName } from "../toClassName.ts"
6
+ import { ScheduleDayCards } from "./ScheduleDayCards.tsx"
7
+ import { ScheduleHourGrid } from "./ScheduleHourGrid.tsx"
8
+ import {
9
+ type ScheduleEntry,
10
+ ScheduleItem,
11
+ } from "./ScheduleItem.tsx"
12
+
13
+ export type ScheduleDay = {
14
+ key: string
15
+ label: string
16
+ dateLabel: string
17
+ isCurrent?: boolean
18
+ isQuiet?: boolean
19
+ emptyLabel?: string
20
+ }
21
+
22
+ export type ScheduleBand = {
23
+ key: string
24
+ label: string
25
+ detail?: string
26
+ startMinute: number
27
+ endMinute: number
28
+ }
29
+ export type ScheduleLayout = "bands" | "hours" | "cards"
30
+ export type ScheduleBoardProps = {
31
+ label: string
32
+ days: readonly ScheduleDay[]
33
+ items: readonly ScheduleEntry[]
34
+ bands: readonly ScheduleBand[]
35
+ layout?: ScheduleLayout
36
+ className?: string
37
+ onSelect?: (key: string) => void
38
+ formatHour?: (minute: number) => string
39
+ /** Axis height, in pixels. A minimum preserves short appointments. */
40
+ hourGridBlockSize?: number
41
+ }
42
+
43
+ /** Aligned time bands or an exact hour axis; folds to day cards on measured width. */
44
+ export const ScheduleBoard = ({
45
+ label,
46
+ days,
47
+ items,
48
+ bands,
49
+ layout = "bands",
50
+ className,
51
+ onSelect,
52
+ formatHour = (minute) => `${Math.floor(minute / 60)}:00`,
53
+ hourGridBlockSize = 480,
54
+ }: ScheduleBoardProps): ReactNode => {
55
+ const { trackRef, inlineSize, fontSize, isMeasured } =
56
+ useTrackMeasurements()
57
+ const isFolded =
58
+ isMeasured &&
59
+ inlineSize < (days.length * 8 + 4) * fontSize
60
+ const template = `5.5rem ${days.map((day) => (day.isQuiet ? "minmax(0, .65fr)" : "minmax(0, 1fr)")).join(" ")}`
61
+ const dayHeader = (
62
+ <div
63
+ className="grid gap-2 border-b border-border-subtle pb-2"
64
+ style={{ gridTemplateColumns: template }}
65
+ >
66
+ <span />
67
+ {days.map((day) => (
68
+ <div
69
+ className={toClassName(
70
+ "min-w-0 wrap-anywhere",
71
+ day.isCurrent
72
+ ? "text-intent-accent-content"
73
+ : "text-content-secondary",
74
+ )}
75
+ key={day.key}
76
+ >
77
+ <span className="block text-sm">
78
+ {day.label}
79
+ {day.isCurrent ? " · Today" : ""}
80
+ </span>
81
+ <strong className="text-2xl leading-tight">
82
+ {day.dateLabel}
83
+ </strong>
84
+ </div>
85
+ ))}
86
+ </div>
87
+ )
88
+ const untimed = items.filter(
89
+ (item) => item.startMinute === null,
90
+ )
91
+ return (
92
+ <section
93
+ aria-label={label}
94
+ className={toClassName(
95
+ "@container flex min-w-0 flex-col gap-2",
96
+ className,
97
+ )}
98
+ >
99
+ <div
100
+ aria-hidden="true"
101
+ className="h-0"
102
+ ref={trackRef}
103
+ />
104
+ {layout === "cards" || isFolded ? (
105
+ <AdaptiveGrid
106
+ itemBlockSize={320}
107
+ itemCount={days.length}
108
+ maxColumns={7}
109
+ minColumnInlineSize={230}
110
+ >
111
+ {days.map((day) => (
112
+ <ScheduleDayCards
113
+ day={day}
114
+ items={items.filter(
115
+ (item) => item.dayKey === day.key,
116
+ )}
117
+ key={day.key}
118
+ onSelect={onSelect}
119
+ />
120
+ ))}
121
+ </AdaptiveGrid>
122
+ ) : (
123
+ <>
124
+ {dayHeader}
125
+ {untimed.length > 0 ? (
126
+ <div
127
+ className="grid gap-2 border-b border-border-subtle pb-2"
128
+ style={{ gridTemplateColumns: template }}
129
+ >
130
+ <span className="text-xs text-content-muted">
131
+ All day / due
132
+ </span>
133
+ {days.map((day) => (
134
+ <div
135
+ className="flex min-w-0 flex-col gap-2"
136
+ key={day.key}
137
+ >
138
+ {untimed
139
+ .filter(
140
+ (item) => item.dayKey === day.key,
141
+ )
142
+ .map((item) => (
143
+ <ScheduleItem
144
+ item={item}
145
+ key={item.key}
146
+ onSelect={onSelect}
147
+ />
148
+ ))}
149
+ </div>
150
+ ))}
151
+ </div>
152
+ ) : null}
153
+ {layout === "hours" ? (
154
+ <ScheduleHourGrid
155
+ blockSize={hourGridBlockSize}
156
+ days={days}
157
+ formatHour={formatHour}
158
+ items={items}
159
+ onSelect={onSelect}
160
+ template={template}
161
+ />
162
+ ) : (
163
+ bands.map((band) => (
164
+ // biome-ignore lint/a11y/useSemanticElements: this names schedule entries, not form controls.
165
+ <div
166
+ aria-label={band.label}
167
+ className="grid gap-2 border-b border-border-subtle pb-2"
168
+ key={band.key}
169
+ role="group"
170
+ style={{ gridTemplateColumns: template }}
171
+ >
172
+ <div className="min-w-0 wrap-anywhere text-xs text-content-secondary">
173
+ <strong className="block">
174
+ {band.label}
175
+ </strong>
176
+ <span className="text-content-muted">
177
+ {band.detail}
178
+ </span>
179
+ </div>
180
+ {days.map((day) => (
181
+ <div
182
+ className="flex min-w-0 flex-col gap-2"
183
+ key={day.key}
184
+ >
185
+ {items
186
+ .filter(
187
+ (item) =>
188
+ item.dayKey === day.key &&
189
+ item.startMinute !== null &&
190
+ item.startMinute >=
191
+ band.startMinute &&
192
+ item.startMinute < band.endMinute,
193
+ )
194
+ .sort(
195
+ (first, second) =>
196
+ (first.startMinute ?? 0) -
197
+ (second.startMinute ?? 0),
198
+ )
199
+ .map((item) => (
200
+ <ScheduleItem
201
+ item={item}
202
+ key={item.key}
203
+ onSelect={onSelect}
204
+ />
205
+ ))}
206
+ </div>
207
+ ))}
208
+ </div>
209
+ ))
210
+ )}
211
+ </>
212
+ )}
213
+ </section>
214
+ )
215
+ }
@@ -0,0 +1,44 @@
1
+ import type { ReactNode } from "react"
2
+
3
+ import { Card } from "../Card/Card.tsx"
4
+ import type { ScheduleDay } from "./ScheduleBoard.tsx"
5
+ import {
6
+ type ScheduleEntry,
7
+ ScheduleItem,
8
+ } from "./ScheduleItem.tsx"
9
+
10
+ export const ScheduleDayCards = ({
11
+ day,
12
+ items,
13
+ onSelect,
14
+ }: {
15
+ day: ScheduleDay
16
+ items: readonly ScheduleEntry[]
17
+ onSelect?: (key: string) => void
18
+ }): ReactNode => (
19
+ <Card padding="sm">
20
+ <h3 className="font-semibold text-md">
21
+ {day.label}, {day.dateLabel}
22
+ {day.isCurrent ? " · Today" : ""}
23
+ </h3>
24
+ {items.length === 0 ? (
25
+ <p className="text-sm text-content-muted">
26
+ {day.emptyLabel ?? "Nothing scheduled"}
27
+ </p>
28
+ ) : (
29
+ [...items]
30
+ .sort(
31
+ (first, second) =>
32
+ (first.startMinute ?? -1) -
33
+ (second.startMinute ?? -1),
34
+ )
35
+ .map((item) => (
36
+ <ScheduleItem
37
+ item={item}
38
+ key={item.key}
39
+ onSelect={onSelect}
40
+ />
41
+ ))
42
+ )}
43
+ </Card>
44
+ )
@@ -0,0 +1,146 @@
1
+ import type { ReactNode } from "react"
2
+
3
+ import type { ScheduleDay } from "./ScheduleBoard.tsx"
4
+ import {
5
+ type ScheduleEntry,
6
+ ScheduleItem,
7
+ } from "./ScheduleItem.tsx"
8
+ import { placeScheduleIntervals } from "./scheduleGeometry.ts"
9
+
10
+ export const ScheduleHourGrid = ({
11
+ blockSize,
12
+ days,
13
+ formatHour,
14
+ items,
15
+ onSelect,
16
+ template,
17
+ }: {
18
+ blockSize: number
19
+ days: readonly ScheduleDay[]
20
+ formatHour: (minute: number) => string
21
+ items: readonly ScheduleEntry[]
22
+ onSelect?: (key: string) => void
23
+ template: string
24
+ }): ReactNode => {
25
+ const timed = items.filter(
26
+ (item) => item.startMinute !== null,
27
+ )
28
+ const start =
29
+ Math.floor(
30
+ Math.min(
31
+ 480,
32
+ ...timed.map((item) => item.startMinute ?? 480),
33
+ ) / 60,
34
+ ) * 60
35
+ const end = Math.min(
36
+ 1440,
37
+ Math.ceil(
38
+ Math.max(
39
+ 1080,
40
+ ...timed.map(
41
+ (item) =>
42
+ item.endMinute ??
43
+ (item.startMinute ?? 1080) + 30,
44
+ ),
45
+ ) / 60,
46
+ ) * 60,
47
+ )
48
+ const height = Math.max(360, blockSize)
49
+ const scale = height / Math.max(60, end - start)
50
+ const hours = Array.from(
51
+ { length: (end - start) / 60 + 1 },
52
+ (_unused, index) => start + index * 60,
53
+ )
54
+ return (
55
+ <div
56
+ className="grid gap-2 pb-6"
57
+ style={{ gridTemplateColumns: template }}
58
+ >
59
+ <div
60
+ className="relative text-xs text-content-muted tabular-nums"
61
+ style={{ blockSize: height }}
62
+ >
63
+ {hours.map((minute) => (
64
+ <span
65
+ className="absolute start-0"
66
+ key={minute}
67
+ style={{
68
+ insetBlockStart: (minute - start) * scale,
69
+ }}
70
+ >
71
+ {formatHour(minute)}
72
+ </span>
73
+ ))}
74
+ </div>
75
+ {days.map((day) => {
76
+ const entries = timed.filter(
77
+ (item) => item.dayKey === day.key,
78
+ )
79
+ // Minimum painted height participates in packing, so short adjacent items cannot hide each other.
80
+ const placed = placeScheduleIntervals(
81
+ entries.map((item) => ({
82
+ key: item.key,
83
+ startMinute: item.startMinute ?? 0,
84
+ endMinute: Math.max(
85
+ item.endMinute ??
86
+ (item.startMinute ?? 0) + 30,
87
+ (item.startMinute ?? 0) + 30 / scale,
88
+ ),
89
+ })),
90
+ )
91
+ return (
92
+ // biome-ignore lint/a11y/useSemanticElements: this names schedule entries, not form controls.
93
+ <div
94
+ aria-label={`${day.label}, ${day.dateLabel}`}
95
+ className="relative min-w-0 border-s border-border-subtle"
96
+ key={day.key}
97
+ role="group"
98
+ style={{ blockSize: height }}
99
+ >
100
+ {hours.map((minute) => (
101
+ <div
102
+ aria-hidden="true"
103
+ className="absolute w-full border-t border-border-subtle"
104
+ key={minute}
105
+ style={{
106
+ insetBlockStart: (minute - start) * scale,
107
+ }}
108
+ />
109
+ ))}
110
+ {placed.map((position) => {
111
+ const item = entries.find(
112
+ (entry) => entry.key === position.key,
113
+ )
114
+ return item ? (
115
+ <div
116
+ className="absolute min-w-0 pe-1"
117
+ key={item.key}
118
+ style={{
119
+ insetBlockStart:
120
+ (position.startMinute - start) *
121
+ scale,
122
+ blockSize: Math.max(
123
+ 30,
124
+ ((item.endMinute ??
125
+ position.startMinute + 30) -
126
+ position.startMinute) *
127
+ scale,
128
+ ),
129
+ insetInlineStart: `${(position.column / position.columnCount) * 100}%`,
130
+ inlineSize: `${100 / position.columnCount}%`,
131
+ }}
132
+ >
133
+ <ScheduleItem
134
+ isClipped
135
+ item={item}
136
+ onSelect={onSelect}
137
+ />
138
+ </div>
139
+ ) : null
140
+ })}
141
+ </div>
142
+ )
143
+ })}
144
+ </div>
145
+ )
146
+ }
@@ -0,0 +1,88 @@
1
+ import type { CategoricalIndex } from "@charcuterie/tokens"
2
+ import type { ReactNode } from "react"
3
+
4
+ import {
5
+ CATEGORICAL_APPEARANCE_CLASS,
6
+ CATEGORICAL_HOVER_CLASS,
7
+ } from "../categoricalStyles.ts"
8
+ import { FOCUS_RING_CLASS } from "../intentStyles.ts"
9
+ import { toClassName } from "../toClassName.ts"
10
+
11
+ export type ScheduleEntry = {
12
+ key: string
13
+ dayKey: string
14
+ title: string
15
+ timeLabel: string
16
+ sourceLabel: string
17
+ sourceMarker?: string
18
+ categorical?: CategoricalIndex
19
+ /** Minutes since the start of this displayed day. Null is the untimed row. */
20
+ startMinute: number | null
21
+ endMinute: number | null
22
+ }
23
+
24
+ export const ScheduleItem = ({
25
+ item,
26
+ isClipped = false,
27
+ onSelect,
28
+ }: {
29
+ item: ScheduleEntry
30
+ isClipped?: boolean
31
+ onSelect?: (key: string) => void
32
+ }): ReactNode => {
33
+ const categorical = item.categorical ?? 1
34
+ const label = `${item.timeLabel}, ${item.title}, ${item.sourceLabel}`
35
+ const content = (
36
+ <>
37
+ <span className="flex items-baseline justify-between gap-1">
38
+ <strong className="min-w-0 wrap-anywhere text-sm leading-tight tabular-nums">
39
+ {item.timeLabel}
40
+ </strong>
41
+ {item.sourceMarker ? (
42
+ <span aria-hidden="true" className="text-xs">
43
+ {item.sourceMarker}
44
+ </span>
45
+ ) : null}
46
+ </span>
47
+ <span
48
+ className={
49
+ isClipped
50
+ ? "line-clamp-2 text-sm leading-tight"
51
+ : "wrap-anywhere text-sm leading-tight"
52
+ }
53
+ >
54
+ {item.title}
55
+ </span>
56
+ </>
57
+ )
58
+ const className = toClassName(
59
+ "flex w-full min-w-0 flex-col gap-1 overflow-hidden rounded-md border p-2 text-start",
60
+ isClipped ? "h-full" : undefined,
61
+ CATEGORICAL_APPEARANCE_CLASS[categorical].soft,
62
+ onSelect
63
+ ? CATEGORICAL_HOVER_CLASS[categorical].soft
64
+ : undefined,
65
+ FOCUS_RING_CLASS,
66
+ )
67
+ return onSelect ? (
68
+ <button
69
+ aria-label={label}
70
+ className={className}
71
+ onClick={() => {
72
+ onSelect(item.key)
73
+ }}
74
+ title={label}
75
+ type="button"
76
+ >
77
+ {content}
78
+ </button>
79
+ ) : (
80
+ <article
81
+ aria-label={label}
82
+ className={className}
83
+ title={label}
84
+ >
85
+ {content}
86
+ </article>
87
+ )
88
+ }
@@ -0,0 +1,23 @@
1
+ import { expect, test } from "vitest"
2
+ import { placeScheduleIntervals } from "./scheduleGeometry.ts"
3
+
4
+ test("a chained overlap cluster shares its maximum width, then resets", () => {
5
+ const placed = placeScheduleIntervals([
6
+ { key: "a", startMinute: 540, endMinute: 660 },
7
+ { key: "b", startMinute: 600, endMinute: 720 },
8
+ { key: "c", startMinute: 660, endMinute: 780 },
9
+ { key: "d", startMinute: 780, endMinute: 840 },
10
+ ])
11
+ expect(
12
+ placed.map(({ key, column, columnCount }) => [
13
+ key,
14
+ column,
15
+ columnCount,
16
+ ]),
17
+ ).toEqual([
18
+ ["a", 0, 2],
19
+ ["b", 1, 2],
20
+ ["c", 0, 2],
21
+ ["d", 0, 1],
22
+ ])
23
+ })
@@ -0,0 +1,56 @@
1
+ export type ScheduleInterval = {
2
+ key: string
3
+ startMinute: number
4
+ endMinute: number
5
+ }
6
+
7
+ export type PlacedScheduleInterval = ScheduleInterval & {
8
+ column: number
9
+ columnCount: number
10
+ }
11
+
12
+ /** Pack each connected overlap cluster independently; adjacent intervals share first column. */
13
+ export const placeScheduleIntervals = (
14
+ items: readonly ScheduleInterval[],
15
+ ): PlacedScheduleInterval[] => {
16
+ const result: PlacedScheduleInterval[] = []
17
+ let cluster: PlacedScheduleInterval[] = []
18
+ let ends: number[] = []
19
+ let clusterEnd = -1
20
+ const flush = () => {
21
+ result.push(
22
+ ...cluster.map((item) => ({
23
+ ...item,
24
+ columnCount: ends.length,
25
+ })),
26
+ )
27
+ cluster = []
28
+ ends = []
29
+ }
30
+ for (const item of [...items].sort(
31
+ (first, second) =>
32
+ first.startMinute - second.startMinute ||
33
+ second.endMinute - first.endMinute ||
34
+ first.key.localeCompare(second.key),
35
+ )) {
36
+ if (item.startMinute >= clusterEnd) flush()
37
+ const free = ends.findIndex(
38
+ (end) => end <= item.startMinute,
39
+ )
40
+ const column = free < 0 ? ends.length : free
41
+ const endMinute = Math.max(
42
+ item.startMinute + 1,
43
+ item.endMinute,
44
+ )
45
+ ends[column] = endMinute
46
+ clusterEnd = Math.max(...ends)
47
+ cluster.push({
48
+ ...item,
49
+ endMinute,
50
+ column,
51
+ columnCount: 1,
52
+ })
53
+ }
54
+ flush()
55
+ return result
56
+ }
@@ -286,6 +286,12 @@ export const Responsive: Story = {
286
286
  args: {
287
287
  blockSizeResolver: fixedBlockSize(600),
288
288
  minColumnInlineSize: 160,
289
+ // Every row mounted. This story is about the column count, not
290
+ // the window, and the VRT shot of the whole root is taller than
291
+ // the viewport: the grids re-window while it is taken, so which
292
+ // rows were drawn depended on the runner's timing (release PR
293
+ // #319 drew 13 rows where the baseline drew 5).
294
+ overscanRows: 60,
289
295
  },
290
296
  render: (gridProps) => (
291
297
  <div className="flex flex-wrap items-start gap-6">
@@ -0,0 +1,29 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+ import * as Stories from "./WeekdayIndicator.stories";
3
+
4
+ <Meta of={Stories} />
5
+
6
+ # WeekdayIndicator
7
+
8
+ Seven labeled circles in Monday-first order. Pass zero-based weekday offsets (Monday 0, Sunday 6). The accessible name spells out selected days. The app decides whether the pattern is a recurrence rule or only the weekdays observed in the displayed period, and supplies the label accordingly. This is a display, not a weekday picker.
9
+
10
+ <Canvas of={Stories.Playground} />
11
+
12
+ ## Props
13
+
14
+ <Controls />
15
+
16
+ ## Layouts and states
17
+
18
+ <Canvas of={Stories.AllVariants} />
19
+ <Canvas of={Stories.AllStates} />
20
+
21
+ ## Narrow containers
22
+
23
+ <Canvas of={Stories.Responsive} />
24
+
25
+ ## Keyboard interaction
26
+
27
+ Tab reaches each action. Enter or Space activates it. The display itself adds no tab stops.
28
+
29
+ <Canvas of={Stories.Interactive} />
@@ -0,0 +1,71 @@
1
+ import { playgroundParameters } from "@charcuterie/storybook-config/story-parameters"
2
+ import type { Meta, StoryObj } from "@storybook/react"
3
+ import { useState } from "react"
4
+ import { Button } from "../Button/Button.tsx"
5
+ import { ContainerBoard } from "../board.storyHelpers.tsx"
6
+ import { WeekdayIndicator } from "./WeekdayIndicator.tsx"
7
+
8
+ const meta = {
9
+ title: "Components/Data/WeekdayIndicator",
10
+ component: WeekdayIndicator,
11
+ args: { days: [0, 1, 2, 3], label: "Repeats" },
12
+ argTypes: {
13
+ categorical: {
14
+ control: "select",
15
+ options: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
16
+ },
17
+ },
18
+ } satisfies Meta<typeof WeekdayIndicator>
19
+ export default meta
20
+ type Story = StoryObj<typeof meta>
21
+ export const Playground: Story = {
22
+ parameters: playgroundParameters,
23
+ }
24
+ export const AllVariants: Story = {
25
+ render: (args) => (
26
+ <div className="flex flex-col gap-4">
27
+ {([1, 3, 5, 7, 9] as const).map((categorical) => (
28
+ <WeekdayIndicator
29
+ {...args}
30
+ categorical={categorical}
31
+ key={categorical}
32
+ />
33
+ ))}
34
+ </div>
35
+ ),
36
+ }
37
+ export const AllStates: Story = {
38
+ render: () => (
39
+ <div className="flex flex-col gap-4">
40
+ <WeekdayIndicator days={[]} />
41
+ <WeekdayIndicator days={[0, 1, 2, 3, 4, 5, 6]} />
42
+ <WeekdayIndicator days={[5, 6]} />
43
+ </div>
44
+ ),
45
+ }
46
+ export const Responsive: Story = {
47
+ render: (args) => (
48
+ <ContainerBoard>
49
+ <WeekdayIndicator {...args} />
50
+ </ContainerBoard>
51
+ ),
52
+ }
53
+ export const Interactive: Story = {
54
+ render: () => {
55
+ const [isWeekend, setIsWeekend] = useState(false)
56
+ return (
57
+ <div className="flex flex-col items-start gap-3">
58
+ <Button
59
+ onClick={() => {
60
+ setIsWeekend(!isWeekend)
61
+ }}
62
+ >
63
+ Switch pattern
64
+ </Button>
65
+ <WeekdayIndicator
66
+ days={isWeekend ? [5, 6] : [0, 1, 2, 3, 4]}
67
+ />
68
+ </div>
69
+ )
70
+ },
71
+ }