@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.
- package/README.md +10 -0
- package/dist/Badge/Badge.d.ts +33 -3
- package/dist/Badge/Badge.d.ts.map +1 -1
- package/dist/Badge/Badge.js +20 -2
- package/dist/Badge/Badge.js.map +1 -1
- package/dist/Board/Board.d.ts +119 -0
- package/dist/Board/Board.d.ts.map +1 -0
- package/dist/Board/Board.js +165 -0
- package/dist/Board/Board.js.map +1 -0
- package/dist/Board/BoardCard.d.ts +120 -0
- package/dist/Board/BoardCard.d.ts.map +1 -0
- package/dist/Board/BoardCard.js +77 -0
- package/dist/Board/BoardCard.js.map +1 -0
- package/dist/Board/BoardLaneList.d.ts +103 -0
- package/dist/Board/BoardLaneList.d.ts.map +1 -0
- package/dist/Board/BoardLaneList.js +69 -0
- package/dist/Board/BoardLaneList.js.map +1 -0
- package/dist/Board/boardMove.d.ts +116 -0
- package/dist/Board/boardMove.d.ts.map +1 -0
- package/dist/Board/boardMove.js +116 -0
- package/dist/Board/boardMove.js.map +1 -0
- package/dist/Board/useBoardDrag.d.ts +44 -0
- package/dist/Board/useBoardDrag.d.ts.map +1 -0
- package/dist/Board/useBoardDrag.js +237 -0
- package/dist/Board/useBoardDrag.js.map +1 -0
- package/dist/Checkbox/Checkbox.d.ts.map +1 -1
- package/dist/Checkbox/Checkbox.js +15 -4
- package/dist/Checkbox/Checkbox.js.map +1 -1
- package/dist/DataTable/DataTable.d.ts +159 -0
- package/dist/DataTable/DataTable.d.ts.map +1 -0
- package/dist/DataTable/DataTable.js +197 -0
- package/dist/DataTable/DataTable.js.map +1 -0
- package/dist/DatePicker/DateGrid.d.ts +79 -0
- package/dist/DatePicker/DateGrid.d.ts.map +1 -0
- package/dist/DatePicker/DateGrid.js +220 -0
- package/dist/DatePicker/DateGrid.js.map +1 -0
- package/dist/DatePicker/DatePicker.d.ts +138 -0
- package/dist/DatePicker/DatePicker.d.ts.map +1 -0
- package/dist/DatePicker/DatePicker.js +384 -0
- package/dist/DatePicker/DatePicker.js.map +1 -0
- package/dist/DatePicker/datePresets.d.ts +29 -0
- package/dist/DatePicker/datePresets.d.ts.map +1 -0
- package/dist/DatePicker/datePresets.js +28 -0
- package/dist/DatePicker/datePresets.js.map +1 -0
- package/dist/DatePicker/parseDateInput.d.ts +106 -0
- package/dist/DatePicker/parseDateInput.d.ts.map +1 -0
- package/dist/DatePicker/parseDateInput.js +419 -0
- package/dist/DatePicker/parseDateInput.js.map +1 -0
- package/dist/DatePicker/plainDate.d.ts +167 -0
- package/dist/DatePicker/plainDate.d.ts.map +1 -0
- package/dist/DatePicker/plainDate.js +272 -0
- package/dist/DatePicker/plainDate.js.map +1 -0
- package/dist/Dialog/Dialog.d.ts +12 -2
- package/dist/Dialog/Dialog.d.ts.map +1 -1
- package/dist/Dialog/Dialog.js +2 -2
- package/dist/Dialog/Dialog.js.map +1 -1
- package/dist/MarkdownEditor/MarkdownEditor.d.ts +147 -0
- package/dist/MarkdownEditor/MarkdownEditor.d.ts.map +1 -0
- package/dist/MarkdownEditor/MarkdownEditor.js +492 -0
- package/dist/MarkdownEditor/MarkdownEditor.js.map +1 -0
- package/dist/MarkdownEditor/markdownCommands.d.ts +202 -0
- package/dist/MarkdownEditor/markdownCommands.d.ts.map +1 -0
- package/dist/MarkdownEditor/markdownCommands.js +379 -0
- package/dist/MarkdownEditor/markdownCommands.js.map +1 -0
- package/dist/MarkdownEditor/markdownSpans.d.ts +92 -0
- package/dist/MarkdownEditor/markdownSpans.d.ts.map +1 -0
- package/dist/MarkdownEditor/markdownSpans.js +303 -0
- package/dist/MarkdownEditor/markdownSpans.js.map +1 -0
- package/dist/Modal/Modal.d.ts +6 -2
- package/dist/Modal/Modal.d.ts.map +1 -1
- package/dist/Modal/Modal.js +1 -1
- package/dist/Modal/Modal.js.map +1 -1
- package/dist/Overlay/OverlayPanel.d.ts +13 -2
- package/dist/Overlay/OverlayPanel.d.ts.map +1 -1
- package/dist/Overlay/OverlayPanel.js +5 -1
- package/dist/Overlay/OverlayPanel.js.map +1 -1
- package/dist/categoricalStyles.d.ts +53 -0
- package/dist/categoricalStyles.d.ts.map +1 -0
- package/dist/categoricalStyles.js +195 -0
- package/dist/categoricalStyles.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/Badge/Badge.mdx +46 -0
- package/src/Badge/Badge.stories.tsx +98 -4
- package/src/Badge/Badge.test.tsx +157 -2
- package/src/Badge/Badge.tsx +49 -3
- package/src/Board/Board.mdx +199 -0
- package/src/Board/Board.stories.tsx +707 -0
- package/src/Board/Board.test.tsx +310 -0
- package/src/Board/Board.tsx +333 -0
- package/src/Board/BoardCard.tsx +290 -0
- package/src/Board/BoardLaneList.tsx +275 -0
- package/src/Board/boardMove.test.ts +213 -0
- package/src/Board/boardMove.ts +225 -0
- package/src/Board/useBoardDrag.ts +413 -0
- package/src/Checkbox/Checkbox.tsx +34 -3
- package/src/DataTable/DataTable.mdx +123 -0
- package/src/DataTable/DataTable.stories.tsx +411 -0
- package/src/DataTable/DataTable.test.tsx +386 -0
- package/src/DataTable/DataTable.tsx +497 -0
- package/src/DatePicker/DateGrid.tsx +467 -0
- package/src/DatePicker/DatePicker.mdx +170 -0
- package/src/DatePicker/DatePicker.stories.tsx +268 -0
- package/src/DatePicker/DatePicker.test.tsx +455 -0
- package/src/DatePicker/DatePicker.tsx +852 -0
- package/src/DatePicker/datePresets.ts +34 -0
- package/src/DatePicker/parseDateInput.test.ts +187 -0
- package/src/DatePicker/parseDateInput.ts +629 -0
- package/src/DatePicker/plainDate.test.ts +375 -0
- package/src/DatePicker/plainDate.ts +465 -0
- package/src/Dialog/Dialog.stories.tsx +48 -0
- package/src/Dialog/Dialog.test.tsx +30 -0
- package/src/Dialog/Dialog.tsx +13 -1
- package/src/MarkdownEditor/MarkdownEditor.mdx +189 -0
- package/src/MarkdownEditor/MarkdownEditor.stories.tsx +267 -0
- package/src/MarkdownEditor/MarkdownEditor.test.tsx +627 -0
- package/src/MarkdownEditor/MarkdownEditor.tsx +862 -0
- package/src/MarkdownEditor/markdownCommands.test.ts +384 -0
- package/src/MarkdownEditor/markdownCommands.ts +566 -0
- package/src/MarkdownEditor/markdownSpans.test.ts +217 -0
- package/src/MarkdownEditor/markdownSpans.ts +458 -0
- package/src/Modal/Modal.tsx +7 -1
- package/src/Overlay/OverlayPanel.tsx +17 -1
- package/src/argTypes.storyHelpers.ts +39 -0
- package/src/categoricalStyles.ts +244 -0
- package/src/index.ts +89 -0
- 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
|
+
}
|