@charcuterie/ui 4.6.0 → 4.7.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 +12 -0
- package/dist/LaneTimeline/LaneTimeline.d.ts +132 -0
- package/dist/LaneTimeline/LaneTimeline.d.ts.map +1 -0
- package/dist/LaneTimeline/LaneTimeline.js +165 -0
- package/dist/LaneTimeline/LaneTimeline.js.map +1 -0
- package/dist/LaneTimeline/LaneTimelineBar.d.ts +97 -0
- package/dist/LaneTimeline/LaneTimelineBar.d.ts.map +1 -0
- package/dist/LaneTimeline/LaneTimelineBar.js +69 -0
- package/dist/LaneTimeline/LaneTimelineBar.js.map +1 -0
- package/dist/LaneTimeline/LaneTimelineLane.d.ts +84 -0
- package/dist/LaneTimeline/LaneTimelineLane.d.ts.map +1 -0
- package/dist/LaneTimeline/LaneTimelineLane.js +109 -0
- package/dist/LaneTimeline/LaneTimelineLane.js.map +1 -0
- package/dist/LaneTimeline/laneTimelineGeometry.d.ts +268 -0
- package/dist/LaneTimeline/laneTimelineGeometry.d.ts.map +1 -0
- package/dist/LaneTimeline/laneTimelineGeometry.js +277 -0
- package/dist/LaneTimeline/laneTimelineGeometry.js.map +1 -0
- package/dist/LaneTimeline/useTrackMeasurements.d.ts +69 -0
- package/dist/LaneTimeline/useTrackMeasurements.d.ts.map +1 -0
- package/dist/LaneTimeline/useTrackMeasurements.js +134 -0
- package/dist/LaneTimeline/useTrackMeasurements.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/LaneTimeline/LaneTimeline.mdx +191 -0
- package/src/LaneTimeline/LaneTimeline.stories.tsx +561 -0
- package/src/LaneTimeline/LaneTimeline.test.tsx +330 -0
- package/src/LaneTimeline/LaneTimeline.tsx +381 -0
- package/src/LaneTimeline/LaneTimelineBar.tsx +237 -0
- package/src/LaneTimeline/LaneTimelineLane.tsx +332 -0
- package/src/LaneTimeline/laneTimelineGeometry.test.ts +429 -0
- package/src/LaneTimeline/laneTimelineGeometry.ts +476 -0
- package/src/LaneTimeline/useTrackMeasurements.ts +165 -0
- package/src/index.ts +13 -0
- package/src/sourceRules.test.ts +22 -1
|
@@ -0,0 +1,237 @@
|
|
|
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 { UnstyledLink } from "../RouterLink/UnstyledLink.tsx"
|
|
10
|
+
import { toClassName } from "../toClassName.ts"
|
|
11
|
+
import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx"
|
|
12
|
+
import type { TimelineSpan } from "./laneTimelineGeometry.ts"
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Who owns the item's activation — nobody, a router, or a handler.
|
|
16
|
+
*
|
|
17
|
+
* A union rather than two loose optionals, the same call
|
|
18
|
+
* `BoardItemTitleLink` makes and for the same reason: `href` renders
|
|
19
|
+
* an `<a>` and `onSelect` renders a `<button>`, so a bar carrying
|
|
20
|
+
* both is one of them with the other silently dropped. On screen the
|
|
21
|
+
* two are identical.
|
|
22
|
+
*/
|
|
23
|
+
type TimelineItemAction =
|
|
24
|
+
| {
|
|
25
|
+
/**
|
|
26
|
+
* Where the bar navigates. Routed through
|
|
27
|
+
* `RouterLinkProvider` like every other `href` in this
|
|
28
|
+
* library.
|
|
29
|
+
*/
|
|
30
|
+
href?: string
|
|
31
|
+
onSelect?: never
|
|
32
|
+
}
|
|
33
|
+
| {
|
|
34
|
+
href?: never
|
|
35
|
+
/** For a timeline that opens a panel rather than a route. */
|
|
36
|
+
onSelect?: () => void
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type TimelineItem = TimelineSpan & {
|
|
40
|
+
/**
|
|
41
|
+
* The bar's colour, when somebody has chosen one. Omitted, it is
|
|
42
|
+
* the **lane's**, because on this component colour means *which
|
|
43
|
+
* lane* — an item that picked its own hue out of the ten would
|
|
44
|
+
* read as belonging somewhere else. Pass this only when the item
|
|
45
|
+
* genuinely carries its own identity, and then put that identity
|
|
46
|
+
* in words too.
|
|
47
|
+
*/
|
|
48
|
+
categorical?: CategoricalIndex
|
|
49
|
+
key: string
|
|
50
|
+
/**
|
|
51
|
+
* The words. A **string**, not a `ReactNode`, for the reason
|
|
52
|
+
* `BoardLane.label` is one: the same value goes into the bar's
|
|
53
|
+
* accessible name, the `title` a hovering reader gets, and the
|
|
54
|
+
* Narrow View's row — none of which can hold a node.
|
|
55
|
+
*/
|
|
56
|
+
title: string
|
|
57
|
+
} & TimelineItemAction
|
|
58
|
+
|
|
59
|
+
export type LaneTimelineBarProps = {
|
|
60
|
+
className?: string
|
|
61
|
+
item: TimelineItem
|
|
62
|
+
/** The lane's hue, used when the item picked none. */
|
|
63
|
+
laneCategorical: CategoricalIndex
|
|
64
|
+
/** What the bar says out loud: title, then the span in words. */
|
|
65
|
+
description: string
|
|
66
|
+
/** Square the leading edge — the item began before this range. */
|
|
67
|
+
hasEarlierStart: boolean
|
|
68
|
+
/** Square the trailing edge — it runs past this range. */
|
|
69
|
+
hasLaterEnd: boolean
|
|
70
|
+
/**
|
|
71
|
+
* `bar` is the block on the axis. `row` is the Narrow View's
|
|
72
|
+
* full-width line, where the same item has room for two lines of
|
|
73
|
+
* text and no position to carry meaning.
|
|
74
|
+
*/
|
|
75
|
+
shape: "bar" | "row"
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* One item, in either of the two shapes this component draws it in.
|
|
80
|
+
*
|
|
81
|
+
* ### The name is the whole of the accessibility argument
|
|
82
|
+
*
|
|
83
|
+
* A bar means something by **where it sits and how long it is**, and
|
|
84
|
+
* neither of those reaches a screen reader. Nor does the hue: ten
|
|
85
|
+
* categorical colours carry *which lane*, and colour alone failing
|
|
86
|
+
* WCAG 1.4.1 is the least of it — the lane is already a named group,
|
|
87
|
+
* so the colour is reinforcement rather than information.
|
|
88
|
+
*
|
|
89
|
+
* What is genuinely only in the geometry is the **span**, so every
|
|
90
|
+
* bar carries it as text: `description` is appended to the title in
|
|
91
|
+
* a `VisuallyHidden` and repeated in the `title` attribute. That
|
|
92
|
+
* second one is not an accessibility feature, it is for the sighted
|
|
93
|
+
* reader: a one-day bar is about twenty pixels wide and cannot show
|
|
94
|
+
* its own name at all.
|
|
95
|
+
*
|
|
96
|
+
* A native `title` rather than `Tooltip`, deliberately. A household
|
|
97
|
+
* week is two hundred bars, and two hundred floating-ui instances to
|
|
98
|
+
* describe boxes that are already described is a cost with nothing
|
|
99
|
+
* on the other side of it.
|
|
100
|
+
*
|
|
101
|
+
* ### The clipped edge is square, and it is also in words
|
|
102
|
+
*
|
|
103
|
+
* An item running out of the visible range loses the radius on that
|
|
104
|
+
* edge, which is the convention every Gantt chart uses. It is **not**
|
|
105
|
+
* the only signal:
|
|
106
|
+
* `description` already says "starts before this range", because a
|
|
107
|
+
* missing corner is not something anybody perceives without the
|
|
108
|
+
* unclipped bar beside it to compare against.
|
|
109
|
+
*/
|
|
110
|
+
export const LaneTimelineBar = ({
|
|
111
|
+
className,
|
|
112
|
+
description,
|
|
113
|
+
hasEarlierStart,
|
|
114
|
+
hasLaterEnd,
|
|
115
|
+
item,
|
|
116
|
+
laneCategorical,
|
|
117
|
+
shape,
|
|
118
|
+
}: LaneTimelineBarProps): ReactNode => {
|
|
119
|
+
const categorical = item.categorical ?? laneCategorical
|
|
120
|
+
|
|
121
|
+
const isInteractive = Boolean(item.href ?? item.onSelect)
|
|
122
|
+
|
|
123
|
+
const content = (
|
|
124
|
+
<>
|
|
125
|
+
{/*
|
|
126
|
+
* `truncate` and a `min-w-0`, per the flex-overflow rule: the
|
|
127
|
+
* title is the flex row's text child and a single unbreakable
|
|
128
|
+
* word would otherwise become the bar's floor and push the
|
|
129
|
+
* bar past its own grid column. The whole string is in the
|
|
130
|
+
* `title` attribute and in the hidden sentence, so nothing is
|
|
131
|
+
* lost to the ellipsis.
|
|
132
|
+
*/}
|
|
133
|
+
<span
|
|
134
|
+
className={toClassName(
|
|
135
|
+
"min-w-0 flex-1",
|
|
136
|
+
shape === "bar"
|
|
137
|
+
? "truncate"
|
|
138
|
+
: "wrap-anywhere text-sm",
|
|
139
|
+
)}
|
|
140
|
+
>
|
|
141
|
+
{item.title}
|
|
142
|
+
</span>
|
|
143
|
+
|
|
144
|
+
{/*
|
|
145
|
+
* Three cases, and which one applies is the reason this is not
|
|
146
|
+
* one line.
|
|
147
|
+
*
|
|
148
|
+
* The Narrow View has room to print the span, so it prints it
|
|
149
|
+
* — a reader who can see the row should not need a hover to
|
|
150
|
+
* learn when a thing is on.
|
|
151
|
+
*
|
|
152
|
+
* A bar that is a control takes its span through `aria-label`
|
|
153
|
+
* instead, because a name assembled from two elements is
|
|
154
|
+
* assembled by the **browser**: chromium joins the two text
|
|
155
|
+
* runs with a space and firefox with nothing, so
|
|
156
|
+
* `getByRole("button", { name })` matches on one engine and
|
|
157
|
+
* not the other. One attribute makes the name exact, and the
|
|
158
|
+
* visible title still opens it, which is what WCAG 2.5.3 asks.
|
|
159
|
+
*
|
|
160
|
+
* A bar that is not a control has no name to write, so the
|
|
161
|
+
* sentence goes in the content where a screen reader will
|
|
162
|
+
* still read it.
|
|
163
|
+
*/}
|
|
164
|
+
{shape === "row" ? (
|
|
165
|
+
<span className="text-content-muted text-xs">
|
|
166
|
+
{description}
|
|
167
|
+
</span>
|
|
168
|
+
) : isInteractive ? null : (
|
|
169
|
+
<VisuallyHidden>{description}</VisuallyHidden>
|
|
170
|
+
)}
|
|
171
|
+
</>
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Only the bar shape gets one, and it carries the **title** as
|
|
176
|
+
* well as the span. The Narrow View prints both already, and a
|
|
177
|
+
* tooltip repeating text that is on screen is noise a pointer
|
|
178
|
+
* user cannot dismiss.
|
|
179
|
+
*/
|
|
180
|
+
const sentence = `${item.title}, ${description}`
|
|
181
|
+
|
|
182
|
+
const hoverText = shape === "bar" ? sentence : undefined
|
|
183
|
+
|
|
184
|
+
const shellClassName = toClassName(
|
|
185
|
+
"w-full min-w-0 border px-1.5 text-xs leading-tight",
|
|
186
|
+
shape === "bar"
|
|
187
|
+
? "flex min-h-5 items-center rounded-sm"
|
|
188
|
+
: "flex flex-col gap-0.5 rounded-md py-1",
|
|
189
|
+
CATEGORICAL_APPEARANCE_CLASS[categorical].soft,
|
|
190
|
+
// A square edge where the item leaves the range. `rounded-*-none`
|
|
191
|
+
// rather than a different component, so a clipped bar and a whole
|
|
192
|
+
// one are the same box with one corner rule changed.
|
|
193
|
+
hasEarlierStart && shape === "bar" && "rounded-s-none",
|
|
194
|
+
hasLaterEnd && shape === "bar" && "rounded-e-none",
|
|
195
|
+
isInteractive && "cursor-pointer",
|
|
196
|
+
isInteractive &&
|
|
197
|
+
CATEGORICAL_HOVER_CLASS[categorical].soft,
|
|
198
|
+
isInteractive && FOCUS_RING_CLASS,
|
|
199
|
+
className,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if (item.href) {
|
|
203
|
+
return (
|
|
204
|
+
<UnstyledLink
|
|
205
|
+
aria-label={sentence}
|
|
206
|
+
className={shellClassName}
|
|
207
|
+
href={item.href}
|
|
208
|
+
title={hoverText}
|
|
209
|
+
>
|
|
210
|
+
{content}
|
|
211
|
+
</UnstyledLink>
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (item.onSelect) {
|
|
216
|
+
return (
|
|
217
|
+
<button
|
|
218
|
+
aria-label={sentence}
|
|
219
|
+
className={toClassName(
|
|
220
|
+
shellClassName,
|
|
221
|
+
"text-start",
|
|
222
|
+
)}
|
|
223
|
+
onClick={item.onSelect}
|
|
224
|
+
title={hoverText}
|
|
225
|
+
type="button"
|
|
226
|
+
>
|
|
227
|
+
{content}
|
|
228
|
+
</button>
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return (
|
|
233
|
+
<span className={shellClassName} title={hoverText}>
|
|
234
|
+
{content}
|
|
235
|
+
</span>
|
|
236
|
+
)
|
|
237
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { useUniqueId } from "@charcuterie/logic"
|
|
2
|
+
import type { CategoricalIndex } from "@charcuterie/tokens"
|
|
3
|
+
import {
|
|
4
|
+
CATEGORICAL_INDEX_COUNT,
|
|
5
|
+
CATEGORICAL_SEQUENCE,
|
|
6
|
+
} from "@charcuterie/tokens"
|
|
7
|
+
import type { ReactNode } from "react"
|
|
8
|
+
|
|
9
|
+
import { Badge } from "../Badge/Badge.tsx"
|
|
10
|
+
import { CATEGORICAL_SOLID_FILL_CLASS } from "../categoricalStyles.ts"
|
|
11
|
+
import { TextLink } from "../TextLink/TextLink.tsx"
|
|
12
|
+
import { toClassName } from "../toClassName.ts"
|
|
13
|
+
import type { TimelineItem } from "./LaneTimelineBar.tsx"
|
|
14
|
+
import { LaneTimelineBar } from "./LaneTimelineBar.tsx"
|
|
15
|
+
import type { TimelineAxis } from "./laneTimelineGeometry.ts"
|
|
16
|
+
import {
|
|
17
|
+
describeTimelineSpan,
|
|
18
|
+
toPackedLane,
|
|
19
|
+
} from "./laneTimelineGeometry.ts"
|
|
20
|
+
|
|
21
|
+
export type TimelineLane = {
|
|
22
|
+
/**
|
|
23
|
+
* Which of the ten hues this lane wears, overriding the one its
|
|
24
|
+
* **position** would have given it.
|
|
25
|
+
*
|
|
26
|
+
* Reach for it when a lane's colour means something outside this
|
|
27
|
+
* set — a person who is the same colour on every screen of the
|
|
28
|
+
* app, a pair that must not drift apart when a third lane is
|
|
29
|
+
* inserted between them. An overridden lane keeps its hue and the
|
|
30
|
+
* rest go on walking `CATEGORICAL_SEQUENCE` from their own
|
|
31
|
+
* positions, so naming one does not force naming all of them.
|
|
32
|
+
*/
|
|
33
|
+
categorical?: CategoricalIndex
|
|
34
|
+
/**
|
|
35
|
+
* Where the lane's heading navigates — the "show me only this
|
|
36
|
+
* lane" route. A link **inside** the heading, never a heading
|
|
37
|
+
* replaced by a link, exactly as `BoardLane.href` is: the heading
|
|
38
|
+
* keeps its level, its id, and its job of naming the group.
|
|
39
|
+
*/
|
|
40
|
+
href?: string
|
|
41
|
+
items: readonly TimelineItem[]
|
|
42
|
+
key: string
|
|
43
|
+
/**
|
|
44
|
+
* The lane's name. A **string**: it names the group, and a group
|
|
45
|
+
* with a `ReactNode` name is a group with no name.
|
|
46
|
+
*/
|
|
47
|
+
label: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type LaneTimelineLaneProps = {
|
|
51
|
+
axis: TimelineAxis
|
|
52
|
+
/** The CSS track list, built once by the parent and shared. */
|
|
53
|
+
columnTemplate: string
|
|
54
|
+
headingLevel: 2 | 3 | 4 | 5
|
|
55
|
+
lane: TimelineLane
|
|
56
|
+
locale?: string
|
|
57
|
+
maxRowCount: number
|
|
58
|
+
/** Where this lane sits in the set, for its default hue. */
|
|
59
|
+
position: number
|
|
60
|
+
/**
|
|
61
|
+
* `axis` is the bars across the date axis. `list` is the Narrow
|
|
62
|
+
* View, where there is no axis and each item is a line of text.
|
|
63
|
+
*/
|
|
64
|
+
shape: "axis" | "list"
|
|
65
|
+
/** Draw a separator every this many columns. */
|
|
66
|
+
tickStep: number
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* One lane: its name, how much it is carrying, and either a track of
|
|
71
|
+
* bars or a list of lines.
|
|
72
|
+
*
|
|
73
|
+
* ### A lane is a `group`, not a landmark
|
|
74
|
+
*
|
|
75
|
+
* The same call `BoardLaneList` makes, and it was a real axe failure
|
|
76
|
+
* there before it changed. A named `<section>` is a `region`, so a
|
|
77
|
+
* timeline of eight lanes would put eight landmarks on a page that
|
|
78
|
+
* has one job, and two timelines on one page would put two landmarks
|
|
79
|
+
* with the same name in the document — which is `landmark-unique`.
|
|
80
|
+
* It is a `<div role="group">` rather than a `<section role="group">`
|
|
81
|
+
* because axe matches `section[aria-labelledby]` on the **element**
|
|
82
|
+
* and audits it as a landmark even after the role is overridden.
|
|
83
|
+
*
|
|
84
|
+
* ### The count is what the lane carries IN THIS RANGE
|
|
85
|
+
*
|
|
86
|
+
* Not `items.length`. A consumer hands over a month and shows a
|
|
87
|
+
* week; counting the array would print "31" beside a lane with two
|
|
88
|
+
* bars on it. `inRangeCount` includes anything the row limit hid, so
|
|
89
|
+
* a lane cannot look lighter by being more loaded.
|
|
90
|
+
*
|
|
91
|
+
* ### A lane gets TALLER when it is busy, and that is the design
|
|
92
|
+
*
|
|
93
|
+
* Overlapping items stack into rows (`toPackedLane`), so a lane
|
|
94
|
+
* carrying three things at once is three bars tall while one
|
|
95
|
+
* carrying them in sequence is one. Load asymmetry then reads as
|
|
96
|
+
* height from across the room, before anybody counts anything —
|
|
97
|
+
* which is the whole reason this component exists rather than a
|
|
98
|
+
* month grid, where every cell is the same size no matter what is
|
|
99
|
+
* in it.
|
|
100
|
+
*/
|
|
101
|
+
export const LaneTimelineLane = ({
|
|
102
|
+
axis,
|
|
103
|
+
columnTemplate,
|
|
104
|
+
headingLevel,
|
|
105
|
+
lane,
|
|
106
|
+
locale,
|
|
107
|
+
maxRowCount,
|
|
108
|
+
position,
|
|
109
|
+
shape,
|
|
110
|
+
tickStep,
|
|
111
|
+
}: LaneTimelineLaneProps): ReactNode => {
|
|
112
|
+
const headingId = useUniqueId()
|
|
113
|
+
|
|
114
|
+
/*
|
|
115
|
+
* Position, not `getCategoricalIndex(lane.key)`, and `ActionTiles`
|
|
116
|
+
* made the same call for the same reason. That helper hashes, so a
|
|
117
|
+
* row keeps its colour across a list nobody controls the order of
|
|
118
|
+
* — right for Docket's labels, wrong here: a hash of two lane keys
|
|
119
|
+
* can collide, and two lanes in one hue defeats the only thing the
|
|
120
|
+
* colour is for. Measured on this component's own fixtures, where
|
|
121
|
+
* `wren` and `juno` both hashed to Pink and the screenshot showed
|
|
122
|
+
* one person's week twice.
|
|
123
|
+
*
|
|
124
|
+
* `CATEGORICAL_SEQUENCE` rather than the ring: the ring is
|
|
125
|
+
* hue-ordered for the swatch picker's sake, so walking it in order
|
|
126
|
+
* hands a two-lane set the two hardest colours in the family to
|
|
127
|
+
* tell apart.
|
|
128
|
+
*/
|
|
129
|
+
const categorical =
|
|
130
|
+
lane.categorical ??
|
|
131
|
+
CATEGORICAL_SEQUENCE[position % CATEGORICAL_INDEX_COUNT]
|
|
132
|
+
|
|
133
|
+
const packed = toPackedLane({
|
|
134
|
+
axis,
|
|
135
|
+
items: lane.items,
|
|
136
|
+
/*
|
|
137
|
+
* The Narrow View has no row limit, because there are no rows:
|
|
138
|
+
* it is a vertical list, so the thing the limit protects
|
|
139
|
+
* against — a lane growing past the height of the screen while
|
|
140
|
+
* every other lane stays one bar tall — cannot happen. Hiding
|
|
141
|
+
* items there would lose them for the reader who has the least
|
|
142
|
+
* room to spare.
|
|
143
|
+
*/
|
|
144
|
+
maxRowCount:
|
|
145
|
+
shape === "list" ? lane.items.length : maxRowCount,
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
const Heading = `h${headingLevel}` as const
|
|
149
|
+
|
|
150
|
+
const header = (
|
|
151
|
+
<header className="flex min-w-0 items-center gap-2">
|
|
152
|
+
{/*
|
|
153
|
+
* The lane's hue, beside its name, so the reader learns which
|
|
154
|
+
* colour is which lane once instead of per bar. Decoration
|
|
155
|
+
* only — the heading is the thing that says whose lane it is,
|
|
156
|
+
* which is what keeps the colour from being the sole carrier
|
|
157
|
+
* of anything.
|
|
158
|
+
*/}
|
|
159
|
+
<span
|
|
160
|
+
aria-hidden="true"
|
|
161
|
+
className={toClassName(
|
|
162
|
+
"h-4 w-1 shrink-0 rounded-full",
|
|
163
|
+
CATEGORICAL_SOLID_FILL_CLASS[categorical],
|
|
164
|
+
)}
|
|
165
|
+
/>
|
|
166
|
+
|
|
167
|
+
<Heading
|
|
168
|
+
className="min-w-0 truncate font-semibold text-content-primary text-sm"
|
|
169
|
+
id={headingId}
|
|
170
|
+
title={lane.label}
|
|
171
|
+
>
|
|
172
|
+
{lane.href ? (
|
|
173
|
+
<TextLink
|
|
174
|
+
appearance="standalone"
|
|
175
|
+
className="font-semibold text-inherit"
|
|
176
|
+
href={lane.href}
|
|
177
|
+
>
|
|
178
|
+
{lane.label}
|
|
179
|
+
</TextLink>
|
|
180
|
+
) : (
|
|
181
|
+
lane.label
|
|
182
|
+
)}
|
|
183
|
+
</Heading>
|
|
184
|
+
|
|
185
|
+
<Badge appearance="soft" intent="neutral" size="sm">
|
|
186
|
+
{String(packed.inRangeCount)}
|
|
187
|
+
</Badge>
|
|
188
|
+
</header>
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
if (shape === "list") {
|
|
192
|
+
const items = packed.rows
|
|
193
|
+
.flat()
|
|
194
|
+
.sort(
|
|
195
|
+
(first, second) =>
|
|
196
|
+
first.placement.columnStart -
|
|
197
|
+
second.placement.columnStart,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
return (
|
|
201
|
+
// biome-ignore lint/a11y/useSemanticElements: the semantic element for `group` is `<fieldset>`, which drags `<legend>`, a border and form-reset behaviour onto a lane of dates. Same call `BoardLaneList`, `Menu`'s group and `AccordionSection` already made.
|
|
202
|
+
<div
|
|
203
|
+
aria-labelledby={headingId}
|
|
204
|
+
className="flex flex-col gap-1.5 border-border-subtle border-t py-2"
|
|
205
|
+
role="group"
|
|
206
|
+
>
|
|
207
|
+
{header}
|
|
208
|
+
|
|
209
|
+
{items.length === 0 ? (
|
|
210
|
+
<p className="text-content-muted text-xs">
|
|
211
|
+
Nothing in this range.
|
|
212
|
+
</p>
|
|
213
|
+
) : (
|
|
214
|
+
<ul className="flex flex-col gap-1">
|
|
215
|
+
{items.map(({ item, placement }) => (
|
|
216
|
+
<li className="min-w-0" key={item.key}>
|
|
217
|
+
<LaneTimelineBar
|
|
218
|
+
laneCategorical={categorical}
|
|
219
|
+
description={describeTimelineSpan({
|
|
220
|
+
locale,
|
|
221
|
+
placement,
|
|
222
|
+
span: item,
|
|
223
|
+
})}
|
|
224
|
+
hasEarlierStart={
|
|
225
|
+
placement.hasEarlierStart
|
|
226
|
+
}
|
|
227
|
+
hasLaterEnd={placement.hasLaterEnd}
|
|
228
|
+
item={item}
|
|
229
|
+
shape="row"
|
|
230
|
+
/>
|
|
231
|
+
</li>
|
|
232
|
+
))}
|
|
233
|
+
</ul>
|
|
234
|
+
)}
|
|
235
|
+
</div>
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
// biome-ignore lint/a11y/useSemanticElements: see the Narrow View branch above — `<fieldset>` is the only semantic element for `group`, and it is a form-control grouping.
|
|
241
|
+
<div
|
|
242
|
+
aria-labelledby={headingId}
|
|
243
|
+
className="grid grid-cols-[12rem_minmax(0,1fr)] items-start gap-x-3 border-border-subtle border-t py-1.5"
|
|
244
|
+
role="group"
|
|
245
|
+
>
|
|
246
|
+
{header}
|
|
247
|
+
|
|
248
|
+
<div className="relative min-h-6">
|
|
249
|
+
{/*
|
|
250
|
+
* The separators, drawn behind the bars rather than between
|
|
251
|
+
* them. A real element between two columns takes up space,
|
|
252
|
+
* which moves the columns it was measuring itself against —
|
|
253
|
+
* the same feedback loop `BoardLaneList`'s drop indicator
|
|
254
|
+
* avoids by being absolute.
|
|
255
|
+
*/}
|
|
256
|
+
<div
|
|
257
|
+
aria-hidden="true"
|
|
258
|
+
className="pointer-events-none absolute inset-0 grid"
|
|
259
|
+
style={{ gridTemplateColumns: columnTemplate }}
|
|
260
|
+
>
|
|
261
|
+
{/*
|
|
262
|
+
* Keyed by the DAY, not by the loop index — the day number
|
|
263
|
+
* is what the cell is, and a key that is an index is a key
|
|
264
|
+
* that moves when the range does.
|
|
265
|
+
*/}
|
|
266
|
+
{Array.from(
|
|
267
|
+
{ length: axis.columnCount },
|
|
268
|
+
(_unused, index) => axis.startDay + index,
|
|
269
|
+
).map((dayNumber) => (
|
|
270
|
+
<span
|
|
271
|
+
className={toClassName(
|
|
272
|
+
// Never on the leading edge: a line down the start of
|
|
273
|
+
// the track reads as a frame the track does not have.
|
|
274
|
+
dayNumber > axis.startDay &&
|
|
275
|
+
(dayNumber - axis.startDay) % tickStep ===
|
|
276
|
+
0 &&
|
|
277
|
+
"border-border-subtle border-s",
|
|
278
|
+
)}
|
|
279
|
+
key={dayNumber}
|
|
280
|
+
/>
|
|
281
|
+
))}
|
|
282
|
+
</div>
|
|
283
|
+
|
|
284
|
+
<ul
|
|
285
|
+
className="relative grid gap-1"
|
|
286
|
+
style={{ gridTemplateColumns: columnTemplate }}
|
|
287
|
+
>
|
|
288
|
+
{packed.rows.flatMap((row, rowIndex) =>
|
|
289
|
+
row.map(({ item, placement }) => (
|
|
290
|
+
<li
|
|
291
|
+
className="min-w-0"
|
|
292
|
+
key={item.key}
|
|
293
|
+
style={{
|
|
294
|
+
/*
|
|
295
|
+
* `span`, never a start/end pair. An end line is
|
|
296
|
+
* one past the last column the bar covers, so
|
|
297
|
+
* writing it is the single easiest place to lose
|
|
298
|
+
* a day — and it is invisible, because a bar one
|
|
299
|
+
* day short still looks like a bar.
|
|
300
|
+
*/
|
|
301
|
+
gridColumn: `${placement.columnStart} / span ${placement.columnSpan}`,
|
|
302
|
+
gridRow: rowIndex + 1,
|
|
303
|
+
}}
|
|
304
|
+
>
|
|
305
|
+
<LaneTimelineBar
|
|
306
|
+
laneCategorical={categorical}
|
|
307
|
+
description={describeTimelineSpan({
|
|
308
|
+
locale,
|
|
309
|
+
placement,
|
|
310
|
+
span: item,
|
|
311
|
+
})}
|
|
312
|
+
hasEarlierStart={
|
|
313
|
+
placement.hasEarlierStart
|
|
314
|
+
}
|
|
315
|
+
hasLaterEnd={placement.hasLaterEnd}
|
|
316
|
+
item={item}
|
|
317
|
+
shape="bar"
|
|
318
|
+
/>
|
|
319
|
+
</li>
|
|
320
|
+
)),
|
|
321
|
+
)}
|
|
322
|
+
</ul>
|
|
323
|
+
|
|
324
|
+
{packed.hiddenCount > 0 ? (
|
|
325
|
+
<p className="pt-1 text-content-muted text-xs">
|
|
326
|
+
{`+ ${packed.hiddenCount} more overlapping in ${lane.label}`}
|
|
327
|
+
</p>
|
|
328
|
+
) : null}
|
|
329
|
+
</div>
|
|
330
|
+
</div>
|
|
331
|
+
)
|
|
332
|
+
}
|