@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.
- package/README.md +2 -0
- package/dist/Chart/Chart.d.ts +10 -0
- package/dist/Chart/Chart.d.ts.map +1 -0
- package/dist/Chart/Chart.js +42 -0
- package/dist/Chart/Chart.js.map +1 -0
- package/dist/ScheduleBoard/ScheduleBoard.d.ts +33 -0
- package/dist/ScheduleBoard/ScheduleBoard.d.ts.map +1 -0
- package/dist/ScheduleBoard/ScheduleBoard.js +30 -0
- package/dist/ScheduleBoard/ScheduleBoard.js.map +1 -0
- package/dist/ScheduleBoard/ScheduleDayCards.d.ts +9 -0
- package/dist/ScheduleBoard/ScheduleDayCards.d.ts.map +1 -0
- package/dist/ScheduleBoard/ScheduleDayCards.js +8 -0
- package/dist/ScheduleBoard/ScheduleDayCards.js.map +1 -0
- package/dist/ScheduleBoard/ScheduleHourGrid.d.ts +12 -0
- package/dist/ScheduleBoard/ScheduleHourGrid.d.ts.map +1 -0
- package/dist/ScheduleBoard/ScheduleHourGrid.js +40 -0
- package/dist/ScheduleBoard/ScheduleHourGrid.js.map +1 -0
- package/dist/ScheduleBoard/ScheduleItem.d.ts +20 -0
- package/dist/ScheduleBoard/ScheduleItem.d.ts.map +1 -0
- package/dist/ScheduleBoard/ScheduleItem.js +18 -0
- package/dist/ScheduleBoard/ScheduleItem.js.map +1 -0
- package/dist/ScheduleBoard/scheduleGeometry.d.ts +12 -0
- package/dist/ScheduleBoard/scheduleGeometry.d.ts.map +1 -0
- package/dist/ScheduleBoard/scheduleGeometry.js +35 -0
- package/dist/ScheduleBoard/scheduleGeometry.js.map +1 -0
- package/dist/WeekdayIndicator/WeekdayIndicator.d.ts +12 -0
- package/dist/WeekdayIndicator/WeekdayIndicator.d.ts.map +1 -0
- package/dist/WeekdayIndicator/WeekdayIndicator.js +18 -0
- package/dist/WeekdayIndicator/WeekdayIndicator.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/Chart/Chart.mdx +16 -0
- package/src/Chart/Chart.stories.tsx +92 -0
- package/src/Chart/Chart.test.tsx +37 -0
- package/src/Chart/Chart.tsx +129 -0
- package/src/ScheduleBoard/ScheduleBoard.mdx +29 -0
- package/src/ScheduleBoard/ScheduleBoard.stories.tsx +187 -0
- package/src/ScheduleBoard/ScheduleBoard.test.tsx +98 -0
- package/src/ScheduleBoard/ScheduleBoard.tsx +215 -0
- package/src/ScheduleBoard/ScheduleDayCards.tsx +44 -0
- package/src/ScheduleBoard/ScheduleHourGrid.tsx +146 -0
- package/src/ScheduleBoard/ScheduleItem.tsx +88 -0
- package/src/ScheduleBoard/scheduleGeometry.test.ts +23 -0
- package/src/ScheduleBoard/scheduleGeometry.ts +56 -0
- package/src/VirtualizedGrid/VirtualizedGrid.stories.tsx +6 -0
- package/src/WeekdayIndicator/WeekdayIndicator.mdx +29 -0
- package/src/WeekdayIndicator/WeekdayIndicator.stories.tsx +71 -0
- package/src/WeekdayIndicator/WeekdayIndicator.test.tsx +34 -0
- package/src/WeekdayIndicator/WeekdayIndicator.tsx +57 -0
- package/src/index.ts +12 -0
- 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
|
+
}
|