@charcuterie/ui 4.13.0 → 4.14.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 (45) hide show
  1. package/dist/ScheduleBoard/ScheduleBoard.d.ts +33 -0
  2. package/dist/ScheduleBoard/ScheduleBoard.d.ts.map +1 -0
  3. package/dist/ScheduleBoard/ScheduleBoard.js +30 -0
  4. package/dist/ScheduleBoard/ScheduleBoard.js.map +1 -0
  5. package/dist/ScheduleBoard/ScheduleDayCards.d.ts +9 -0
  6. package/dist/ScheduleBoard/ScheduleDayCards.d.ts.map +1 -0
  7. package/dist/ScheduleBoard/ScheduleDayCards.js +8 -0
  8. package/dist/ScheduleBoard/ScheduleDayCards.js.map +1 -0
  9. package/dist/ScheduleBoard/ScheduleHourGrid.d.ts +12 -0
  10. package/dist/ScheduleBoard/ScheduleHourGrid.d.ts.map +1 -0
  11. package/dist/ScheduleBoard/ScheduleHourGrid.js +40 -0
  12. package/dist/ScheduleBoard/ScheduleHourGrid.js.map +1 -0
  13. package/dist/ScheduleBoard/ScheduleItem.d.ts +20 -0
  14. package/dist/ScheduleBoard/ScheduleItem.d.ts.map +1 -0
  15. package/dist/ScheduleBoard/ScheduleItem.js +18 -0
  16. package/dist/ScheduleBoard/ScheduleItem.js.map +1 -0
  17. package/dist/ScheduleBoard/scheduleGeometry.d.ts +12 -0
  18. package/dist/ScheduleBoard/scheduleGeometry.d.ts.map +1 -0
  19. package/dist/ScheduleBoard/scheduleGeometry.js +35 -0
  20. package/dist/ScheduleBoard/scheduleGeometry.js.map +1 -0
  21. package/dist/WeekdayIndicator/WeekdayIndicator.d.ts +12 -0
  22. package/dist/WeekdayIndicator/WeekdayIndicator.d.ts.map +1 -0
  23. package/dist/WeekdayIndicator/WeekdayIndicator.js +18 -0
  24. package/dist/WeekdayIndicator/WeekdayIndicator.js.map +1 -0
  25. package/dist/index.d.ts +5 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +2 -0
  28. package/dist/index.js.map +1 -1
  29. package/package.json +2 -2
  30. package/src/ScheduleBoard/ScheduleBoard.mdx +29 -0
  31. package/src/ScheduleBoard/ScheduleBoard.stories.tsx +187 -0
  32. package/src/ScheduleBoard/ScheduleBoard.test.tsx +98 -0
  33. package/src/ScheduleBoard/ScheduleBoard.tsx +215 -0
  34. package/src/ScheduleBoard/ScheduleDayCards.tsx +44 -0
  35. package/src/ScheduleBoard/ScheduleHourGrid.tsx +146 -0
  36. package/src/ScheduleBoard/ScheduleItem.tsx +88 -0
  37. package/src/ScheduleBoard/scheduleGeometry.test.ts +23 -0
  38. package/src/ScheduleBoard/scheduleGeometry.ts +56 -0
  39. package/src/WeekdayIndicator/WeekdayIndicator.mdx +29 -0
  40. package/src/WeekdayIndicator/WeekdayIndicator.stories.tsx +71 -0
  41. package/src/WeekdayIndicator/WeekdayIndicator.test.tsx +34 -0
  42. package/src/WeekdayIndicator/WeekdayIndicator.tsx +57 -0
  43. package/src/index.ts +10 -0
  44. package/src/markdownEditorCodeMirror/MarkdownView.test.tsx +4 -0
  45. package/src/sourceRules.test.ts +2 -1
@@ -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
+ })
@@ -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
+ }