@charcuterie/ui 3.0.0 → 3.1.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 (113) 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/MarkdownEditor/MarkdownEditor.d.ts +147 -0
  54. package/dist/MarkdownEditor/MarkdownEditor.d.ts.map +1 -0
  55. package/dist/MarkdownEditor/MarkdownEditor.js +492 -0
  56. package/dist/MarkdownEditor/MarkdownEditor.js.map +1 -0
  57. package/dist/MarkdownEditor/markdownCommands.d.ts +202 -0
  58. package/dist/MarkdownEditor/markdownCommands.d.ts.map +1 -0
  59. package/dist/MarkdownEditor/markdownCommands.js +379 -0
  60. package/dist/MarkdownEditor/markdownCommands.js.map +1 -0
  61. package/dist/MarkdownEditor/markdownSpans.d.ts +92 -0
  62. package/dist/MarkdownEditor/markdownSpans.d.ts.map +1 -0
  63. package/dist/MarkdownEditor/markdownSpans.js +303 -0
  64. package/dist/MarkdownEditor/markdownSpans.js.map +1 -0
  65. package/dist/categoricalStyles.d.ts +53 -0
  66. package/dist/categoricalStyles.d.ts.map +1 -0
  67. package/dist/categoricalStyles.js +195 -0
  68. package/dist/categoricalStyles.js.map +1 -0
  69. package/dist/index.d.ts +22 -0
  70. package/dist/index.d.ts.map +1 -1
  71. package/dist/index.js +11 -0
  72. package/dist/index.js.map +1 -1
  73. package/package.json +2 -2
  74. package/src/Badge/Badge.mdx +46 -0
  75. package/src/Badge/Badge.stories.tsx +98 -4
  76. package/src/Badge/Badge.test.tsx +157 -2
  77. package/src/Badge/Badge.tsx +49 -3
  78. package/src/Board/Board.mdx +199 -0
  79. package/src/Board/Board.stories.tsx +707 -0
  80. package/src/Board/Board.test.tsx +310 -0
  81. package/src/Board/Board.tsx +333 -0
  82. package/src/Board/BoardCard.tsx +290 -0
  83. package/src/Board/BoardLaneList.tsx +275 -0
  84. package/src/Board/boardMove.test.ts +213 -0
  85. package/src/Board/boardMove.ts +225 -0
  86. package/src/Board/useBoardDrag.ts +413 -0
  87. package/src/Checkbox/Checkbox.tsx +34 -3
  88. package/src/DataTable/DataTable.mdx +123 -0
  89. package/src/DataTable/DataTable.stories.tsx +411 -0
  90. package/src/DataTable/DataTable.test.tsx +386 -0
  91. package/src/DataTable/DataTable.tsx +497 -0
  92. package/src/DatePicker/DateGrid.tsx +467 -0
  93. package/src/DatePicker/DatePicker.mdx +170 -0
  94. package/src/DatePicker/DatePicker.stories.tsx +268 -0
  95. package/src/DatePicker/DatePicker.test.tsx +455 -0
  96. package/src/DatePicker/DatePicker.tsx +852 -0
  97. package/src/DatePicker/datePresets.ts +34 -0
  98. package/src/DatePicker/parseDateInput.test.ts +187 -0
  99. package/src/DatePicker/parseDateInput.ts +629 -0
  100. package/src/DatePicker/plainDate.test.ts +375 -0
  101. package/src/DatePicker/plainDate.ts +465 -0
  102. package/src/MarkdownEditor/MarkdownEditor.mdx +189 -0
  103. package/src/MarkdownEditor/MarkdownEditor.stories.tsx +267 -0
  104. package/src/MarkdownEditor/MarkdownEditor.test.tsx +627 -0
  105. package/src/MarkdownEditor/MarkdownEditor.tsx +862 -0
  106. package/src/MarkdownEditor/markdownCommands.test.ts +384 -0
  107. package/src/MarkdownEditor/markdownCommands.ts +566 -0
  108. package/src/MarkdownEditor/markdownSpans.test.ts +217 -0
  109. package/src/MarkdownEditor/markdownSpans.ts +458 -0
  110. package/src/argTypes.storyHelpers.ts +39 -0
  111. package/src/categoricalStyles.ts +244 -0
  112. package/src/index.ts +89 -0
  113. 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.