@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
@@ -0,0 +1,155 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as TimecodeInputStories from "./TimecodeInput.stories";
4
+
5
+ <Meta of={TimecodeInputStories} />
6
+
7
+ # TimecodeInput
8
+
9
+ A text field for a position in a piece of media — `hh:mm:ss.mmm` — with an `isRange` mode
10
+ for a section of a file. The consumer that shaped it is **QueuePilot**, which plays only a
11
+ chosen part of a video
12
+ ([decision](../../../../docs/decisions/2026-09-01-a-timecode-is-milliseconds-and-a-section-is-a-mode.md)).
13
+
14
+ <Canvas of={TimecodeInputStories.Default} />
15
+
16
+ ## Props
17
+
18
+ <Controls />
19
+
20
+ ## The value is milliseconds, and the props say so
21
+
22
+ `onChange` reports an integer count of milliseconds from the start of the file. Not a
23
+ `Date`, not a clock time, not a string. A media position is a **duration**, the only
24
+ arithmetic anybody does with it is subtraction, and a number is the type that supports it.
25
+
26
+ Every prop carrying one keeps the `Ms` suffix — `durationMs`, `maxValueMs`, `minValueMs`,
27
+ `stepMs`, `valueMs` — because a duration's unit is genuinely ambiguous and this fleet has
28
+ both seconds and milliseconds in use. A `*At` timestamp is the opposite case and takes no
29
+ suffix.
30
+
31
+ ```tsx
32
+ <TimecodeInput
33
+ durationMs={file.durationMs} // the default maximum
34
+ label="Start at"
35
+ onChange={(value) => { setStartMs(value) }} // number | null
36
+ valueMs={startMs} // seeds; Charcuterie owns it from then on
37
+ />
38
+ ```
39
+
40
+ ## Why this is not `<input type="time">`
41
+
42
+ Two reasons, and neither is taste.
43
+
44
+ A `time` input's value is a wall-clock `HH:MM` string capped at 24 hours, its smallest step
45
+ is a second unless `step` is fractional, and it has no way to say "1.5 hours into a file" as
46
+ a duration rather than as half past one. A clip boundary is milliseconds; the platform
47
+ control cannot carry them.
48
+
49
+ And it is painted by the operating system, out of `::-webkit-datetime-edit-*`
50
+ pseudo-elements no token reaches inside — the same mechanism that deprecated the native
51
+ `Select` and that kept `Slider` off `<input type="range">`.
52
+
53
+ ## Typing is the whole interface, so nothing guesses in silence
54
+
55
+ | Typed | Resolves to |
56
+ | --- | --- |
57
+ | `90` | `00:01:30.000` — a bare number is **seconds** |
58
+ | `1:30` | `00:01:30.000` |
59
+ | `90:00` | `01:30:00.000` — the largest field present is unbounded |
60
+ | `1:02:03` | `01:02:03.000` |
61
+ | `1:02:03.500`, `1:02:03,500` | `01:02:03.500` |
62
+ | `+90` | `00:01:30.000` — a leading `+` is tolerated |
63
+
64
+ Three mechanisms keep a grammar that permissive from being dangerous:
65
+
66
+ 1. **The grammar is ordered and total.** Each rule is anchored on the *whole* input, the
67
+ first match wins, and no match is a stated failure. `1:` is not one minute.
68
+ 2. **An overflow is a failure with a name, not a carry.** `1:90` comes back as *A minute
69
+ has 60 seconds, so "90" is not a seconds field. Type 02:30 to carry it, or 90 to mean 90
70
+ seconds.* Both legal spellings are offered and neither is taken. Carrying it silently
71
+ moves the position by a whole minute, which is the `1:60` trap mux-magic wrote down in
72
+ 2023.
73
+ 3. **Nothing commits without an echo.** The resolved position is rendered in full underneath
74
+ the field, in a `role="status"` live region wired into the input's `aria-describedby`.
75
+ Commit is Enter or blur; a keystroke never commits.
76
+
77
+ **The largest field present is unbounded; every smaller one is 0–59.** That one sentence is
78
+ every bound in the grammar. It is also why a bare number is seconds: seconds is the smallest
79
+ unit the field prints, so the reading that needs no explanation is the one where the typist
80
+ appended nothing.
81
+
82
+ Refused by name: a negative, a seconds or minutes field of 60 or more with a larger field
83
+ beside it, more than three colon-separated parts, a fraction longer than three digits, and
84
+ anything the accepting rules do not match. A refused value leaves the text **exactly as
85
+ typed**, marks the field invalid and says why.
86
+
87
+ <Canvas of={TimecodeInputStories.AllVariants} />
88
+
89
+ ## A section is `isRange`, and both of its ends are optional
90
+
91
+ `{ end, start }` has four real states, not three plus an error:
92
+
93
+ | `start` | `end` | Means |
94
+ | --- | --- | --- |
95
+ | `null` | `null` | no window at all |
96
+ | set | `null` | from here to the end of the media |
97
+ | `null` | set | from the beginning, stopping here |
98
+ | set | set | the window between them |
99
+
100
+ So there is no `isEndOpen` prop — `null` already says it — and clearing one end never
101
+ touches the other. An open start is the **absence of a choice**, not zero, which is why a
102
+ half-open section is clamped but never compared.
103
+
104
+ Picking an end before the start **swaps**, following `DatePicker`: throwing the pair away
105
+ would discard the boundary just typed. An end **equal** to the start is **refused** by name,
106
+ which is where this parts company with a date range: a zero-length section plays nothing, so
107
+ swapping it would hide a typo behind a window that silently does not exist.
108
+
109
+ There is no `error` prop. `Field` and `FieldGroup` own the semantic error; this component
110
+ owns only the echo.
111
+
112
+ <Canvas of={TimecodeInputStories.AllStates} />
113
+
114
+ ## Keyboard
115
+
116
+ | Key | Does |
117
+ | --- | --- |
118
+ | Enter | commits the typed text |
119
+ | Tab / blur | commits the typed text |
120
+ | Escape | puts the last committed value back |
121
+ | ArrowUp / ArrowDown | steps by `stepMs` (default 1000) and **commits** |
122
+ | Shift + ArrowUp / ArrowDown | steps by ten times `stepMs` |
123
+
124
+ A step commits, and that is not an exception to "a keystroke never commits" — it is the
125
+ other side of it. Typing is a draft the echo reports on; a step is a whole gesture on a
126
+ value that already exists, the same act as clicking a day in `DatePicker`'s calendar. A
127
+ stepper that needed Enter afterwards is a stepper nobody presses twice.
128
+
129
+ The control keeps the plain `textbox` role. It is **not** a `role="spinbutton"`: that role
130
+ promises an `aria-valuenow`, and this field has no value at all while it is empty or while
131
+ its text is refused — which are exactly the states the echo exists for. A spinbutton with no
132
+ value is a widget that under-reports itself to a screen reader in its two most important
133
+ states.
134
+
135
+ <Canvas of={TimecodeInputStories.Responsive} />
136
+
137
+ ## The formatter is exported, because the fleet writes it repeatedly
138
+
139
+ `formatTimecode`, `parseTimecodeInput` and `clampTimecode` come out of the barrel. The
140
+ printing half is written five times across the fleet — queuepilot's `clock` and `toClock`,
141
+ castkit's `formatTime`, and mux-magic's two modal formatters — each with its own answer to
142
+ whether the hour is shown and whether the minute is padded. A read-only surface should call
143
+ the exported one rather than spell a sixth.
144
+
145
+ ```ts
146
+ formatTimecode(3_723_500) // "01:02:03.500"
147
+ formatTimecode(90_000, { isHoursShown: false }) // "01:30.000"
148
+ formatTimecode(1_999, { millisecondDigits: 0 }) // "00:00:01" — truncates
149
+ ```
150
+
151
+ The default is the canonical `hh:mm:ss.mmm`, and it is what the field writes back on commit:
152
+ one spelling means a re-read of the field parses to the number that produced it, and a
153
+ screenshot of the same value is the same picture.
154
+
155
+ <Canvas of={TimecodeInputStories.Interactive} />
@@ -0,0 +1,304 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+ import type { ReactNode } from "react"
3
+ import { useState } from "react"
4
+
5
+ import { controlSizeArgType } from "../argTypes.storyHelpers.ts"
6
+ import {
7
+ ContainerBoard,
8
+ StoryCell,
9
+ StoryGrid,
10
+ StorySection,
11
+ } from "../board.storyHelpers.tsx"
12
+ import { Field } from "../Field/Field.tsx"
13
+ import { TimecodeInput } from "./TimecodeInput.tsx"
14
+ import type { TimecodeRange } from "./timecode.ts"
15
+ import { formatTimecode } from "./timecode.ts"
16
+
17
+ /**
18
+ * A made-up 45 minute 12 second file, so every demo reads the same
19
+ * numbers and a screenshot of one is the same picture next week.
20
+ */
21
+ const DURATION_MS = 2_712_000
22
+
23
+ const describeValue = (
24
+ value: null | number | TimecodeRange,
25
+ ) => {
26
+ if (value === null) {
27
+ return "—"
28
+ }
29
+
30
+ if (typeof value === "number") {
31
+ return formatTimecode(value)
32
+ }
33
+
34
+ // A section with neither end set is the same absence `onChange`
35
+ // reports as `null`, so the demo reads it the same way.
36
+ if (value.end === null && value.start === null) {
37
+ return "—"
38
+ }
39
+
40
+ return `${value.start === null ? "the beginning" : formatTimecode(value.start)} to ${value.end === null ? "the end" : formatTimecode(value.end)}`
41
+ }
42
+
43
+ const meta = {
44
+ title: "Components/Controls/TimecodeInput",
45
+ component: TimecodeInput,
46
+ args: {
47
+ durationMs: DURATION_MS,
48
+ isDisabled: false,
49
+ isRange: false,
50
+ label: "Start at",
51
+ size: "md",
52
+ stepMs: 1_000,
53
+ },
54
+ argTypes: {
55
+ size: controlSizeArgType,
56
+ },
57
+ parameters: { layout: "padded" },
58
+ } satisfies Meta<typeof TimecodeInput>
59
+
60
+ export default meta
61
+
62
+ type Story = StoryObj<typeof meta>
63
+
64
+ const TimecodeHarness = ({
65
+ ...inputProps
66
+ }: {
67
+ durationMs?: number
68
+ isDisabled?: boolean
69
+ isRange?: boolean
70
+ label: string
71
+ maxValueMs?: number
72
+ minValueMs?: number
73
+ stepMs?: number
74
+ valueMs?: number | TimecodeRange
75
+ }): ReactNode => {
76
+ const [chosen, setChosen] = useState<
77
+ null | number | TimecodeRange
78
+ >(inputProps.valueMs ?? null)
79
+
80
+ return (
81
+ <div className="flex flex-col gap-1">
82
+ <TimecodeInput
83
+ {...inputProps}
84
+ durationMs={inputProps.durationMs ?? DURATION_MS}
85
+ onChange={setChosen}
86
+ />
87
+
88
+ <p className="text-content-muted text-xs">
89
+ Chosen: {describeValue(chosen)}
90
+ </p>
91
+ </div>
92
+ )
93
+ }
94
+
95
+ /**
96
+ * The field, empty. Type a position and the line underneath resolves
97
+ * it before anything commits — `90` is ninety seconds, `1:30` is the
98
+ * same position spelled the other way, `1:02:03.500` is the full
99
+ * form. Enter or blur commits; Escape puts the last value back.
100
+ */
101
+ export const Default: Story = {
102
+ args: { label: "Start at" },
103
+ render: () => <TimecodeHarness label="Start at" />,
104
+ }
105
+
106
+ /**
107
+ * Single and section, a bounded window, a coarse step, and the field
108
+ * inside a `Field`.
109
+ *
110
+ * `isRange` is a **mode**, not a sibling component — the same call
111
+ * `DatePicker` made, and for the same reason: a section has no ARIA
112
+ * role of its own to be named after.
113
+ */
114
+ export const AllVariants: Story = {
115
+ args: { label: "Start at" },
116
+ render: () => (
117
+ <StoryGrid columns={2}>
118
+ <StoryCell align="stretch" label="single (default)">
119
+ <TimecodeHarness label="Start at" />
120
+ </StoryCell>
121
+
122
+ <StoryCell align="stretch" label="isRange">
123
+ <TimecodeHarness isRange label="Play section" />
124
+ </StoryCell>
125
+
126
+ <StoryCell
127
+ align="stretch"
128
+ label="minValueMs / maxValueMs"
129
+ >
130
+ <TimecodeHarness
131
+ label="Skip the recap until"
132
+ maxValueMs={180_000}
133
+ minValueMs={30_000}
134
+ />
135
+ </StoryCell>
136
+
137
+ <StoryCell align="stretch" label="stepMs={60000}">
138
+ <TimecodeHarness
139
+ label="Chapter mark"
140
+ stepMs={60_000}
141
+ valueMs={600_000}
142
+ />
143
+ </StoryCell>
144
+
145
+ <StoryCell align="stretch" label="inside a Field">
146
+ <Field
147
+ description="Anything typeable: 90, 1:30, 1:02:03.500."
148
+ label="Resume from"
149
+ >
150
+ <TimecodeInput
151
+ durationMs={DURATION_MS}
152
+ label="Resume from"
153
+ />
154
+ </Field>
155
+ </StoryCell>
156
+
157
+ <StoryCell align="stretch" label='size="lg"'>
158
+ <TimecodeInput
159
+ durationMs={DURATION_MS}
160
+ label="Large"
161
+ size="lg"
162
+ valueMs={62_500}
163
+ />
164
+ </StoryCell>
165
+ </StoryGrid>
166
+ ),
167
+ }
168
+
169
+ /**
170
+ * Empty, filled, disabled — and the four states a section really
171
+ * has. Both ends are independently optional, so "from here to the
172
+ * end" and "from the beginning, stopping here" are values rather
173
+ * than half-finished input.
174
+ */
175
+ export const AllStates: Story = {
176
+ args: { label: "Start at" },
177
+ render: () => (
178
+ <StorySection title="States">
179
+ <StoryGrid columns={2}>
180
+ <StoryCell align="stretch" label="empty">
181
+ <TimecodeHarness label="Empty" />
182
+ </StoryCell>
183
+
184
+ <StoryCell align="stretch" label="filled">
185
+ <TimecodeHarness
186
+ label="Filled"
187
+ valueMs={62_500}
188
+ />
189
+ </StoryCell>
190
+
191
+ <StoryCell align="stretch" label="isDisabled">
192
+ <TimecodeHarness
193
+ isDisabled
194
+ label="Locked"
195
+ valueMs={62_500}
196
+ />
197
+ </StoryCell>
198
+
199
+ <StoryCell
200
+ align="stretch"
201
+ label="section: no window"
202
+ >
203
+ <TimecodeHarness
204
+ isRange
205
+ label="No window"
206
+ valueMs={{ end: null, start: null }}
207
+ />
208
+ </StoryCell>
209
+
210
+ <StoryCell
211
+ align="stretch"
212
+ label="section: open end"
213
+ >
214
+ <TimecodeHarness
215
+ isRange
216
+ label="From here on"
217
+ valueMs={{ end: null, start: 300_000 }}
218
+ />
219
+ </StoryCell>
220
+
221
+ <StoryCell
222
+ align="stretch"
223
+ label="section: open start"
224
+ >
225
+ <TimecodeHarness
226
+ isRange
227
+ label="Up to here"
228
+ valueMs={{ end: 300_000, start: null }}
229
+ />
230
+ </StoryCell>
231
+
232
+ <StoryCell align="stretch" label="section: both">
233
+ <TimecodeHarness
234
+ isRange
235
+ label="Between"
236
+ valueMs={{ end: 900_000, start: 300_000 }}
237
+ />
238
+ </StoryCell>
239
+
240
+ <StoryCell
241
+ align="stretch"
242
+ label="error (type “1:90”)"
243
+ >
244
+ <Field
245
+ error="Pick a position the field can read."
246
+ label="Refused"
247
+ >
248
+ <TimecodeInput
249
+ durationMs={DURATION_MS}
250
+ label="Refused"
251
+ />
252
+ </Field>
253
+ </StoryCell>
254
+ </StoryGrid>
255
+ </StorySection>
256
+ ),
257
+ }
258
+
259
+ /**
260
+ * The field at three container widths. Two inputs and a word between
261
+ * them is the layout that runs out of room first, so the section
262
+ * mode is the one worth looking at narrow.
263
+ */
264
+ export const Responsive: Story = {
265
+ args: { label: "Start at" },
266
+ render: () => (
267
+ <ContainerBoard>
268
+ {(width) => (
269
+ <TimecodeInput
270
+ durationMs={DURATION_MS}
271
+ isRange
272
+ label={`Section ${width}`}
273
+ />
274
+ )}
275
+ </ContainerBoard>
276
+ ),
277
+ }
278
+
279
+ /**
280
+ * The complete keyboard path.
281
+ *
282
+ * Tab to the field and type — the line underneath resolves what you
283
+ * typed, and nothing has committed. Enter or Tab commits. Escape
284
+ * puts the last committed value back. ArrowUp and ArrowDown step by
285
+ * `stepMs`, and Shift multiplies that by ten.
286
+ *
287
+ * In the section below, type an end that sits before the start and
288
+ * the pair **swaps**; type an end equal to the start and it is
289
+ * **refused**, because a section with no length plays nothing.
290
+ */
291
+ export const Interactive: Story = {
292
+ args: { label: "Start at" },
293
+ render: () => (
294
+ <div className="flex flex-col gap-4">
295
+ <TimecodeHarness label="Start at" />
296
+
297
+ <TimecodeHarness
298
+ isRange
299
+ label="Play section"
300
+ valueMs={{ end: null, start: 300_000 }}
301
+ />
302
+ </div>
303
+ ),
304
+ }