@charcuterie/ui 3.0.0 → 3.2.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 (130) hide show
  1. package/README.md +10 -0
  2. package/dist/Badge/Badge.d.ts +33 -3
  3. package/dist/Badge/Badge.d.ts.map +1 -1
  4. package/dist/Badge/Badge.js +20 -2
  5. package/dist/Badge/Badge.js.map +1 -1
  6. package/dist/Board/Board.d.ts +119 -0
  7. package/dist/Board/Board.d.ts.map +1 -0
  8. package/dist/Board/Board.js +165 -0
  9. package/dist/Board/Board.js.map +1 -0
  10. package/dist/Board/BoardCard.d.ts +120 -0
  11. package/dist/Board/BoardCard.d.ts.map +1 -0
  12. package/dist/Board/BoardCard.js +77 -0
  13. package/dist/Board/BoardCard.js.map +1 -0
  14. package/dist/Board/BoardLaneList.d.ts +103 -0
  15. package/dist/Board/BoardLaneList.d.ts.map +1 -0
  16. package/dist/Board/BoardLaneList.js +69 -0
  17. package/dist/Board/BoardLaneList.js.map +1 -0
  18. package/dist/Board/boardMove.d.ts +116 -0
  19. package/dist/Board/boardMove.d.ts.map +1 -0
  20. package/dist/Board/boardMove.js +116 -0
  21. package/dist/Board/boardMove.js.map +1 -0
  22. package/dist/Board/useBoardDrag.d.ts +44 -0
  23. package/dist/Board/useBoardDrag.d.ts.map +1 -0
  24. package/dist/Board/useBoardDrag.js +237 -0
  25. package/dist/Board/useBoardDrag.js.map +1 -0
  26. package/dist/Checkbox/Checkbox.d.ts.map +1 -1
  27. package/dist/Checkbox/Checkbox.js +15 -4
  28. package/dist/Checkbox/Checkbox.js.map +1 -1
  29. package/dist/DataTable/DataTable.d.ts +159 -0
  30. package/dist/DataTable/DataTable.d.ts.map +1 -0
  31. package/dist/DataTable/DataTable.js +197 -0
  32. package/dist/DataTable/DataTable.js.map +1 -0
  33. package/dist/DatePicker/DateGrid.d.ts +79 -0
  34. package/dist/DatePicker/DateGrid.d.ts.map +1 -0
  35. package/dist/DatePicker/DateGrid.js +220 -0
  36. package/dist/DatePicker/DateGrid.js.map +1 -0
  37. package/dist/DatePicker/DatePicker.d.ts +138 -0
  38. package/dist/DatePicker/DatePicker.d.ts.map +1 -0
  39. package/dist/DatePicker/DatePicker.js +384 -0
  40. package/dist/DatePicker/DatePicker.js.map +1 -0
  41. package/dist/DatePicker/datePresets.d.ts +29 -0
  42. package/dist/DatePicker/datePresets.d.ts.map +1 -0
  43. package/dist/DatePicker/datePresets.js +28 -0
  44. package/dist/DatePicker/datePresets.js.map +1 -0
  45. package/dist/DatePicker/parseDateInput.d.ts +106 -0
  46. package/dist/DatePicker/parseDateInput.d.ts.map +1 -0
  47. package/dist/DatePicker/parseDateInput.js +419 -0
  48. package/dist/DatePicker/parseDateInput.js.map +1 -0
  49. package/dist/DatePicker/plainDate.d.ts +167 -0
  50. package/dist/DatePicker/plainDate.d.ts.map +1 -0
  51. package/dist/DatePicker/plainDate.js +272 -0
  52. package/dist/DatePicker/plainDate.js.map +1 -0
  53. package/dist/Dialog/Dialog.d.ts +12 -2
  54. package/dist/Dialog/Dialog.d.ts.map +1 -1
  55. package/dist/Dialog/Dialog.js +2 -2
  56. package/dist/Dialog/Dialog.js.map +1 -1
  57. package/dist/MarkdownEditor/MarkdownEditor.d.ts +147 -0
  58. package/dist/MarkdownEditor/MarkdownEditor.d.ts.map +1 -0
  59. package/dist/MarkdownEditor/MarkdownEditor.js +492 -0
  60. package/dist/MarkdownEditor/MarkdownEditor.js.map +1 -0
  61. package/dist/MarkdownEditor/markdownCommands.d.ts +202 -0
  62. package/dist/MarkdownEditor/markdownCommands.d.ts.map +1 -0
  63. package/dist/MarkdownEditor/markdownCommands.js +379 -0
  64. package/dist/MarkdownEditor/markdownCommands.js.map +1 -0
  65. package/dist/MarkdownEditor/markdownSpans.d.ts +92 -0
  66. package/dist/MarkdownEditor/markdownSpans.d.ts.map +1 -0
  67. package/dist/MarkdownEditor/markdownSpans.js +303 -0
  68. package/dist/MarkdownEditor/markdownSpans.js.map +1 -0
  69. package/dist/Modal/Modal.d.ts +6 -2
  70. package/dist/Modal/Modal.d.ts.map +1 -1
  71. package/dist/Modal/Modal.js +1 -1
  72. package/dist/Modal/Modal.js.map +1 -1
  73. package/dist/Overlay/OverlayPanel.d.ts +13 -2
  74. package/dist/Overlay/OverlayPanel.d.ts.map +1 -1
  75. package/dist/Overlay/OverlayPanel.js +5 -1
  76. package/dist/Overlay/OverlayPanel.js.map +1 -1
  77. package/dist/categoricalStyles.d.ts +53 -0
  78. package/dist/categoricalStyles.d.ts.map +1 -0
  79. package/dist/categoricalStyles.js +195 -0
  80. package/dist/categoricalStyles.js.map +1 -0
  81. package/dist/index.d.ts +22 -0
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +11 -0
  84. package/dist/index.js.map +1 -1
  85. package/package.json +2 -2
  86. package/src/Badge/Badge.mdx +46 -0
  87. package/src/Badge/Badge.stories.tsx +98 -4
  88. package/src/Badge/Badge.test.tsx +157 -2
  89. package/src/Badge/Badge.tsx +49 -3
  90. package/src/Board/Board.mdx +199 -0
  91. package/src/Board/Board.stories.tsx +707 -0
  92. package/src/Board/Board.test.tsx +310 -0
  93. package/src/Board/Board.tsx +333 -0
  94. package/src/Board/BoardCard.tsx +290 -0
  95. package/src/Board/BoardLaneList.tsx +275 -0
  96. package/src/Board/boardMove.test.ts +213 -0
  97. package/src/Board/boardMove.ts +225 -0
  98. package/src/Board/useBoardDrag.ts +413 -0
  99. package/src/Checkbox/Checkbox.tsx +34 -3
  100. package/src/DataTable/DataTable.mdx +123 -0
  101. package/src/DataTable/DataTable.stories.tsx +411 -0
  102. package/src/DataTable/DataTable.test.tsx +386 -0
  103. package/src/DataTable/DataTable.tsx +497 -0
  104. package/src/DatePicker/DateGrid.tsx +467 -0
  105. package/src/DatePicker/DatePicker.mdx +170 -0
  106. package/src/DatePicker/DatePicker.stories.tsx +268 -0
  107. package/src/DatePicker/DatePicker.test.tsx +455 -0
  108. package/src/DatePicker/DatePicker.tsx +852 -0
  109. package/src/DatePicker/datePresets.ts +34 -0
  110. package/src/DatePicker/parseDateInput.test.ts +187 -0
  111. package/src/DatePicker/parseDateInput.ts +629 -0
  112. package/src/DatePicker/plainDate.test.ts +375 -0
  113. package/src/DatePicker/plainDate.ts +465 -0
  114. package/src/Dialog/Dialog.stories.tsx +48 -0
  115. package/src/Dialog/Dialog.test.tsx +30 -0
  116. package/src/Dialog/Dialog.tsx +13 -1
  117. package/src/MarkdownEditor/MarkdownEditor.mdx +189 -0
  118. package/src/MarkdownEditor/MarkdownEditor.stories.tsx +267 -0
  119. package/src/MarkdownEditor/MarkdownEditor.test.tsx +627 -0
  120. package/src/MarkdownEditor/MarkdownEditor.tsx +862 -0
  121. package/src/MarkdownEditor/markdownCommands.test.ts +384 -0
  122. package/src/MarkdownEditor/markdownCommands.ts +566 -0
  123. package/src/MarkdownEditor/markdownSpans.test.ts +217 -0
  124. package/src/MarkdownEditor/markdownSpans.ts +458 -0
  125. package/src/Modal/Modal.tsx +7 -1
  126. package/src/Overlay/OverlayPanel.tsx +17 -1
  127. package/src/argTypes.storyHelpers.ts +39 -0
  128. package/src/categoricalStyles.ts +244 -0
  129. package/src/index.ts +89 -0
  130. package/src/sourceRules.test.ts +52 -1
@@ -0,0 +1,290 @@
1
+ import type { IntentName } from "@charcuterie/tokens"
2
+ import type { ReactNode } from "react"
3
+ import { useState } from "react"
4
+
5
+ import { Button } from "../Button/Button.tsx"
6
+ import {
7
+ FOCUS_RING_CLASS,
8
+ INTENT_SOLID_FILL_CLASS,
9
+ } from "../intentStyles.ts"
10
+ import { Menu } from "../Menu/Menu.tsx"
11
+ import { TextLink } from "../TextLink/TextLink.tsx"
12
+ import { toClassName } from "../toClassName.ts"
13
+ import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx"
14
+
15
+ export type BoardItem = {
16
+ /**
17
+ * The leading colour bar. Priority, in the consumer this was built
18
+ * for — *"Priority is styled as a leading colour bar, and **never**
19
+ * a coloured word before the title."*
20
+ *
21
+ * Pair it with `accentLabel`. A bar is colour and nothing else, so
22
+ * on its own it fails WCAG 1.4.1 outright and is invisible to
23
+ * every screen reader; the label rides along in a
24
+ * `VisuallyHidden` so the same fact reaches both channels. Docket's
25
+ * first board painted these `--color-danger-9` — a Radix-style
26
+ * scale this token set has never had — so every bar was
27
+ * transparent while every "is it rendered" assertion passed. The
28
+ * families here are `--color-intent-<intent>-<role>`; there is no
29
+ * numeric scale to reach for.
30
+ */
31
+ accentIntent?: IntentName
32
+ /** What the bar means, in words. "Priority 0". */
33
+ accentLabel?: string
34
+ /**
35
+ * Under the title and metadata, spanning the card: a live run
36
+ * line, a gate explanation, a due date. A `ProgressBar` goes here.
37
+ */
38
+ footer?: ReactNode
39
+ /** Where the title navigates. Routed through `RouterLinkProvider`. */
40
+ href?: string
41
+ /**
42
+ * Visibly present but not actionable — Docket's *gated*: a task
43
+ * whose phase has not opened yet. Paint only; it does not disable
44
+ * the title, because being able to read a thing you cannot start
45
+ * yet is the entire point of showing it.
46
+ */
47
+ isDimmed?: boolean
48
+ key: string
49
+ /**
50
+ * Small status marks rendered immediately before the title —
51
+ * running, blocked, stale. Each one needs its own accessible name;
52
+ * a bare coloured dot is the same WCAG 1.4.1 failure as the bar.
53
+ */
54
+ marks?: ReactNode
55
+ /**
56
+ * Chips. Under the title when the lane is narrow, right-aligned
57
+ * beside it when there is room. `Badge` is what goes here.
58
+ */
59
+ meta?: ReactNode
60
+ /** Fires when there is no `href` — for a board that opens a modal. */
61
+ onSelect?: () => void
62
+ title: string
63
+ /** The trailing cluster: an assignee avatar, an elapsed-time chip. */
64
+ trailing?: ReactNode
65
+ }
66
+
67
+ export type BoardCardProps = {
68
+ item: BoardItem
69
+ laneLabel: string
70
+ /**
71
+ * Shown on the move handle instead of the word "Move". The
72
+ * library ships no icons, so the default is words — a `⋮` renders
73
+ * as nothing where the font lacks it, which includes the kiosk
74
+ * image and the ePaper build. An app that already owns a glyph
75
+ * set passes one and buys back the ~55px per row that a word
76
+ * costs, which in a 500px lane is the difference between twenty
77
+ * characters of title and thirty.
78
+ */
79
+ moveIcon?: ReactNode
80
+ /** The lanes this card can be sent to — never its own. */
81
+ moveTargets: readonly { key: string; label: string }[]
82
+ /** Absent on a read-only board, and then so is the handle. */
83
+ onMoveToLane?: (toLaneKey: string) => void
84
+ onStartDrag?: (pointerEvent: {
85
+ clientX: number
86
+ clientY: number
87
+ currentTarget: EventTarget & Element
88
+ pointerId: number
89
+ }) => void
90
+ }
91
+
92
+ /**
93
+ * One row on the board — and *row* is only what it is at some
94
+ * widths.
95
+ *
96
+ * ### The three shapes are one component, chosen by the lane
97
+ *
98
+ * The requirement this was built against is unusually specific, and
99
+ * it is specific because the owner answered "which row style?" with
100
+ * *"all three, depending"*:
101
+ *
102
+ * | The lane is | The card is |
103
+ * | --- | --- |
104
+ * | narrower than `cq-sm` (24rem) | **two lines** — title clamped to two, metadata underneath, only the trailing slot beside it |
105
+ * | `cq-sm` to `cq-lg` | **one line** — title ellipsised, metadata right-aligned |
106
+ * | `cq-lg` (48rem) and wider | **a card** — its own border, surface and elevation, instead of a divider in a list |
107
+ *
108
+ * Every one of those is a **container** query against the lane's
109
+ * list, never a media query, and the difference is not pedantry: a
110
+ * lane in a three-up board is narrow on a 4K display, and the owner
111
+ * browses zoomed in, so a 1500px window at 175% zoom is ~860
112
+ * effective CSS pixels. Window width is not a proxy for the space
113
+ * this card has. A `@media (max-width: …)` anywhere in this file
114
+ * would be a defect.
115
+ *
116
+ * It also dissolved a bug rather than only satisfying a preference:
117
+ * a *fixed* two-line row collapsed its title to `a…` inside a narrow
118
+ * lane, because the metadata beside it could not shrink. A card
119
+ * sized by its container has no such state to be in.
120
+ *
121
+ * ### The move handle is a button first and a drag handle second
122
+ *
123
+ * One control, two drivers. Pressing it opens a `Menu` of the other
124
+ * lanes — the keyboard and screen-reader path, which also works on
125
+ * touch, and works in the Narrow View where the other lanes are not
126
+ * on screen at all. Dragging from it moves the card directly. Both
127
+ * commit through the same callback, so there is no second code path
128
+ * to keep honest and neither is a fallback for the other.
129
+ */
130
+ export const BoardCard = ({
131
+ item,
132
+ laneLabel,
133
+ moveIcon,
134
+ moveTargets,
135
+ onMoveToLane,
136
+ onStartDrag,
137
+ }: BoardCardProps): ReactNode => {
138
+ const [isMenuVisible, setIsMenuVisible] = useState(false)
139
+
140
+ const titleContent = (
141
+ // `line-clamp-none` before `truncate`, because `line-clamp-2`
142
+ // sets `display: -webkit-box` and `text-overflow` has nothing to
143
+ // ellipsise inside one.
144
+ <span className="line-clamp-2 cq-sm:line-clamp-none cq-sm:block cq-sm:truncate">
145
+ {item.title}
146
+ </span>
147
+ )
148
+
149
+ return (
150
+ <li
151
+ className={toClassName(
152
+ "group relative flex gap-2 border-border-subtle border-b py-2 pe-1 last:border-b-0",
153
+ // At `cq-lg` the list stops being a list of rows: the
154
+ // divider goes and each card grows its own box. The *list*
155
+ // cannot do this to itself — it is the container, and a
156
+ // container query never matches its own container.
157
+ "cq-lg:mb-2 cq-lg:rounded-lg cq-lg:border cq-lg:bg-surface-raised cq-lg:p-3 cq-lg:shadow-low",
158
+ item.isDimmed && "opacity-60",
159
+ )}
160
+ data-board-card={item.key}
161
+ >
162
+ {item.accentIntent ? (
163
+ <span
164
+ className={toClassName(
165
+ "w-1 shrink-0 self-stretch rounded-full",
166
+ INTENT_SOLID_FILL_CLASS[item.accentIntent],
167
+ )}
168
+ >
169
+ {item.accentLabel ? (
170
+ <VisuallyHidden>
171
+ {item.accentLabel}
172
+ </VisuallyHidden>
173
+ ) : null}
174
+ </span>
175
+ ) : null}
176
+
177
+ <div className="flex min-w-0 flex-1 flex-col gap-1">
178
+ <div className="flex min-w-0 items-start gap-2">
179
+ <div className="flex min-w-0 flex-1 flex-col gap-1 cq-sm:flex-row cq-sm:items-center cq-sm:gap-2">
180
+ <div className="flex min-w-0 flex-1 items-start gap-1.5 text-sm leading-snug">
181
+ {item.marks ? (
182
+ <span className="flex shrink-0 items-center gap-1 pt-1">
183
+ {item.marks}
184
+ </span>
185
+ ) : null}
186
+
187
+ {item.href ? (
188
+ <TextLink
189
+ appearance="standalone"
190
+ className="min-w-0 max-w-full font-normal"
191
+ href={item.href}
192
+ intent="neutral"
193
+ >
194
+ {titleContent}
195
+ </TextLink>
196
+ ) : item.onSelect ? (
197
+ <button
198
+ className={toClassName(
199
+ "min-w-0 max-w-full cursor-pointer rounded-xs text-start text-intent-neutral-content hover:underline",
200
+ FOCUS_RING_CLASS,
201
+ )}
202
+ onClick={item.onSelect}
203
+ type="button"
204
+ >
205
+ {titleContent}
206
+ </button>
207
+ ) : (
208
+ titleContent
209
+ )}
210
+ </div>
211
+
212
+ {item.meta ? (
213
+ <div className="flex min-w-0 flex-wrap items-center gap-1 cq-sm:shrink-0 cq-sm:flex-nowrap cq-sm:justify-end">
214
+ {item.meta}
215
+ </div>
216
+ ) : null}
217
+ </div>
218
+
219
+ {item.trailing || onMoveToLane ? (
220
+ <div className="flex shrink-0 items-center gap-1">
221
+ {item.trailing}
222
+
223
+ {onMoveToLane && moveTargets.length > 0 ? (
224
+ <Menu
225
+ isVisible={isMenuVisible}
226
+ items={moveTargets.map((target) => ({
227
+ key: target.key,
228
+ label: target.label,
229
+ onSelect: () => {
230
+ onMoveToLane(target.key)
231
+ },
232
+ }))}
233
+ onDismiss={() => {
234
+ setIsMenuVisible(false)
235
+ }}
236
+ placement="bottom-end"
237
+ trigger={
238
+ <Button
239
+ appearance="ghost"
240
+ intent="neutral"
241
+ // `touch-none` so a touch drag is a drag
242
+ // rather than a page scroll. Only the handle
243
+ // needs it; the rest of the gesture is covered
244
+ // by `preventDefault` on the captured
245
+ // `pointermove`.
246
+ className="touch-none opacity-70 group-hover:opacity-100"
247
+ onClick={() => {
248
+ setIsMenuVisible(
249
+ (isVisible) => !isVisible,
250
+ )
251
+ }}
252
+ onPointerDown={(pointerEvent) => {
253
+ onStartDrag?.(pointerEvent)
254
+ }}
255
+ size="sm"
256
+ sizing={moveIcon ? "icon" : "control"}
257
+ >
258
+ {moveIcon ?? "Move"}
259
+
260
+ {/*
261
+ * Thirty controls all named "Move" is thirty
262
+ * indistinguishable buttons to a screen reader
263
+ * and to `getByRole("button", { name })`, so
264
+ * the accessible name says *which* card and
265
+ * where it currently is. With an icon this is
266
+ * the whole name; with the word it qualifies
267
+ * it.
268
+ */}
269
+ <VisuallyHidden>
270
+ {moveIcon
271
+ ? `Move ${item.title}, currently in ${laneLabel}`
272
+ : ` ${item.title}, currently in ${laneLabel}`}
273
+ </VisuallyHidden>
274
+ </Button>
275
+ }
276
+ />
277
+ ) : null}
278
+ </div>
279
+ ) : null}
280
+ </div>
281
+
282
+ {item.footer ? (
283
+ <div className="min-w-0 text-content-secondary text-xs">
284
+ {item.footer}
285
+ </div>
286
+ ) : null}
287
+ </div>
288
+ </li>
289
+ )
290
+ }
@@ -0,0 +1,275 @@
1
+ import { useUniqueId } from "@charcuterie/logic"
2
+ import type { ReactNode } from "react"
3
+
4
+ import { Badge } from "../Badge/Badge.tsx"
5
+ import { Button } from "../Button/Button.tsx"
6
+ import { EmptyState } from "../EmptyState/EmptyState.tsx"
7
+ import { toClassName } from "../toClassName.ts"
8
+ import type { BoardItem } from "./BoardCard.tsx"
9
+ import { BoardCard } from "./BoardCard.tsx"
10
+
11
+ export type BoardLane = {
12
+ /**
13
+ * Lane-level controls, beside the count — Docket's *"Accept all"*
14
+ * on the review lane. `Button`s, usually.
15
+ */
16
+ actions?: ReactNode
17
+ /**
18
+ * Shown instead of the rows when the lane is empty. Defaults to a
19
+ * real `EmptyState`, because *"Real empty states"* was a stated
20
+ * requirement and an empty lane is the one a person sees most.
21
+ */
22
+ emptyState?: ReactNode
23
+ /**
24
+ * The **true** size of the lane, when `items` is a truncated page
25
+ * of it. This is what the count badge shows, and the difference
26
+ * between it and `items.length` is what the "+ n more" line says.
27
+ *
28
+ * Defaults to `items.length`, so a consumer that does not truncate
29
+ * says nothing about truncation.
30
+ */
31
+ itemCount?: number
32
+ items: readonly BoardItem[]
33
+ key: string
34
+ label: string
35
+ /**
36
+ * What "+ n more in this lane" does. With no handler the line is
37
+ * still shown — the honest count matters more than the
38
+ * affordance — but it is text rather than a button.
39
+ */
40
+ onShowMore?: () => void
41
+ }
42
+
43
+ export type BoardLaneListProps = {
44
+ /**
45
+ * Pixels from the top of the list box, or `null` for no
46
+ * indicator. Absolute rather than a real element between two
47
+ * cards: an indicator that takes up space moves the cards it is
48
+ * measured against, and the resulting feedback loop is why so many
49
+ * hand-rolled boards flicker.
50
+ */
51
+ dropIndicatorOffset: number | null
52
+ /**
53
+ * The lane heading's level. The caller's, because only the caller
54
+ * knows what the board sits under — an `<h3>` directly below the
55
+ * page's `<h1>` is an axe `heading-order` failure, and a board is
56
+ * as likely to sit under a section heading as under the page
57
+ * title.
58
+ */
59
+ headingLevel: 2 | 3 | 4 | 5
60
+ isVisibleWhenNarrow: boolean
61
+ lane: BoardLane
62
+ moveIcon?: ReactNode
63
+ moveTargets: readonly { key: string; label: string }[]
64
+ onMoveToLane?: (
65
+ itemKey: string,
66
+ toLaneKey: string,
67
+ ) => void
68
+ onStartDrag?: (
69
+ pointerEvent: {
70
+ clientX: number
71
+ clientY: number
72
+ currentTarget: EventTarget & Element
73
+ pointerId: number
74
+ },
75
+ origin: { fromIndex: number; itemKey: string },
76
+ ) => void
77
+ registerListElement: (element: HTMLElement | null) => void
78
+ }
79
+
80
+ /**
81
+ * One lane: a named heading, an honest count, its rows, and what it
82
+ * says when there are none.
83
+ *
84
+ * ### The lane is the container, and that is the whole design
85
+ *
86
+ * `@container` sits on a wrapper inside this section, so everything
87
+ * below it — the panel, its rows — is sized by **the lane**. Three
88
+ * lanes in a 1600px window are ~500px each; one lane in the Narrow
89
+ * View is ~360px; the same lane in a docked sidebar is 280px. All
90
+ * three happen on the same monitor, and no media query can tell
91
+ * them apart.
92
+ *
93
+ * The wrapper is a wrapper because a container query matches
94
+ * **descendants only**. Declaring `@container` on the panel itself
95
+ * would compile, generate real CSS, and silently never fire — so
96
+ * the panel could never take its own frame off at `cq-lg`, which is
97
+ * the one thing the widest layout asks for.
98
+ *
99
+ * This `<section>` is outside all of that: it carries the
100
+ * hidden/shown classes, and those resolve against the **board's**
101
+ * container, one level up.
102
+ *
103
+ * ### Long lanes truncate; they do not scroll
104
+ *
105
+ * *"Long lanes truncate with an honest `+ n more` rather than
106
+ * scrolling forever."* So the lane never grows a scrollbar and never
107
+ * virtualises. `itemCount` is the real total and `items` is whatever
108
+ * the consumer chose to render — which keeps paging, sorting and any
109
+ * windowing on the side that owns the data, and keeps this component
110
+ * from pretending to know how many rows are worth painting.
111
+ */
112
+ export const BoardLaneList = ({
113
+ dropIndicatorOffset,
114
+ headingLevel,
115
+ isVisibleWhenNarrow,
116
+ lane,
117
+ moveIcon,
118
+ moveTargets,
119
+ onMoveToLane,
120
+ onStartDrag,
121
+ registerListElement,
122
+ }: BoardLaneListProps): ReactNode => {
123
+ const headingId = useUniqueId()
124
+
125
+ const itemCount = lane.itemCount ?? lane.items.length
126
+
127
+ const hiddenCount = itemCount - lane.items.length
128
+
129
+ const Heading = `h${headingLevel}` as const
130
+
131
+ return (
132
+ /**
133
+ * `role="group"`, **not** a landmark — the same call the
134
+ * accordion made, for the same reason
135
+ * ([decision](../../../docs/decisions/2026-07-31-an-accordion-panel-is-a-group-not-a-landmark.md)).
136
+ * A named `<section>` is a `region`, and a board would then put
137
+ * three or four landmarks on a page that has one job. Worse,
138
+ * two boards on one page — or three copies of one in a
139
+ * container-width story — put two landmarks called "Todo" in
140
+ * the document, which is axe's `landmark-unique` and a genuine
141
+ * navigation problem rather than a lint nit. The board itself
142
+ * stays a region; its lanes are groups inside it.
143
+ *
144
+ * A `<div>` rather than a `<section role="group">`, and the
145
+ * difference is not cosmetic: axe's `landmark-unique` matches
146
+ * `section[aria-labelledby]` on the **element**, so a named
147
+ * `<section>` is still audited as a landmark after its role has
148
+ * been overridden. It failed exactly that way here before this
149
+ * line changed.
150
+ */
151
+ // biome-ignore lint/a11y/useSemanticElements: the semantic element for `group` is `<fieldset>`, which is a form-control grouping — it drags `<legend>` semantics, a border and form-reset behaviour onto a lane that holds a list of cards. Same call `Menu`'s group and `AccordionSection` already made.
152
+ <div
153
+ aria-labelledby={headingId}
154
+ role="group"
155
+ className={toClassName(
156
+ "min-w-0 flex-1 flex-col gap-2",
157
+ // Below the board's own `cq-lg`, one lane is on screen and
158
+ // the rest are chosen with the segmented control above. This
159
+ // queries the **board**, not the list below — an element
160
+ // carrying `container-type` still resolves its own container
161
+ // queries against its nearest ancestor container.
162
+ isVisibleWhenNarrow ? "flex" : "hidden cq-lg:flex",
163
+ )}
164
+ >
165
+ {/*
166
+ * The container is this wrapper rather than the panel inside
167
+ * it, and that is what buys the `cq-lg` treatment: an element
168
+ * cannot query the container it declares, so a panel that
169
+ * declared its own container could never take its own frame
170
+ * off again. One `<div>` and the whole width-dependent
171
+ * treatment becomes expressible.
172
+ */}
173
+ <div className="@container">
174
+ <div className="flex flex-col gap-2 rounded-lg border border-border-subtle bg-surface-raised px-3 py-2 cq-lg:border-0 cq-lg:bg-transparent cq-lg:p-0">
175
+ <header className="flex items-center gap-2">
176
+ <Heading
177
+ className="font-semibold text-content-primary text-sm"
178
+ id={headingId}
179
+ >
180
+ {lane.label}
181
+ </Heading>
182
+
183
+ <Badge
184
+ appearance="soft"
185
+ intent="neutral"
186
+ size="sm"
187
+ >
188
+ {String(itemCount)}
189
+ </Badge>
190
+
191
+ <span className="flex-1" />
192
+
193
+ {lane.actions}
194
+ </header>
195
+
196
+ <ul
197
+ className="relative flex flex-col"
198
+ ref={registerListElement}
199
+ >
200
+ {lane.items.length === 0 ? (
201
+ <li>
202
+ {lane.emptyState ?? (
203
+ <EmptyState
204
+ description="Nothing is waiting here."
205
+ heading="Nothing here"
206
+ headingLevel={
207
+ // One below the lane's own, so the outline
208
+ // stays whole wherever the board is mounted.
209
+ (headingLevel + 1) as 3 | 4 | 5 | 6
210
+ }
211
+ size="sm"
212
+ />
213
+ )}
214
+ </li>
215
+ ) : (
216
+ lane.items.map((item, index) => (
217
+ <BoardCard
218
+ item={item}
219
+ key={item.key}
220
+ laneLabel={lane.label}
221
+ moveIcon={moveIcon}
222
+ moveTargets={moveTargets}
223
+ onMoveToLane={
224
+ onMoveToLane
225
+ ? (toLaneKey) => {
226
+ onMoveToLane(item.key, toLaneKey)
227
+ }
228
+ : undefined
229
+ }
230
+ onStartDrag={
231
+ onStartDrag
232
+ ? (pointerEvent) => {
233
+ onStartDrag(pointerEvent, {
234
+ fromIndex: index,
235
+ itemKey: item.key,
236
+ })
237
+ }
238
+ : undefined
239
+ }
240
+ />
241
+ ))
242
+ )}
243
+
244
+ {dropIndicatorOffset === null ? null : (
245
+ <li
246
+ aria-hidden="true"
247
+ className="pointer-events-none absolute end-0 start-0 h-0.5 rounded-full bg-intent-accent-solid"
248
+ style={{
249
+ insetBlockStart: `${dropIndicatorOffset}px`,
250
+ }}
251
+ />
252
+ )}
253
+ </ul>
254
+
255
+ {hiddenCount > 0 ? (
256
+ <p className="text-content-muted text-xs">
257
+ {lane.onShowMore ? (
258
+ <Button
259
+ appearance="ghost"
260
+ intent="neutral"
261
+ onClick={lane.onShowMore}
262
+ size="sm"
263
+ >
264
+ {`+ ${hiddenCount} more in ${lane.label}`}
265
+ </Button>
266
+ ) : (
267
+ `+ ${hiddenCount} more in ${lane.label}`
268
+ )}
269
+ </p>
270
+ ) : null}
271
+ </div>
272
+ </div>
273
+ </div>
274
+ )
275
+ }