@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,92 @@
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 {
6
+ ContainerBoard,
7
+ StoryCell,
8
+ StoryGrid,
9
+ } from "../board.storyHelpers.tsx"
10
+ import { Chart } from "./Chart.tsx"
11
+
12
+ const meta = {
13
+ title: "Components/Data/Chart",
14
+ component: Chart,
15
+ args: {
16
+ title: "Daily progress",
17
+ labels: ["Mon", "Tue", "Wed", "Thu", "Fri"],
18
+ series: [
19
+ {
20
+ id: "net",
21
+ label: "Net points",
22
+ values: [300, 520, -50, null, 600],
23
+ color: "var(--color-intent-accent-solid)",
24
+ },
25
+ ],
26
+ },
27
+ } satisfies Meta<typeof Chart>
28
+ export default meta
29
+ type Story = StoryObj<typeof meta>
30
+ export const Playground: Story = {
31
+ parameters: playgroundParameters,
32
+ }
33
+ export const AllVariants: Story = {
34
+ render: (props) => (
35
+ <StoryGrid columns={2}>
36
+ {(["bar", "line"] as const).map((kind) => (
37
+ <StoryCell align="stretch" key={kind} label={kind}>
38
+ <Chart {...props} kind={kind} />
39
+ </StoryCell>
40
+ ))}
41
+ </StoryGrid>
42
+ ),
43
+ }
44
+ export const AllStates: Story = {
45
+ render: (props) => (
46
+ <StoryGrid columns={2}>
47
+ {[
48
+ [0, 0],
49
+ [null, null],
50
+ [50, -50],
51
+ [20, 20],
52
+ ].map((values, index) => (
53
+ <StoryCell
54
+ align="stretch"
55
+ key={JSON.stringify(values)}
56
+ label={`State ${index + 1}`}
57
+ >
58
+ <Chart
59
+ {...props}
60
+ labels={["Mon", "Tue"]}
61
+ series={[
62
+ { id: "a", label: "Net points", values },
63
+ ]}
64
+ />
65
+ </StoryCell>
66
+ ))}
67
+ </StoryGrid>
68
+ ),
69
+ }
70
+ export const Responsive: Story = {
71
+ render: (props) => (
72
+ <ContainerBoard>
73
+ {() => <Chart {...props} />}
74
+ </ContainerBoard>
75
+ ),
76
+ }
77
+ export const Interactive: Story = {
78
+ render: (props) => {
79
+ const [isTableVisible, setIsTableVisible] =
80
+ useState(false)
81
+ return (
82
+ <>
83
+ <Button
84
+ onClick={() => setIsTableVisible(!isTableVisible)}
85
+ >
86
+ {isTableVisible ? "Hide values" : "Show values"}
87
+ </Button>
88
+ <Chart {...props} isTableVisible={isTableVisible} />
89
+ </>
90
+ )
91
+ },
92
+ }
@@ -0,0 +1,37 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, userEvent, within } from "storybook/test"
3
+ import { test } from "vitest"
4
+ import { mountStory } from "../mountStory.testHelpers.ts"
5
+ import { expectAgentDrivable } from "../testing/index.ts"
6
+ import * as stories from "./Chart.stories.tsx"
7
+
8
+ const { Playground, Interactive } = composeStories(stories)
9
+ test("the plot has an accessible name and a named legend", async () => {
10
+ const { canvas } = await mountStory(Playground)
11
+ expectAgentDrivable(canvas, {
12
+ role: "img",
13
+ name: "Daily progress",
14
+ })
15
+ await expect(
16
+ canvas.getByRole("list", {
17
+ name: "Daily progress legend",
18
+ }),
19
+ ).toBeVisible()
20
+ })
21
+ test("the keyboard exposes all values, including missing data, in a real table", async () => {
22
+ const { canvas } = await mountStory(Interactive)
23
+ const toggle = expectAgentDrivable(canvas, {
24
+ role: "button",
25
+ name: "Show values",
26
+ })
27
+ toggle.focus()
28
+ await userEvent.keyboard("{Enter}")
29
+ const table = canvas.getByRole("table", {
30
+ name: "Daily progress values",
31
+ })
32
+ await expect(table).toBeVisible()
33
+ await expect(
34
+ within(table).getByText("Unavailable"),
35
+ ).toBeVisible()
36
+ await expect(within(table).getByText("-50")).toBeVisible()
37
+ })
@@ -0,0 +1,129 @@
1
+ // biome-ignore-all lint/security/noDangerouslySetInnerHtml: The portable renderer escapes text and restricts color references; security tests cover this boundary.
2
+ import {
3
+ type ChartOptions,
4
+ renderChartSvg,
5
+ } from "@charcuterie/logic/core"
6
+ import {
7
+ type ReactNode,
8
+ useEffect,
9
+ useRef,
10
+ useState,
11
+ } from "react"
12
+ import { toClassName } from "../toClassName.ts"
13
+
14
+ export type ChartProps = ChartOptions & {
15
+ className?: string
16
+ /** A text table exposes every value without relying on color, hover, or SVG. */
17
+ isTableVisible?: boolean
18
+ }
19
+
20
+ /** The same escaped SVG renderer is available without React for kiosk/image consumers. */
21
+ export const Chart = ({
22
+ className,
23
+ isTableVisible = false,
24
+ ...options
25
+ }: ChartProps): ReactNode => {
26
+ const container = useRef<HTMLDivElement>(null)
27
+ const [measuredWidth, setMeasuredWidth] = useState(
28
+ options.width ?? 640,
29
+ )
30
+ useEffect(() => {
31
+ const element = container.current
32
+ if (!element || options.width !== undefined) return
33
+ const frame = { id: 0 }
34
+ const observer = new ResizeObserver(([entry]) => {
35
+ cancelAnimationFrame(frame.id)
36
+ frame.id = requestAnimationFrame(() => {
37
+ if (entry.contentRect.width > 0) {
38
+ setMeasuredWidth(entry.contentRect.width)
39
+ }
40
+ })
41
+ })
42
+ observer.observe(element)
43
+ return () => {
44
+ observer.disconnect()
45
+ cancelAnimationFrame(frame.id)
46
+ }
47
+ }, [options.width])
48
+ return (
49
+ <figure
50
+ className={toClassName(
51
+ "min-w-0 text-content-primary",
52
+ className,
53
+ )}
54
+ >
55
+ <figcaption className="mb-2 text-sm font-semibold">
56
+ {options.title}
57
+ </figcaption>
58
+ <div
59
+ ref={container}
60
+ style={{ height: options.height ?? 240 }}
61
+ dangerouslySetInnerHTML={{
62
+ __html: renderChartSvg({
63
+ ...options,
64
+ width: options.width ?? measuredWidth,
65
+ }),
66
+ }}
67
+ />
68
+ <ul
69
+ aria-label={`${options.title} legend`}
70
+ className="mt-2 flex flex-wrap gap-3 text-xs"
71
+ >
72
+ {options.series.map((entry, index) => (
73
+ <li
74
+ className="min-w-0 wrap-anywhere"
75
+ key={entry.id}
76
+ >
77
+ <span
78
+ aria-hidden="true"
79
+ style={{
80
+ color: entry.color ?? "currentColor",
81
+ borderTop: `3px ${index % 3 === 0 ? "solid" : index % 3 === 1 ? "dashed" : "dotted"} currentColor`,
82
+ display: "inline-block",
83
+ width: "1rem",
84
+ marginRight: "0.4rem",
85
+ }}
86
+ />
87
+ {entry.label}
88
+ </li>
89
+ ))}
90
+ </ul>
91
+ {options.description && (
92
+ <p className="mt-2 text-xs text-content-muted">
93
+ {options.description}
94
+ </p>
95
+ )}
96
+ {isTableVisible && (
97
+ <div className="mt-3 overflow-auto">
98
+ <table className="w-full text-start text-xs">
99
+ <caption className="sr-only">
100
+ {options.title} values
101
+ </caption>
102
+ <thead>
103
+ <tr>
104
+ <th scope="col">Period</th>
105
+ {options.series.map((entry) => (
106
+ <th scope="col" key={entry.id}>
107
+ {entry.label}
108
+ </th>
109
+ ))}
110
+ </tr>
111
+ </thead>
112
+ <tbody>
113
+ {options.labels.map((label, index) => (
114
+ <tr key={label}>
115
+ <th scope="row">{label}</th>
116
+ {options.series.map((entry) => (
117
+ <td key={entry.id}>
118
+ {entry.values[index] ?? "Unavailable"}
119
+ </td>
120
+ ))}
121
+ </tr>
122
+ ))}
123
+ </tbody>
124
+ </table>
125
+ </div>
126
+ )}
127
+ </figure>
128
+ )
129
+ }
@@ -0,0 +1,29 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+ import * as Stories from "./ScheduleBoard.stories";
3
+
4
+ <Meta of={Stories} />
5
+
6
+ # ScheduleBoard
7
+
8
+ A schedule with aligned time bands, an exact hour axis, and day cards. The app supplies day labels, minute offsets, sources, and formatted times. Bands group by start time; their height never claims duration. The hour layout shows duration and packs overlapping entries side by side. Short items keep a minimum visible height. Full labels remain available through the accessible name and the selection callback. Multi-day entries must be split into day segments by the app. A measured minimum column width switches to day cards, without horizontal scrolling.
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,187 @@
1
+ import { playgroundParameters } from "@charcuterie/storybook-config/story-parameters"
2
+ import type { Meta, StoryObj } from "@storybook/react"
3
+ import { useState } from "react"
4
+
5
+ import { ContainerBoard } from "../board.storyHelpers.tsx"
6
+ import { Dialog } from "../Dialog/Dialog.tsx"
7
+ import { ScheduleBoard } from "./ScheduleBoard.tsx"
8
+
9
+ const meta = {
10
+ title: "Components/Data/ScheduleBoard",
11
+ component: ScheduleBoard,
12
+ parameters: { layout: "padded" },
13
+ args: {
14
+ label: "Studio schedule",
15
+ days: [
16
+ {
17
+ key: "mon",
18
+ label: "Mon",
19
+ dateLabel: "6 Apr",
20
+ isCurrent: true,
21
+ },
22
+ { key: "tue", label: "Tue", dateLabel: "7 Apr" },
23
+ { key: "wed", label: "Wed", dateLabel: "8 Apr" },
24
+ ],
25
+ bands: [
26
+ {
27
+ key: "morning",
28
+ label: "Morning",
29
+ detail: "Before noon",
30
+ startMinute: 0,
31
+ endMinute: 720,
32
+ },
33
+ {
34
+ key: "afternoon",
35
+ label: "Afternoon",
36
+ detail: "Noon–4p",
37
+ startMinute: 720,
38
+ endMinute: 960,
39
+ },
40
+ {
41
+ key: "evening",
42
+ label: "Evening",
43
+ detail: "After 4p",
44
+ startMinute: 960,
45
+ endMinute: 1440,
46
+ },
47
+ ],
48
+ items: [
49
+ {
50
+ key: "mix",
51
+ dayKey: "mon",
52
+ title: "Mixing session",
53
+ timeLabel: "9a–11a",
54
+ sourceLabel: "Studio A",
55
+ sourceMarker: "A",
56
+ startMinute: 540,
57
+ endMinute: 660,
58
+ categorical: 1,
59
+ },
60
+ {
61
+ key: "review",
62
+ dayKey: "mon",
63
+ title: "Client review",
64
+ timeLabel: "10a–12p",
65
+ sourceLabel: "Studio B",
66
+ sourceMarker: "B",
67
+ startMinute: 600,
68
+ endMinute: 720,
69
+ categorical: 3,
70
+ },
71
+ {
72
+ key: "delivery",
73
+ dayKey: "tue",
74
+ title: "Microphone delivery",
75
+ timeLabel: "All day",
76
+ sourceLabel: "Studio A",
77
+ startMinute: null,
78
+ endMinute: null,
79
+ categorical: 1,
80
+ },
81
+ {
82
+ key: "record",
83
+ dayKey: "tue",
84
+ title:
85
+ "Location recording and equipment preparation",
86
+ timeLabel: "2p–4p",
87
+ sourceLabel: "Studio B",
88
+ startMinute: 840,
89
+ endMinute: 960,
90
+ categorical: 3,
91
+ },
92
+ ],
93
+ layout: "bands",
94
+ },
95
+ } satisfies Meta<typeof ScheduleBoard>
96
+ export default meta
97
+ type Story = StoryObj<typeof meta>
98
+
99
+ export const Playground: Story = {
100
+ parameters: playgroundParameters,
101
+ render: (args) => (
102
+ <div style={{ inlineSize: "56rem" }}>
103
+ <ScheduleBoard {...args} />
104
+ </div>
105
+ ),
106
+ }
107
+ export const AllVariants: Story = {
108
+ render: (args) => (
109
+ <div className="flex flex-col gap-8">
110
+ {(["bands", "hours", "cards"] as const).map(
111
+ (layout) => (
112
+ <ScheduleBoard
113
+ {...args}
114
+ key={layout}
115
+ label={`Schedule ${layout}`}
116
+ layout={layout}
117
+ />
118
+ ),
119
+ )}
120
+ </div>
121
+ ),
122
+ }
123
+ export const AllStates: Story = {
124
+ render: (args) => (
125
+ <div className="flex flex-col gap-8">
126
+ <ScheduleBoard
127
+ {...args}
128
+ items={[]}
129
+ label="Empty schedule"
130
+ />
131
+ <ScheduleBoard
132
+ {...args}
133
+ label="Unknown schedule"
134
+ days={args.days.map((day) => ({
135
+ ...day,
136
+ emptyLabel: "Not read yet",
137
+ }))}
138
+ items={[]}
139
+ layout="cards"
140
+ />
141
+ </div>
142
+ ),
143
+ }
144
+ export const Responsive: Story = {
145
+ render: (args) => (
146
+ <ContainerBoard>
147
+ {(width) => (
148
+ <ScheduleBoard
149
+ {...args}
150
+ label={`Schedule at ${width}`}
151
+ />
152
+ )}
153
+ </ContainerBoard>
154
+ ),
155
+ }
156
+ export const Interactive: Story = {
157
+ render: (args) => {
158
+ const [selected, setSelected] = useState<string | null>(
159
+ null,
160
+ )
161
+ const item = args.items.find(
162
+ (entry) => entry.key === selected,
163
+ )
164
+ return (
165
+ <>
166
+ <ScheduleBoard {...args} onSelect={setSelected} />
167
+ <Dialog
168
+ heading={item?.title ?? "Event"}
169
+ isVisible={selected !== null}
170
+ onClose={() => {
171
+ setSelected(null)
172
+ }}
173
+ >
174
+ {item?.timeLabel} · {item?.sourceLabel}
175
+ </Dialog>
176
+ </>
177
+ )
178
+ },
179
+ }
180
+ export const HourGrid: Story = {
181
+ args: { layout: "hours" },
182
+ render: (args) => (
183
+ <div style={{ inlineSize: "56rem" }}>
184
+ <ScheduleBoard {...args} />
185
+ </div>
186
+ ),
187
+ }
@@ -0,0 +1,98 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, userEvent, within } from "storybook/test"
3
+ import { test } from "vitest"
4
+
5
+ import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
6
+ import { mountStory } from "../mountStory.testHelpers.ts"
7
+ import { expectAgentDrivable } from "../testing/index.ts"
8
+ import * as stories from "./ScheduleBoard.stories.tsx"
9
+
10
+ const { Playground, Responsive, Interactive, HourGrid } =
11
+ composeStories(stories)
12
+
13
+ test("bands align periods while retaining exact times and complete titles", async () => {
14
+ const { canvas, canvasElement } =
15
+ await mountStory(Playground)
16
+ expectAgentDrivable(canvas, {
17
+ role: "region",
18
+ name: "Studio schedule",
19
+ })
20
+ const morning: HTMLElement = canvas.getByRole("group", {
21
+ name: "Morning",
22
+ })
23
+ await expect(
24
+ within(morning).getByText("9a–11a"),
25
+ ).toBeInTheDocument()
26
+ await expect(
27
+ within(morning).getByText("Client review"),
28
+ ).toBeInTheDocument()
29
+ await expect(
30
+ canvas.getByText(
31
+ "Location recording and equipment preparation",
32
+ ),
33
+ ).toBeInTheDocument()
34
+ const cards = Array.from(
35
+ morning.querySelectorAll("article"),
36
+ )
37
+ await expect(
38
+ cards.every(
39
+ (card) => card.scrollHeight <= card.clientHeight + 1,
40
+ ),
41
+ ).toBe(true)
42
+ await expectNoAxeViolations(canvasElement)
43
+ })
44
+
45
+ test("the narrow layout keeps all events inside the container", async () => {
46
+ const { canvas, canvasElement } =
47
+ await mountStory(Responsive)
48
+ const region = expectAgentDrivable(canvas, {
49
+ role: "region",
50
+ name: "Schedule at 15rem",
51
+ })
52
+ await expect(
53
+ within(region).getByText(
54
+ "Location recording and equipment preparation",
55
+ ),
56
+ ).toBeInTheDocument()
57
+ await expect(region.scrollWidth).toBeLessThanOrEqual(
58
+ region.clientWidth + 1,
59
+ )
60
+ await expectNoAxeViolations(canvasElement)
61
+ })
62
+
63
+ test("the hour axis places overlapping items beside one another", async () => {
64
+ const { canvas } = await mountStory(HourGrid)
65
+ expectAgentDrivable(canvas, {
66
+ role: "region",
67
+ name: "Studio schedule",
68
+ })
69
+ const first = canvas
70
+ .getByText("Mixing session")
71
+ .parentElement?.getBoundingClientRect()
72
+ const second = canvas
73
+ .getByText("Client review")
74
+ .parentElement?.getBoundingClientRect()
75
+ if (!first || !second)
76
+ throw new Error("Both events must render")
77
+ await expect(second.top).toBeGreaterThan(first.top)
78
+ await expect(second.left).toBeGreaterThanOrEqual(
79
+ first.right,
80
+ )
81
+ })
82
+
83
+ test("keyboard selection opens the detail and returns focus on Escape", async () => {
84
+ const { canvas } = await mountStory(Interactive)
85
+ const button = expectAgentDrivable(canvas, {
86
+ role: "button",
87
+ name: "9a–11a, Mixing session, Studio A",
88
+ })
89
+ button.focus()
90
+ await userEvent.keyboard("{Enter}")
91
+ await expect(
92
+ within(document.body).getByRole("dialog", {
93
+ name: "Mixing session",
94
+ }),
95
+ ).toBeInTheDocument()
96
+ await userEvent.keyboard("{Escape}")
97
+ await expect(button).toHaveFocus()
98
+ })