@charcuterie/ui 3.32.0 → 3.34.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 (76) hide show
  1. package/README.md +30 -0
  2. package/dist/AdaptiveGrid/useAdaptiveColumns.d.ts +16 -0
  3. package/dist/AdaptiveGrid/useAdaptiveColumns.d.ts.map +1 -1
  4. package/dist/AdaptiveGrid/useAdaptiveColumns.js +1 -0
  5. package/dist/AdaptiveGrid/useAdaptiveColumns.js.map +1 -1
  6. package/dist/Board/Board.d.ts.map +1 -1
  7. package/dist/Board/Board.js +2 -2
  8. package/dist/Board/Board.js.map +1 -1
  9. package/dist/RangeSlider/RangeSlider.d.ts +117 -0
  10. package/dist/RangeSlider/RangeSlider.d.ts.map +1 -0
  11. package/dist/RangeSlider/RangeSlider.js +242 -0
  12. package/dist/RangeSlider/RangeSlider.js.map +1 -0
  13. package/dist/RangeSlider/rangeSliderValue.d.ts +102 -0
  14. package/dist/RangeSlider/rangeSliderValue.d.ts.map +1 -0
  15. package/dist/RangeSlider/rangeSliderValue.js +116 -0
  16. package/dist/RangeSlider/rangeSliderValue.js.map +1 -0
  17. package/dist/SegmentedControl/SegmentedControl.d.ts +12 -1
  18. package/dist/SegmentedControl/SegmentedControl.d.ts.map +1 -1
  19. package/dist/SegmentedControl/SegmentedControl.js +3 -3
  20. package/dist/SegmentedControl/SegmentedControl.js.map +1 -1
  21. package/dist/SegmentedControl/SegmentedOption.d.ts +2 -1
  22. package/dist/SegmentedControl/SegmentedOption.d.ts.map +1 -1
  23. package/dist/SegmentedControl/SegmentedOption.js +7 -2
  24. package/dist/SegmentedControl/SegmentedOption.js.map +1 -1
  25. package/dist/Slider/Slider.d.ts +6 -1
  26. package/dist/Slider/Slider.d.ts.map +1 -1
  27. package/dist/Slider/Slider.js +8 -22
  28. package/dist/Slider/Slider.js.map +1 -1
  29. package/dist/TimecodeInput/TimecodeInput.d.ts +88 -0
  30. package/dist/TimecodeInput/TimecodeInput.d.ts.map +1 -0
  31. package/dist/TimecodeInput/TimecodeInput.js +258 -0
  32. package/dist/TimecodeInput/TimecodeInput.js.map +1 -0
  33. package/dist/TimecodeInput/timecode.d.ts +132 -0
  34. package/dist/TimecodeInput/timecode.d.ts.map +1 -0
  35. package/dist/TimecodeInput/timecode.js +246 -0
  36. package/dist/TimecodeInput/timecode.js.map +1 -0
  37. package/dist/VirtualizedGrid/VirtualizedGrid.d.ts +35 -0
  38. package/dist/VirtualizedGrid/VirtualizedGrid.d.ts.map +1 -1
  39. package/dist/VirtualizedGrid/VirtualizedGrid.js +37 -1
  40. package/dist/VirtualizedGrid/VirtualizedGrid.js.map +1 -1
  41. package/dist/index.d.ts +7 -0
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +3 -0
  44. package/dist/index.js.map +1 -1
  45. package/dist/sliderStyles.d.ts +39 -0
  46. package/dist/sliderStyles.d.ts.map +1 -0
  47. package/dist/sliderStyles.js +50 -0
  48. package/dist/sliderStyles.js.map +1 -0
  49. package/package.json +1 -1
  50. package/src/AdaptiveGrid/useAdaptiveColumns.ts +17 -0
  51. package/src/Board/Board.test.tsx +8 -0
  52. package/src/Board/Board.tsx +7 -1
  53. package/src/RangeSlider/RangeSlider.mdx +85 -0
  54. package/src/RangeSlider/RangeSlider.stories.tsx +346 -0
  55. package/src/RangeSlider/RangeSlider.test.tsx +452 -0
  56. package/src/RangeSlider/RangeSlider.tsx +595 -0
  57. package/src/RangeSlider/rangeSliderValue.test.ts +217 -0
  58. package/src/RangeSlider/rangeSliderValue.ts +185 -0
  59. package/src/SegmentedControl/SegmentedControl.stories.tsx +50 -0
  60. package/src/SegmentedControl/SegmentedControl.test.tsx +36 -1
  61. package/src/SegmentedControl/SegmentedControl.tsx +14 -0
  62. package/src/SegmentedControl/SegmentedOption.tsx +7 -0
  63. package/src/Slider/Slider.tsx +23 -29
  64. package/src/TimecodeInput/TimecodeInput.mdx +155 -0
  65. package/src/TimecodeInput/TimecodeInput.stories.tsx +304 -0
  66. package/src/TimecodeInput/TimecodeInput.test.tsx +279 -0
  67. package/src/TimecodeInput/TimecodeInput.tsx +493 -0
  68. package/src/TimecodeInput/timecode.test.ts +236 -0
  69. package/src/TimecodeInput/timecode.ts +391 -0
  70. package/src/VirtualizedGrid/VirtualizedGrid.mdx +21 -0
  71. package/src/VirtualizedGrid/VirtualizedGrid.stories.tsx +64 -0
  72. package/src/VirtualizedGrid/VirtualizedGrid.test.tsx +122 -3
  73. package/src/VirtualizedGrid/VirtualizedGrid.tsx +37 -1
  74. package/src/index.ts +18 -0
  75. package/src/sliderStyles.ts +69 -0
  76. package/src/sourceRules.test.ts +25 -1
@@ -10,6 +10,7 @@ import { Header } from "../Header/Header.tsx"
10
10
  import { Main } from "../Main/Main.tsx"
11
11
  import { SegmentedControl } from "../SegmentedControl/SegmentedControl.tsx"
12
12
  import { Shell } from "../Shell/Shell.tsx"
13
+ import { Tabs } from "../Tabs/Tabs.tsx"
13
14
  import { VirtualizedGrid } from "./VirtualizedGrid.tsx"
14
15
 
15
16
  /**
@@ -130,6 +131,69 @@ export const InShellScrollRegion: Story = {
130
131
  ),
131
132
  }
132
133
 
134
+ /**
135
+ * Two grids in a tab bar, one of them hidden — the shape that found
136
+ * the scroll bug.
137
+ *
138
+ * A `hidden` panel is `display: none`, so the grid inside it has no
139
+ * box: its container measures 0px wide (one column) and every
140
+ * mounted row measures 0px tall. Revealing the panel corrects all
141
+ * of that at once, and a virtualizer is entitled to read a
142
+ * correction as a resize and compensate the scroll position for it.
143
+ *
144
+ * Docket's Triage bar is where it showed: clicking a tab scrolled
145
+ * `Main` down 266px and put the tab bar off the top of the screen.
146
+ * So the claim this story exists to hold is one number — **switching
147
+ * tabs leaves `Main.scrollTop` where it was.**
148
+ *
149
+ * The chrome above the grid is not decoration either. The false
150
+ * adjustment is roughly the distance from the top of the scroll
151
+ * region to the top of the grid, so a story with the grid flush
152
+ * against the top of `Main` would have had nothing to measure and
153
+ * would have passed while the bug was still there.
154
+ */
155
+ export const InHiddenTabPanel: Story = {
156
+ parameters: { layout: "fullscreen" },
157
+ render: (gridProps) => (
158
+ <Shell contentWidth="full">
159
+ <Header heading="Bays" />
160
+
161
+ <Main>
162
+ <p className="text-content-secondary text-sm">
163
+ Switching tabs must not move the page.
164
+ </p>
165
+
166
+ <Tabs
167
+ label="Bay views"
168
+ tabs={[
169
+ {
170
+ content: (
171
+ <VirtualizedGrid
172
+ {...gridProps}
173
+ label="Loaded bays"
174
+ />
175
+ ),
176
+ key: "loaded",
177
+ label: "Loaded",
178
+ },
179
+ {
180
+ content: (
181
+ <VirtualizedGrid
182
+ {...gridProps}
183
+ items={MANY_ITEMS.slice(0, 48)}
184
+ label="Reserved bays"
185
+ />
186
+ ),
187
+ key: "reserved",
188
+ label: "Reserved",
189
+ },
190
+ ]}
191
+ />
192
+ </Main>
193
+ </Shell>
194
+ ),
195
+ }
196
+
133
197
  /**
134
198
  * The same 2,000 items at three window heights.
135
199
  *
@@ -1,5 +1,5 @@
1
1
  import { composeStories } from "@storybook/react"
2
- import { expect, waitFor } from "storybook/test"
2
+ import { expect, userEvent, waitFor } from "storybook/test"
3
3
  import { test } from "vitest"
4
4
 
5
5
  import { mountStory } from "../mountStory.testHelpers.ts"
@@ -31,10 +31,17 @@ import * as stories from "./VirtualizedGrid.stories.tsx"
31
31
  * and "list, 40 items" on a list of 2,000.
32
32
  * - **An empty list reserves no space.** The state a filter lands
33
33
  * on.
34
+ * - **A hidden panel measures nothing, and revealing it does not
35
+ * scroll.** The regression that took Docket's Triage tab bar off
36
+ * the top of the screen.
34
37
  */
35
38
 
36
- const { AllStates, Default, InShellScrollRegion } =
37
- composeStories(stories)
39
+ const {
40
+ AllStates,
41
+ Default,
42
+ InHiddenTabPanel,
43
+ InShellScrollRegion,
44
+ } = composeStories(stories)
38
45
 
39
46
  const getLists = (canvasElement: HTMLElement) => [
40
47
  ...canvasElement.querySelectorAll<HTMLElement>("ul"),
@@ -205,6 +212,118 @@ test("a grid inside Shell follows Main's one scroll region", async () => {
205
212
  ).toBe("0px")
206
213
  })
207
214
 
215
+ test("a grid inside a hidden panel mounts nothing and reveals without scrolling", async () => {
216
+ const { canvasElement } = await mountStory(
217
+ InHiddenTabPanel,
218
+ )
219
+
220
+ const main =
221
+ canvasElement.querySelector<HTMLElement>("main")
222
+
223
+ if (!main) {
224
+ throw new Error("The tab story has no Main element.")
225
+ }
226
+
227
+ await waitFor(async () => {
228
+ await expect(
229
+ getRowIndexes(canvasElement).length,
230
+ ).toBeGreaterThan(0)
231
+ })
232
+
233
+ const hiddenPanel = canvasElement.querySelector(
234
+ '[role="tabpanel"][hidden]',
235
+ )
236
+
237
+ if (!hiddenPanel) {
238
+ throw new Error("Both tab panels are showing.")
239
+ }
240
+
241
+ /*
242
+ * The direct observable, and the one the fix is: `display: none`
243
+ * gives an element no box, so nothing inside it can be measured.
244
+ * A grid that mounted its rows anyway would record a 0px-wide
245
+ * container and 0px-tall rows as facts.
246
+ */
247
+ /*
248
+ * THE ASSERTION THAT DISCRIMINATES, and it is the cause rather
249
+ * than the symptom on purpose.
250
+ *
251
+ * `display: none` gives an element no box, so nothing inside it
252
+ * can be measured. A grid that mounted its rows anyway records a
253
+ * 0px-wide container and 0px-tall rows as facts, and the
254
+ * virtualizer then reads the correction as a resize and
255
+ * compensates the scroll position for it.
256
+ *
257
+ * Whether that compensation actually lands is a RACE — between
258
+ * the `ResizeObserver` delivering the real row heights and the
259
+ * layout effect correcting `scrollMargin`. In Docket it landed
260
+ * and moved `Main` by 266px, 190px and 1,190px on three
261
+ * different switches; in this story it does not land at all. So
262
+ * the symptom is not what is asserted here. This is: with
263
+ * nothing mounted, the race has no entrants.
264
+ */
265
+ await expect(
266
+ hiddenPanel.querySelectorAll("ul").length,
267
+ ).toBe(0)
268
+
269
+ const tabs = [
270
+ ...canvasElement.querySelectorAll<HTMLElement>(
271
+ '[role="tab"]',
272
+ ),
273
+ ]
274
+
275
+ const nextTab = tabs.find(
276
+ (tab) => tab.getAttribute("aria-selected") === "false",
277
+ )
278
+
279
+ if (!nextTab) {
280
+ throw new Error("No unselected tab to switch to.")
281
+ }
282
+
283
+ await expect(main.scrollTop).toBe(0)
284
+
285
+ await userEvent.click(nextTab)
286
+
287
+ /**
288
+ * The list inside the panel that is SHOWING. Scoping matters:
289
+ * without the gate the hidden panel still holds a `<ul>`, and a
290
+ * query that took the first one in the canvas would watch the
291
+ * panel that was just put away and settle before the revealed
292
+ * one had measured anything.
293
+ */
294
+ const getShownList = () => {
295
+ const shown = canvasElement.querySelector(
296
+ '[role="tabpanel"]:not([hidden])',
297
+ )
298
+
299
+ const list = shown?.querySelector<HTMLElement>("ul")
300
+
301
+ if (!list) {
302
+ throw new Error("The shown panel has no grid.")
303
+ }
304
+
305
+ return list
306
+ }
307
+
308
+ // Wait for the revealed grid to have MEASURED, not merely to
309
+ // have mounted — the adjustment, when it happens, happens on the
310
+ // virtualizer's resize path.
311
+ await waitFor(async () => {
312
+ await expect(
313
+ Number(
314
+ getShownList().style.paddingBlockEnd.replace(
315
+ "px",
316
+ "",
317
+ ),
318
+ ),
319
+ ).toBeGreaterThan(0)
320
+ })
321
+
322
+ // The symptom, kept as a guard rather than as the proof. It is
323
+ // free, and it is the sentence a reader of this file wants.
324
+ await expect(main.scrollTop).toBe(0)
325
+ })
326
+
208
327
  test("a screen reader is told the real length", async () => {
209
328
  const { canvasElement } = await mountStory(Default)
210
329
 
@@ -157,6 +157,41 @@ export type VirtualizedGridProps<Item> = {
157
157
  * answer: the browser's `Ctrl+F` only searches what is mounted, and
158
158
  * so does "select all". A page that needs either owes its users a
159
159
  * search field of its own.
160
+ *
161
+ * ### Nothing is mounted until there is a box to measure
162
+ *
163
+ * `display: none` gives an element no boxes at all, so every
164
+ * measurement taken through one reads zero — and zero is the
165
+ * absence of a measurement, not a measurement of nothing. A grid
166
+ * inside a `hidden` tab panel, a collapsed disclosure or a closed
167
+ * `<details>` therefore measures a 0px container (one column) and
168
+ * 0px-tall rows, and the virtualizer stores those as facts.
169
+ *
170
+ * Revealing the panel corrects all of them at once, and the
171
+ * virtualizer reads a correction as a **resize**. That is the one
172
+ * event it is entitled to move the scroll position for: an item
173
+ * above the viewport that grows must be compensated for, or the
174
+ * page slides under the reader. So it compensates — for a growth
175
+ * that never happened — and scrolls the shared scroll owner down
176
+ * by roughly the distance from the top of the region to the top of
177
+ * the grid.
178
+ *
179
+ * Whether the compensation lands is a race between the
180
+ * `ResizeObserver` delivering the real row heights and the layout
181
+ * effect correcting `scrollMargin`, so the symptom is intermittent
182
+ * and its size varies. Docket's Triage bar is where it was found:
183
+ * one switch moved `Main` 266px, another 190px, another 1,190px,
184
+ * and the tab bar went off the top of the screen. The cause is not
185
+ * the tab bar, and fixing it there would have left every other
186
+ * host of a hidden grid broken.
187
+ *
188
+ * `isLaidOut` is the gate. While the container has no box the
189
+ * windowed grid is not mounted, so there is nothing to measure
190
+ * wrongly and no virtualizer to mislead; it mounts when the box
191
+ * arrives and measures once, correctly. The cost is that a
192
+ * revealed grid re-measures from its estimate instead of resuming
193
+ * a cache — the same thing a routed tab does, and the reason a
194
+ * routed tab never had this bug.
160
195
  */
161
196
  export const VirtualizedGrid = <Item,>({
162
197
  blockSizeResolver,
@@ -227,7 +262,8 @@ export const VirtualizedGrid = <Item,>({
227
262
  )}
228
263
  ref={layout.containerRef}
229
264
  >
230
- {scrollOwner?.kind === "element" ? (
265
+ {!layout.isLaidOut ? null : scrollOwner?.kind ===
266
+ "element" ? (
231
267
  <ElementGrid
232
268
  {...gridProps}
233
269
  scrollOwner={scrollOwner}
package/src/index.ts CHANGED
@@ -349,6 +349,12 @@ export type {
349
349
  RailSide,
350
350
  } from "./Rail/Rail.tsx"
351
351
  export { Rail } from "./Rail/Rail.tsx"
352
+ export type {
353
+ RangeSliderProps,
354
+ RangeSliderTick,
355
+ } from "./RangeSlider/RangeSlider.tsx"
356
+ export { RangeSlider } from "./RangeSlider/RangeSlider.tsx"
357
+ export type { RangeSliderValue } from "./RangeSlider/rangeSliderValue.ts"
352
358
  export type {
353
359
  ReorderListItem,
354
360
  ReorderListProps,
@@ -448,6 +454,18 @@ export type {
448
454
  TextLinkProps,
449
455
  } from "./TextLink/TextLink.tsx"
450
456
  export { TextLink } from "./TextLink/TextLink.tsx"
457
+ export type { TimecodeInputProps } from "./TimecodeInput/TimecodeInput.tsx"
458
+ export { TimecodeInput } from "./TimecodeInput/TimecodeInput.tsx"
459
+ export type {
460
+ FormatTimecodeOptions,
461
+ TimecodeInputResult,
462
+ TimecodeRange,
463
+ } from "./TimecodeInput/timecode.ts"
464
+ export {
465
+ clampTimecode,
466
+ formatTimecode,
467
+ parseTimecodeInput,
468
+ } from "./TimecodeInput/timecode.ts"
451
469
  export type { ToastProps } from "./Toast/Toast.tsx"
452
470
  export { Toast } from "./Toast/Toast.tsx"
453
471
  export type {
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The paint `Slider` and `RangeSlider` share.
3
+ *
4
+ * The two are separate components because their **focus models** are
5
+ * different — one `role="slider"` on the track against two of them in
6
+ * a `role="group"`, one tab stop against two
7
+ * ([decision](../../../docs/decisions/2026-09-01-a-two-thumb-range-is-its-own-component-and-shares-the-bar.md)).
8
+ * What is identical is the bar: a sunken rounded track, an intent
9
+ * fill inside it, and a round thumb pulled back by half its own
10
+ * width.
11
+ *
12
+ * Held here rather than copied for the reason `tileStyles.ts` exists:
13
+ * "the two look the same" spelled as two class strings is a promise
14
+ * that survives exactly one edit. `RangeSlider.test.tsx` compares the
15
+ * two thumbs as **computed** styles, so a class-name refactor cannot
16
+ * quietly break it either.
17
+ *
18
+ * No focus ring here. It is the one part of the bar the two do not
19
+ * share — `Slider` rings the track, because the track is the widget;
20
+ * `RangeSlider` rings each thumb, because each thumb is.
21
+ */
22
+
23
+ export type SliderSize = "lg" | "md" | "sm"
24
+
25
+ /** The bar itself. A `sm` bar is 2px; that is not a hit target. */
26
+ export const SLIDER_TRACK_SIZE_CLASS: Record<
27
+ SliderSize,
28
+ string
29
+ > = {
30
+ sm: "h-1",
31
+ md: "h-2",
32
+ lg: "h-3",
33
+ }
34
+
35
+ export const SLIDER_THUMB_SIZE_CLASS: Record<
36
+ SliderSize,
37
+ string
38
+ > = {
39
+ sm: "size-3",
40
+ md: "size-4",
41
+ lg: "size-5",
42
+ }
43
+
44
+ /** Half of each `SLIDER_THUMB_SIZE_CLASS`, as the inline-start pull-back. */
45
+ export const SLIDER_THUMB_OFFSET: Record<
46
+ SliderSize,
47
+ string
48
+ > = {
49
+ sm: "-0.375rem",
50
+ md: "-0.5rem",
51
+ lg: "-0.625rem",
52
+ }
53
+
54
+ /**
55
+ * The row the bar sits in, which is taller than the bar: a 2px track
56
+ * is not a pointer target, and a finger is 44px. The height comes
57
+ * from the shared control-size system, so `[data-density]` decides
58
+ * it — never `MIN_TOUCH_TARGET_CLASS`, which would make this control
59
+ * taller than the ones beside it at desktop density
60
+ * ([decision](../../../docs/decisions/2026-08-05-controls-share-one-height-no-per-component-touch-floor.md)).
61
+ */
62
+ export const SLIDER_ROW_CLASS =
63
+ "relative flex w-full touch-none cursor-pointer items-center rounded-full min-h-(--control-height-sm)"
64
+
65
+ export const SLIDER_TRACK_CLASS =
66
+ "w-full overflow-hidden rounded-full bg-surface-sunken"
67
+
68
+ export const SLIDER_THUMB_CLASS =
69
+ "absolute rounded-full border-2 border-surface-raised shadow-sm"
@@ -545,7 +545,31 @@ test("the barrel is the only place components are re-exported", async () => {
545
545
  // what it deliberately does not cover — a tile that navigates is
546
546
  // not a radio, and giving one `aria-checked` would be worse than
547
547
  // the paint it replaced.
548
- expect(componentNames.length).toBe(62)
548
+ //
549
+ // `TimecodeInput` — a typed media position, with an `isRange` mode
550
+ // for a section of a file — is +1 -> 63. `timecode.ts` is its
551
+ // member and stays out of this count by the `<Name>/<Name>.tsx`
552
+ // rule, while still being exported from the barrel: the fleet
553
+ // spells the *printing* half five times over (queuepilot twice,
554
+ // castkit, mux-magic twice) and a read-only surface needs the
555
+ // formatter without the field.
556
+ //
557
+ // `RangeSlider` is +1 -> 64. `Slider`'s own record said a two-thumb
558
+ // range is *"a different widget with its own focus model"*, and the
559
+ // focus model is exactly what makes it a component rather than an
560
+ // `isRange` mode: one `role="slider"` on the track becomes two of
561
+ // them on the thumbs inside a `role="group"`, and one tab stop
562
+ // becomes two. So this shares the ARITHMETIC (`sliderValue.ts`,
563
+ // called rather than copied) and the BAR (`sliderStyles.ts`, the
564
+ // same extraction `tileStyles.ts` is), and nothing else.
565
+ // `rangeSliderValue.ts` is its member and stays out of this count
566
+ // by the `<Name>/<Name>.tsx` rule. It lands beside `TimecodeInput`
567
+ // rather than instead of it: the two halves of QueuePilot's clip
568
+ // editor are a typed field and a dragged bar over one value, and
569
+ // `isRange` is right there and wrong here for a reason that is
570
+ // written down — a second text field is still a text field, while a
571
+ // second thumb cannot share one `aria-valuenow`.
572
+ expect(componentNames.length).toBe(64)
549
573
 
550
574
  for (const name of componentNames) {
551
575
  expect(barrel).toContain(`export { ${name} }`)