@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,413 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
} from "react"
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
BoardDropLane,
|
|
10
|
+
BoardDropTarget,
|
|
11
|
+
BoardRect,
|
|
12
|
+
} from "./boardMove.ts"
|
|
13
|
+
import { chooseDropTarget } from "./boardMove.ts"
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Pointer dragging for the board — **on Pointer Events, with no
|
|
17
|
+
* drag-and-drop dependency**, and the reasoning is written down in
|
|
18
|
+
* `docs/decisions/2026-08-19-the-board-owns-the-move-and-takes-no-drag-and-drop-dependency.md`.
|
|
19
|
+
*
|
|
20
|
+
* The short version: a published design system's dependency list is
|
|
21
|
+
* inherited by every consumer, `castkit/packages/slatecast` has 60 KB
|
|
22
|
+
* gzipped to spend in total, and the two candidates cost 12–30 KB
|
|
23
|
+
* before a card has been drawn. What they buy is sensors, collision
|
|
24
|
+
* strategies, and sortable animation — a general solution to a
|
|
25
|
+
* problem this component has a specific version of: one card, from
|
|
26
|
+
* one lane, into one other lane.
|
|
27
|
+
*
|
|
28
|
+
* ### Pointer Events rather than HTML5 drag-and-drop
|
|
29
|
+
*
|
|
30
|
+
* `draggable="true"` is the zero-dependency instinct and it is the
|
|
31
|
+
* wrong one. It does not fire on touch at all — so the Narrow View,
|
|
32
|
+
* the kiosk density, and the tablet the owner actually reads this on
|
|
33
|
+
* get nothing — its drag image is a browser-drawn ghost with no
|
|
34
|
+
* styling hook worth using, and `dataTransfer` is readable only
|
|
35
|
+
* inside a real `drop`, which makes a live drop indicator awkward.
|
|
36
|
+
* Pointer Events are one code path for mouse, touch and pen, and
|
|
37
|
+
* `setPointerCapture` means the drag survives the pointer leaving the
|
|
38
|
+
* card, the lane, and the window.
|
|
39
|
+
*
|
|
40
|
+
* ### Every rectangle is measured once, at grab
|
|
41
|
+
*
|
|
42
|
+
* Re-measuring on each `pointermove` reads more correct and is
|
|
43
|
+
* worse. The drop indicator is a real element; inserting it moves
|
|
44
|
+
* the cards below it; the next move measures the moved cards and
|
|
45
|
+
* computes a different index; the indicator jumps back. That
|
|
46
|
+
* feedback loop is why the indicator here is `position: absolute`
|
|
47
|
+
* against a snapshot taken at grab — the layout under the pointer
|
|
48
|
+
* is frozen for the length of the drag, so the number the person
|
|
49
|
+
* sees and the number that gets committed are the same number.
|
|
50
|
+
*
|
|
51
|
+
* The cost is honest and small: a lane that reflows mid-drag (a live
|
|
52
|
+
* agent-run line wrapping to two lines) is measured stale. A board
|
|
53
|
+
* where that matters wants a re-snapshot on `ResizeObserver`, which
|
|
54
|
+
* is a change to this file and to nothing else.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* How far the pointer has to travel before a press becomes a drag.
|
|
59
|
+
*
|
|
60
|
+
* Without it the handle is a button that cannot be clicked: every
|
|
61
|
+
* mouse-down/up pair carries a pixel or two of movement, and a
|
|
62
|
+
* "drag" of one pixel would commit a move and swallow the click that
|
|
63
|
+
* was meant to open the move menu. Four device-independent pixels is
|
|
64
|
+
* the usual figure and it is comfortably below a deliberate drag.
|
|
65
|
+
*/
|
|
66
|
+
const DRAG_THRESHOLD_PX = 4
|
|
67
|
+
|
|
68
|
+
export type BoardDragOrigin = {
|
|
69
|
+
fromIndex: number
|
|
70
|
+
fromLaneKey: string
|
|
71
|
+
itemKey: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type BoardDragState = BoardDragOrigin & {
|
|
75
|
+
/**
|
|
76
|
+
* Where the indicator is drawn, in pixels from the top of the
|
|
77
|
+
* target lane's list box. `null` while the pointer is over no
|
|
78
|
+
* lane the snapshot knows about.
|
|
79
|
+
*/
|
|
80
|
+
indicatorOffset: number | null
|
|
81
|
+
target: BoardDropTarget | null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type LaneSnapshot = BoardDropLane & {
|
|
85
|
+
gap: number
|
|
86
|
+
listTop: number
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const toRect = (element: Element): BoardRect => {
|
|
90
|
+
const rect = element.getBoundingClientRect()
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
height: rect.height,
|
|
94
|
+
left: rect.left,
|
|
95
|
+
top: rect.top,
|
|
96
|
+
width: rect.width,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Where the line goes for a given insertion index — the midpoint of
|
|
102
|
+
* the gap the card would be dropped into, in the list's own
|
|
103
|
+
* coordinates.
|
|
104
|
+
*
|
|
105
|
+
* Split out and exported because it is the one piece of this file
|
|
106
|
+
* that is pure arithmetic over the snapshot, and the three cases
|
|
107
|
+
* (before everything, between two, after everything) are exactly
|
|
108
|
+
* where an off-by-one lives.
|
|
109
|
+
*/
|
|
110
|
+
export const getIndicatorOffset = (
|
|
111
|
+
lane: LaneSnapshot,
|
|
112
|
+
index: number,
|
|
113
|
+
): number => {
|
|
114
|
+
const { cardRects, gap, listTop } = lane
|
|
115
|
+
|
|
116
|
+
if (cardRects.length === 0) {
|
|
117
|
+
return 0
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const previous = cardRects[index - 1]
|
|
121
|
+
|
|
122
|
+
const next = cardRects[index]
|
|
123
|
+
|
|
124
|
+
if (!previous) {
|
|
125
|
+
return (
|
|
126
|
+
(cardRects[0]?.top ?? listTop) - listTop - gap / 2
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!next) {
|
|
131
|
+
return (
|
|
132
|
+
previous.top + previous.height - listTop + gap / 2
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
(previous.top + previous.height + next.top) / 2 -
|
|
138
|
+
listTop
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export const useBoardDrag = ({
|
|
143
|
+
onDrop,
|
|
144
|
+
}: {
|
|
145
|
+
onDrop: (
|
|
146
|
+
origin: BoardDragOrigin,
|
|
147
|
+
target: BoardDropTarget,
|
|
148
|
+
) => void
|
|
149
|
+
}): {
|
|
150
|
+
drag: BoardDragState | null
|
|
151
|
+
registerLane: (
|
|
152
|
+
laneKey: string,
|
|
153
|
+
) => (element: HTMLElement | null) => void
|
|
154
|
+
startDrag: (
|
|
155
|
+
pointerEvent: {
|
|
156
|
+
clientX: number
|
|
157
|
+
clientY: number
|
|
158
|
+
currentTarget: EventTarget & Element
|
|
159
|
+
pointerId: number
|
|
160
|
+
},
|
|
161
|
+
origin: BoardDragOrigin,
|
|
162
|
+
) => void
|
|
163
|
+
} => {
|
|
164
|
+
const laneElements = useRef(
|
|
165
|
+
new Map<string, HTMLElement>(),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
const snapshot = useRef<LaneSnapshot[]>([])
|
|
169
|
+
|
|
170
|
+
const [drag, setDrag] = useState<BoardDragState | null>(
|
|
171
|
+
null,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The live drag, readable from a listener that was registered
|
|
176
|
+
* once. The listeners below are attached for the whole life of the
|
|
177
|
+
* component rather than added and removed per drag — re-attaching
|
|
178
|
+
* on every state change is how a `pointerup` gets missed — so they
|
|
179
|
+
* cannot close over `drag` itself.
|
|
180
|
+
*/
|
|
181
|
+
const dragRef = useRef<BoardDragState | null>(null)
|
|
182
|
+
|
|
183
|
+
const setDragState = useCallback(
|
|
184
|
+
(next: BoardDragState | null) => {
|
|
185
|
+
dragRef.current = next
|
|
186
|
+
|
|
187
|
+
setDrag(next)
|
|
188
|
+
},
|
|
189
|
+
[],
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
const registerLane = useCallback(
|
|
193
|
+
(laneKey: string) => (element: HTMLElement | null) => {
|
|
194
|
+
if (element) {
|
|
195
|
+
laneElements.current.set(laneKey, element)
|
|
196
|
+
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
laneElements.current.delete(laneKey)
|
|
201
|
+
},
|
|
202
|
+
[],
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
const takeSnapshot = useCallback(() => {
|
|
206
|
+
snapshot.current = Array.from(
|
|
207
|
+
laneElements.current,
|
|
208
|
+
([key, element]) => {
|
|
209
|
+
const cardElements = Array.from(
|
|
210
|
+
element.querySelectorAll("[data-board-card]"),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
cardRects: cardElements.map(toRect),
|
|
215
|
+
gap:
|
|
216
|
+
Number.parseFloat(
|
|
217
|
+
globalThis.getComputedStyle(element).rowGap,
|
|
218
|
+
) || 0,
|
|
219
|
+
key,
|
|
220
|
+
listTop: element.getBoundingClientRect().top,
|
|
221
|
+
rect: toRect(element),
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
)
|
|
225
|
+
}, [])
|
|
226
|
+
|
|
227
|
+
const moveTo = useCallback(
|
|
228
|
+
(point: { x: number; y: number }) => {
|
|
229
|
+
const origin = dragRef.current
|
|
230
|
+
|
|
231
|
+
if (!origin) {
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const target = chooseDropTarget(
|
|
236
|
+
snapshot.current,
|
|
237
|
+
point,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
const lane = snapshot.current.find(
|
|
241
|
+
(one) => one.key === target?.laneKey,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
setDragState({
|
|
245
|
+
...origin,
|
|
246
|
+
indicatorOffset:
|
|
247
|
+
lane && target
|
|
248
|
+
? getIndicatorOffset(lane, target.index)
|
|
249
|
+
: null,
|
|
250
|
+
target,
|
|
251
|
+
})
|
|
252
|
+
},
|
|
253
|
+
[setDragState],
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
const startDrag = useCallback(
|
|
257
|
+
(
|
|
258
|
+
pointerEvent: {
|
|
259
|
+
clientX: number
|
|
260
|
+
clientY: number
|
|
261
|
+
currentTarget: EventTarget & Element
|
|
262
|
+
pointerId: number
|
|
263
|
+
},
|
|
264
|
+
origin: BoardDragOrigin,
|
|
265
|
+
) => {
|
|
266
|
+
const handle = pointerEvent.currentTarget
|
|
267
|
+
|
|
268
|
+
const startX = pointerEvent.clientX
|
|
269
|
+
|
|
270
|
+
const startY = pointerEvent.clientY
|
|
271
|
+
|
|
272
|
+
// The capture goes on the handle, so `pointermove` keeps
|
|
273
|
+
// arriving after the pointer has left it — which it does
|
|
274
|
+
// immediately, because the card it is dragging moves out from
|
|
275
|
+
// under it.
|
|
276
|
+
if (handle instanceof HTMLElement) {
|
|
277
|
+
handle.setPointerCapture?.(pointerEvent.pointerId)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const onPointerMove = (moveEvent: PointerEvent) => {
|
|
281
|
+
if (
|
|
282
|
+
moveEvent.pointerId !== pointerEvent.pointerId
|
|
283
|
+
) {
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (
|
|
288
|
+
!dragRef.current &&
|
|
289
|
+
Math.hypot(
|
|
290
|
+
moveEvent.clientX - startX,
|
|
291
|
+
moveEvent.clientY - startY,
|
|
292
|
+
) < DRAG_THRESHOLD_PX
|
|
293
|
+
) {
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (!dragRef.current) {
|
|
298
|
+
takeSnapshot()
|
|
299
|
+
|
|
300
|
+
setDragState({
|
|
301
|
+
...origin,
|
|
302
|
+
indicatorOffset: null,
|
|
303
|
+
target: null,
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// A touch drag scrolls the page unless something says
|
|
308
|
+
// otherwise, and `touch-action: none` on the handle only
|
|
309
|
+
// covers the handle. This covers the rest of the gesture.
|
|
310
|
+
moveEvent.preventDefault()
|
|
311
|
+
|
|
312
|
+
moveTo({
|
|
313
|
+
x: moveEvent.clientX,
|
|
314
|
+
y: moveEvent.clientY,
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const finish = (isCommitted: boolean) => {
|
|
319
|
+
globalThis.removeEventListener(
|
|
320
|
+
"pointermove",
|
|
321
|
+
onPointerMove,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
globalThis.removeEventListener("pointerup", onUp)
|
|
325
|
+
|
|
326
|
+
globalThis.removeEventListener(
|
|
327
|
+
"pointercancel",
|
|
328
|
+
onCancel,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
const live = dragRef.current
|
|
332
|
+
|
|
333
|
+
setDragState(null)
|
|
334
|
+
|
|
335
|
+
if (!live) {
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* A pointer sequence that ends on the element it started
|
|
341
|
+
* from still fires a `click`, and the element it started
|
|
342
|
+
* from is a button that opens the move menu. Without this,
|
|
343
|
+
* every completed drag lands the card and then opens a menu
|
|
344
|
+
* on top of it.
|
|
345
|
+
*
|
|
346
|
+
* Swallowed in the **capture** phase and `once`, so it
|
|
347
|
+
* consumes exactly the synthetic click this gesture
|
|
348
|
+
* produced and nothing after it. A `hasDragged` flag on the
|
|
349
|
+
* card would be the same idea with a lifetime nobody owns.
|
|
350
|
+
*/
|
|
351
|
+
globalThis.addEventListener(
|
|
352
|
+
"click",
|
|
353
|
+
(clickEvent) => {
|
|
354
|
+
clickEvent.preventDefault()
|
|
355
|
+
|
|
356
|
+
clickEvent.stopPropagation()
|
|
357
|
+
},
|
|
358
|
+
{ capture: true, once: true },
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
if (isCommitted && live.target) {
|
|
362
|
+
onDrop(origin, live.target)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const onUp = (upEvent: PointerEvent) => {
|
|
367
|
+
if (upEvent.pointerId === pointerEvent.pointerId) {
|
|
368
|
+
finish(true)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const onCancel = () => {
|
|
373
|
+
finish(false)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
globalThis.addEventListener(
|
|
377
|
+
"pointermove",
|
|
378
|
+
onPointerMove,
|
|
379
|
+
{ passive: false },
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
globalThis.addEventListener("pointerup", onUp)
|
|
383
|
+
|
|
384
|
+
globalThis.addEventListener("pointercancel", onCancel)
|
|
385
|
+
},
|
|
386
|
+
[moveTo, onDrop, setDragState, takeSnapshot],
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Escape abandons the drag, and it is registered for the life of
|
|
391
|
+
* the component rather than for the life of a drag. A listener
|
|
392
|
+
* added when a drag starts is a listener that leaks when the
|
|
393
|
+
* component unmounts mid-drag — which is exactly what a route
|
|
394
|
+
* change during a drag does.
|
|
395
|
+
*/
|
|
396
|
+
useEffect(() => {
|
|
397
|
+
const onKeyDown = (keyEvent: KeyboardEvent) => {
|
|
398
|
+
if (keyEvent.key === "Escape" && dragRef.current) {
|
|
399
|
+
keyEvent.preventDefault()
|
|
400
|
+
|
|
401
|
+
setDragState(null)
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
globalThis.addEventListener("keydown", onKeyDown)
|
|
406
|
+
|
|
407
|
+
return () => {
|
|
408
|
+
globalThis.removeEventListener("keydown", onKeyDown)
|
|
409
|
+
}
|
|
410
|
+
}, [setDragState])
|
|
411
|
+
|
|
412
|
+
return { drag, registerLane, startDrag }
|
|
413
|
+
}
|
|
@@ -157,9 +157,18 @@ export const Checkbox = ({
|
|
|
157
157
|
// No `disabled:` colour override; the wrapper's `opacity-60`
|
|
158
158
|
// does the dimming, and doubling it on the box is what made a
|
|
159
159
|
// disabled checkbox vanish.
|
|
160
|
+
// `indeterminate:` wears the same fill as `checked:`. It is
|
|
161
|
+
// the state a *group* box is in when some of its members are
|
|
162
|
+
// ticked, and it cannot be written in markup at all —
|
|
163
|
+
// `indeterminate` is a DOM property with no HTML attribute —
|
|
164
|
+
// so the only way to reach it is a consumer writing to the
|
|
165
|
+
// input. `DataTable`'s select-all box does exactly that, and
|
|
166
|
+
// before this line it painted **nothing**: the property was
|
|
167
|
+
// set, screen readers announced "mixed", and the box on
|
|
168
|
+
// screen was indistinguishable from empty.
|
|
160
169
|
isReadOnly
|
|
161
|
-
? "border-border-default checked:border-intent-neutral-solid checked:bg-intent-neutral-solid"
|
|
162
|
-
: "border-border-strong checked:border-intent-accent-solid checked:bg-intent-accent-solid",
|
|
170
|
+
? "border-border-default checked:border-intent-neutral-solid checked:bg-intent-neutral-solid indeterminate:border-intent-neutral-solid indeterminate:bg-intent-neutral-solid"
|
|
171
|
+
: "border-border-strong checked:border-intent-accent-solid checked:bg-intent-accent-solid indeterminate:border-intent-accent-solid indeterminate:bg-intent-accent-solid",
|
|
163
172
|
isDisabled
|
|
164
173
|
? "cursor-not-allowed"
|
|
165
174
|
: isReadOnly
|
|
@@ -202,7 +211,7 @@ export const Checkbox = ({
|
|
|
202
211
|
<svg
|
|
203
212
|
aria-hidden="true"
|
|
204
213
|
className={toClassName(
|
|
205
|
-
"pointer-events-none invisible absolute peer-checked:visible",
|
|
214
|
+
"pointer-events-none invisible absolute peer-checked:visible peer-indeterminate:invisible",
|
|
206
215
|
// Matches whichever fill the box wears, so the tick keeps its
|
|
207
216
|
// guaranteed on-solid contrast in either intent.
|
|
208
217
|
isReadOnly
|
|
@@ -219,6 +228,28 @@ export const Checkbox = ({
|
|
|
219
228
|
>
|
|
220
229
|
<path d="M20 6 9 17l-5-5" />
|
|
221
230
|
</svg>
|
|
231
|
+
|
|
232
|
+
{/* The mixed mark — a dash, not a tick. Its own element rather
|
|
233
|
+
than a second `<path>` in the tick's `<svg>`, because the
|
|
234
|
+
two states have to be able to hide independently and a
|
|
235
|
+
`<path>` cannot carry a `peer-*` variant of its own. */}
|
|
236
|
+
<svg
|
|
237
|
+
aria-hidden="true"
|
|
238
|
+
className={toClassName(
|
|
239
|
+
"pointer-events-none invisible absolute peer-indeterminate:visible",
|
|
240
|
+
isReadOnly
|
|
241
|
+
? "text-intent-neutral-on-solid"
|
|
242
|
+
: "text-intent-accent-on-solid",
|
|
243
|
+
CHECK_SIZE_CLASS[size],
|
|
244
|
+
)}
|
|
245
|
+
fill="none"
|
|
246
|
+
stroke="currentColor"
|
|
247
|
+
strokeLinecap="round"
|
|
248
|
+
strokeWidth={3}
|
|
249
|
+
viewBox="0 0 24 24"
|
|
250
|
+
>
|
|
251
|
+
<path d="M6 12h12" />
|
|
252
|
+
</svg>
|
|
222
253
|
</span>
|
|
223
254
|
|
|
224
255
|
<span>{label}</span>
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as DataTableStories from "./DataTable.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={DataTableStories} />
|
|
6
|
+
|
|
7
|
+
# DataTable
|
|
8
|
+
|
|
9
|
+
**A table that reflows instead of scrolling, sized by its container.** Columns, rows, an
|
|
10
|
+
optional selection column, and the sort *announcement* — the data itself stays the
|
|
11
|
+
consumer's.
|
|
12
|
+
|
|
13
|
+
<Canvas of={DataTableStories.Default} />
|
|
14
|
+
|
|
15
|
+
## Props
|
|
16
|
+
|
|
17
|
+
<Controls />
|
|
18
|
+
|
|
19
|
+
## It composes `SortableTableHeader`
|
|
20
|
+
|
|
21
|
+
Every sortable column renders [`SortableTableHeader`](?path=/docs/components-sortabletableheader--docs),
|
|
22
|
+
which shipped first and stays shipped. It is in a published `1.0.0` with a consumer of its
|
|
23
|
+
own, so subsuming it would have been a breaking change bought for nothing — and there is
|
|
24
|
+
value in exactly one place in the fleet knowing that `aria-sort` belongs on the `<th>` while
|
|
25
|
+
the click target is a `<button>` inside it.
|
|
26
|
+
|
|
27
|
+
That composition is also why this is a **real `<table>`** rather than a grid of
|
|
28
|
+
`<div role="row">`s: a div grid cannot host a `<th>` at all, so it would have meant either
|
|
29
|
+
duplicating the header component or breaking it.
|
|
30
|
+
|
|
31
|
+
| | `<table>` | `<div role="grid">` |
|
|
32
|
+
| --- | --- | --- |
|
|
33
|
+
| Hosts `SortableTableHeader` | yes | no |
|
|
34
|
+
| Column widths | the browser's auto layout, free | you write the track list |
|
|
35
|
+
| Screen-reader table mode | native | depends on the roles being complete |
|
|
36
|
+
| Keyboard model | ordinary controls in document order | a roving focus you now own |
|
|
37
|
+
|
|
38
|
+
A `grid` is the right answer for a spreadsheet — cell-by-cell arrow navigation, editable
|
|
39
|
+
cells. This is a **table**: rows of data with the odd control in them.
|
|
40
|
+
|
|
41
|
+
## Narrow means reflow, never a sideways scroll
|
|
42
|
+
|
|
43
|
+
<Canvas of={DataTableStories.Responsive} />
|
|
44
|
+
|
|
45
|
+
Below `--cq-md` (32rem) each row becomes a labelled block — the column name beside its
|
|
46
|
+
value — and the header row wraps into a strip of sort controls that stays reachable. Above
|
|
47
|
+
it, an ordinary table.
|
|
48
|
+
|
|
49
|
+
The two alternatives, and why neither:
|
|
50
|
+
|
|
51
|
+
- **Horizontal scroll.** WCAG 2.2 SC 1.4.10 (Reflow) exists because of it, and the consumer
|
|
52
|
+
this was built for bans it outright: *"No horizontal scroll, ever."* A pan surface also
|
|
53
|
+
hides the *existence* of the off-screen columns.
|
|
54
|
+
- **Dropping low-priority columns.** Cheapest to build, and the only option that loses data:
|
|
55
|
+
the assignee column does not come back, and no gesture asks for it.
|
|
56
|
+
|
|
57
|
+
## Container queries, never a media query
|
|
58
|
+
|
|
59
|
+
The width that decides the layout is **this element's**, not the window's. A table in a
|
|
60
|
+
three-up board lane is narrow on a 4K monitor, and a reader browsing at 175% zoom has ~860
|
|
61
|
+
effective pixels in a 1500px window — so a `@media (max-width: …)` here is measuring
|
|
62
|
+
something that is not the question.
|
|
63
|
+
|
|
64
|
+
The `@container` is declared on the wrapper and queried by its descendants, because
|
|
65
|
+
[a container query never matches the element that declares it](?path=/docs/guides-building-an-app--docs).
|
|
66
|
+
`Responsive` is the only honest way to see it: three widths, one window, no viewport change
|
|
67
|
+
at all.
|
|
68
|
+
|
|
69
|
+
## What it does not own
|
|
70
|
+
|
|
71
|
+
<Canvas of={DataTableStories.AllVariants} />
|
|
72
|
+
|
|
73
|
+
**Sorting the data.** `sort` says which column is sorted; `onSortChange` asks for a
|
|
74
|
+
different one; the rows arrive already ordered. Comparing two values is per-column app logic
|
|
75
|
+
— `priority` in the stories sorts high → medium → low, which is not its alphabetical order —
|
|
76
|
+
and a table that sorted itself would need a comparator per column to do it, which is the
|
|
77
|
+
same code in a worse place.
|
|
78
|
+
|
|
79
|
+
**The selected set.** `selection.selectedRowKeys` is authoritative and lives with the
|
|
80
|
+
consumer, because every *use* of a selection — the count in a toolbar, the bulk action, the
|
|
81
|
+
undo — is outside this component. That is the opposite of
|
|
82
|
+
[`Checkbox`](?path=/docs/components-checkbox--docs), and for a stated reason: the platform
|
|
83
|
+
owns one checkbox's checkedness, and nothing at all owns "these five rows".
|
|
84
|
+
|
|
85
|
+
**Column widths.** No drag handle and no measured layout. The browser's own auto table
|
|
86
|
+
layout sizes columns to their content, and `column.className` overrides it — `w-32` on a
|
|
87
|
+
column lands on that column's `<th>` and every one of its `<td>`s. Drag-to-resize is a
|
|
88
|
+
*persistence* question (whose widths, stored where, keyed on what) that a component cannot
|
|
89
|
+
answer for its consumer.
|
|
90
|
+
|
|
91
|
+
**Virtualisation.** Deliberately absent. Every row is in the DOM, which is what keeps the
|
|
92
|
+
accessibility tree honest and the auto column layout possible; the measurements and the
|
|
93
|
+
cliff are in
|
|
94
|
+
[the decision record](https://github.com/Sawtaytoes/charcuterie/blob/master/docs/decisions/2026-08-19-the-data-table-composes-sortabletableheader-reflows-and-does-not-virtualise.md).
|
|
95
|
+
Past a few thousand rows, filter or page — a virtualised table is a different component,
|
|
96
|
+
because it has to fix row heights and column widths to work at all.
|
|
97
|
+
|
|
98
|
+
## Selection is written to the input, not re-mounted
|
|
99
|
+
|
|
100
|
+
<Canvas of={DataTableStories.AllStates} />
|
|
101
|
+
|
|
102
|
+
`Checkbox` is uncontrolled by design — `isChecked` seeds the first paint and the `<input>`
|
|
103
|
+
is the store from then on. Keeping a controlled `selectedRowKeys` authoritative therefore
|
|
104
|
+
means **writing to the store**: an effect sets `input.checked` on every render. The
|
|
105
|
+
alternative, a state-bearing `key` that re-mounts the box, throws the focus away one row
|
|
106
|
+
into a keyboard pass down the column.
|
|
107
|
+
|
|
108
|
+
It is also the only way to express the header box's third state: `indeterminate` is a DOM
|
|
109
|
+
property with **no HTML attribute at all**, so no markup could have set it.
|
|
110
|
+
|
|
111
|
+
The row carries `data-is-selected` for the paint, and **not** `aria-selected`: that property
|
|
112
|
+
belongs to `grid` and `treegrid` rows. In a `table`, the row's own checkbox is what states
|
|
113
|
+
the fact — and it is a control that can also change it.
|
|
114
|
+
|
|
115
|
+
## Keyboard
|
|
116
|
+
|
|
117
|
+
<Canvas of={DataTableStories.Interactive} />
|
|
118
|
+
|
|
119
|
+
There is no custom key handling, and that is the design. `Tab` reaches each sortable
|
|
120
|
+
header's button and each row's checkbox in document order; `Enter`/`Space` sorts; `Space`
|
|
121
|
+
ticks. A table is not a `grid`, so there is no roving focus and no arrow-key cell
|
|
122
|
+
navigation — a screen reader's own table mode moves through the data, and everything
|
|
123
|
+
interactive is a real control the browser already knows how to reach.
|