@charcuterie/ui 3.33.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 (63) hide show
  1. package/README.md +30 -0
  2. package/dist/Board/Board.d.ts.map +1 -1
  3. package/dist/Board/Board.js +2 -2
  4. package/dist/Board/Board.js.map +1 -1
  5. package/dist/RangeSlider/RangeSlider.d.ts +117 -0
  6. package/dist/RangeSlider/RangeSlider.d.ts.map +1 -0
  7. package/dist/RangeSlider/RangeSlider.js +242 -0
  8. package/dist/RangeSlider/RangeSlider.js.map +1 -0
  9. package/dist/RangeSlider/rangeSliderValue.d.ts +102 -0
  10. package/dist/RangeSlider/rangeSliderValue.d.ts.map +1 -0
  11. package/dist/RangeSlider/rangeSliderValue.js +116 -0
  12. package/dist/RangeSlider/rangeSliderValue.js.map +1 -0
  13. package/dist/SegmentedControl/SegmentedControl.d.ts +12 -1
  14. package/dist/SegmentedControl/SegmentedControl.d.ts.map +1 -1
  15. package/dist/SegmentedControl/SegmentedControl.js +3 -3
  16. package/dist/SegmentedControl/SegmentedControl.js.map +1 -1
  17. package/dist/SegmentedControl/SegmentedOption.d.ts +2 -1
  18. package/dist/SegmentedControl/SegmentedOption.d.ts.map +1 -1
  19. package/dist/SegmentedControl/SegmentedOption.js +7 -2
  20. package/dist/SegmentedControl/SegmentedOption.js.map +1 -1
  21. package/dist/Slider/Slider.d.ts +6 -1
  22. package/dist/Slider/Slider.d.ts.map +1 -1
  23. package/dist/Slider/Slider.js +8 -22
  24. package/dist/Slider/Slider.js.map +1 -1
  25. package/dist/TimecodeInput/TimecodeInput.d.ts +88 -0
  26. package/dist/TimecodeInput/TimecodeInput.d.ts.map +1 -0
  27. package/dist/TimecodeInput/TimecodeInput.js +258 -0
  28. package/dist/TimecodeInput/TimecodeInput.js.map +1 -0
  29. package/dist/TimecodeInput/timecode.d.ts +132 -0
  30. package/dist/TimecodeInput/timecode.d.ts.map +1 -0
  31. package/dist/TimecodeInput/timecode.js +246 -0
  32. package/dist/TimecodeInput/timecode.js.map +1 -0
  33. package/dist/index.d.ts +7 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +3 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/sliderStyles.d.ts +39 -0
  38. package/dist/sliderStyles.d.ts.map +1 -0
  39. package/dist/sliderStyles.js +50 -0
  40. package/dist/sliderStyles.js.map +1 -0
  41. package/package.json +1 -1
  42. package/src/Board/Board.test.tsx +8 -0
  43. package/src/Board/Board.tsx +7 -1
  44. package/src/RangeSlider/RangeSlider.mdx +85 -0
  45. package/src/RangeSlider/RangeSlider.stories.tsx +346 -0
  46. package/src/RangeSlider/RangeSlider.test.tsx +452 -0
  47. package/src/RangeSlider/RangeSlider.tsx +595 -0
  48. package/src/RangeSlider/rangeSliderValue.test.ts +217 -0
  49. package/src/RangeSlider/rangeSliderValue.ts +185 -0
  50. package/src/SegmentedControl/SegmentedControl.stories.tsx +50 -0
  51. package/src/SegmentedControl/SegmentedControl.test.tsx +36 -1
  52. package/src/SegmentedControl/SegmentedControl.tsx +14 -0
  53. package/src/SegmentedControl/SegmentedOption.tsx +7 -0
  54. package/src/Slider/Slider.tsx +23 -29
  55. package/src/TimecodeInput/TimecodeInput.mdx +155 -0
  56. package/src/TimecodeInput/TimecodeInput.stories.tsx +304 -0
  57. package/src/TimecodeInput/TimecodeInput.test.tsx +279 -0
  58. package/src/TimecodeInput/TimecodeInput.tsx +493 -0
  59. package/src/TimecodeInput/timecode.test.ts +236 -0
  60. package/src/TimecodeInput/timecode.ts +391 -0
  61. package/src/index.ts +18 -0
  62. package/src/sliderStyles.ts +69 -0
  63. package/src/sourceRules.test.ts +25 -1
@@ -0,0 +1,236 @@
1
+ /**
2
+ * The typed-timecode grammar.
3
+ *
4
+ * The interesting half of this file is the refusals. A parser that
5
+ * only proves it accepts `1:02:03` is a parser nobody has checked for
6
+ * the thing that actually hurts: quietly accepting something as the
7
+ * wrong position, which in this domain means a clip that starts a
8
+ * minute late and a rip nobody rewatches until it is too late.
9
+ */
10
+
11
+ import { expect, test } from "vitest"
12
+
13
+ import {
14
+ clampTimecode,
15
+ formatTimecode,
16
+ parseTimecodeInput,
17
+ } from "./timecode.ts"
18
+
19
+ const parsedMilliseconds = (text: string) => {
20
+ const result = parseTimecodeInput(text)
21
+
22
+ return result.kind === "timecode"
23
+ ? result.milliseconds
24
+ : result
25
+ }
26
+
27
+ const refusalReason = (text: string) => {
28
+ const result = parseTimecodeInput(text)
29
+
30
+ return result.kind === "unparsed" ? result.reason : ""
31
+ }
32
+
33
+ test("a bare number is SECONDS, because seconds is the smallest field the form prints", () => {
34
+ expect(parsedMilliseconds("0")).toBe(0)
35
+ expect(parsedMilliseconds("9")).toBe(9_000)
36
+ expect(parsedMilliseconds("90")).toBe(90_000)
37
+ expect(parsedMilliseconds("5400")).toBe(5_400_000)
38
+ })
39
+
40
+ test("mm:ss and hh:mm:ss both resolve, and the largest field is unbounded", () => {
41
+ expect(parsedMilliseconds("1:30")).toBe(90_000)
42
+ expect(parsedMilliseconds("0:09")).toBe(9_000)
43
+ expect(parsedMilliseconds("1:02:03")).toBe(3_723_000)
44
+
45
+ // 90 minutes. The largest field present carries no 0–59 bound, so
46
+ // this is the same position as `1:30:00` and both are accepted.
47
+ expect(parsedMilliseconds("90:00")).toBe(5_400_000)
48
+ expect(parsedMilliseconds("1:30:00")).toBe(5_400_000)
49
+ })
50
+
51
+ test("a fraction is a decimal fraction of a second, so it pads on the right", () => {
52
+ expect(parsedMilliseconds("1.5")).toBe(1_500)
53
+ expect(parsedMilliseconds("1.05")).toBe(1_050)
54
+ expect(parsedMilliseconds("1.005")).toBe(1_005)
55
+ expect(parsedMilliseconds("1:02:03.500")).toBe(3_723_500)
56
+
57
+ // The decimal comma resolves identically. It is not a locale
58
+ // switch: a timecode has no thousands separator, so there is
59
+ // nothing for the comma to be confused with.
60
+ expect(parsedMilliseconds("1,5")).toBe(1_500)
61
+ })
62
+
63
+ test("a leading + is tolerated, and changes nothing", () => {
64
+ expect(parsedMilliseconds("+90")).toBe(90_000)
65
+ expect(parsedMilliseconds("+1:02:03")).toBe(3_723_000)
66
+ })
67
+
68
+ test("surrounding whitespace is not something a person should have to notice", () => {
69
+ expect(parsedMilliseconds(" 1:30 ")).toBe(90_000)
70
+ expect(parsedMilliseconds("1 : 30")).toBe(90_000)
71
+ })
72
+
73
+ test("empty is its own outcome, because clearing a timecode is legitimate", () => {
74
+ expect(parseTimecodeInput("").kind).toBe("empty")
75
+ expect(parseTimecodeInput(" ").kind).toBe("empty")
76
+ })
77
+
78
+ test("a seconds field of 60 or more is refused by name, and never carried", () => {
79
+ // The `1:60` trap mux-magic wrote down. A parser that carries this
80
+ // moves the position by a whole minute and nothing ever says so.
81
+ expect(parseTimecodeInput("1:90").kind).toBe("unparsed")
82
+
83
+ const reason = refusalReason("1:90")
84
+
85
+ expect(reason).toContain("A minute has 60 seconds")
86
+
87
+ // Both legal spellings are offered and neither is taken.
88
+ expect(reason).toContain("02:30")
89
+ expect(reason).toContain("90")
90
+
91
+ expect(parseTimecodeInput("1:60").kind).toBe("unparsed")
92
+ expect(parseTimecodeInput("1:02:60").kind).toBe(
93
+ "unparsed",
94
+ )
95
+ })
96
+
97
+ test("a minutes field of 60 or more is refused whenever an hour sits beside it", () => {
98
+ expect(parseTimecodeInput("1:90:00").kind).toBe(
99
+ "unparsed",
100
+ )
101
+
102
+ expect(refusalReason("1:90:00")).toContain(
103
+ "An hour has 60 minutes",
104
+ )
105
+
106
+ // …and is accepted when it is the largest field, which is the
107
+ // whole of the rule in one pair of cases.
108
+ expect(parsedMilliseconds("90:00")).toBe(5_400_000)
109
+ })
110
+
111
+ test("a negative is refused as out of bounds, not as unreadable", () => {
112
+ expect(parseTimecodeInput("-5").kind).toBe("unparsed")
113
+
114
+ expect(refusalReason("-5")).toContain("never negative")
115
+
116
+ expect(parseTimecodeInput("-1:30").kind).toBe("unparsed")
117
+ })
118
+
119
+ test("more than three parts is refused, and says how many there are", () => {
120
+ expect(parseTimecodeInput("1:2:3:4").kind).toBe(
121
+ "unparsed",
122
+ )
123
+
124
+ expect(refusalReason("1:2:3:4")).toContain(
125
+ "three parts at most",
126
+ )
127
+ })
128
+
129
+ test("a fraction longer than three digits is refused rather than truncated", () => {
130
+ expect(parseTimecodeInput("1.5000").kind).toBe("unparsed")
131
+
132
+ expect(refusalReason("1.5000")).toContain(
133
+ "three digits at most",
134
+ )
135
+ })
136
+
137
+ test("a partial or nonsense input never becomes a nearest guess", () => {
138
+ for (const text of [
139
+ "1:",
140
+ ":30",
141
+ "1::30",
142
+ "abc",
143
+ "1m30s",
144
+ "1:30pm",
145
+ "--",
146
+ ".",
147
+ "1.",
148
+ ]) {
149
+ expect(parseTimecodeInput(text).kind, text).toBe(
150
+ "unparsed",
151
+ )
152
+ }
153
+ })
154
+
155
+ test("formatTimecode prints the canonical hh:mm:ss.mmm by default", () => {
156
+ expect(formatTimecode(0)).toBe("00:00:00.000")
157
+ expect(formatTimecode(90_000)).toBe("00:01:30.000")
158
+ expect(formatTimecode(3_723_500)).toBe("01:02:03.500")
159
+ expect(formatTimecode(36_000_000)).toBe("10:00:00.000")
160
+ })
161
+
162
+ test("what formatTimecode prints, parseTimecodeInput reads back unchanged", () => {
163
+ for (const milliseconds of [
164
+ 0, 1, 999, 1_000, 90_000, 3_723_500, 5_400_000,
165
+ 36_000_000,
166
+ ]) {
167
+ expect(
168
+ parsedMilliseconds(formatTimecode(milliseconds)),
169
+ String(milliseconds),
170
+ ).toBe(milliseconds)
171
+ }
172
+ })
173
+
174
+ test("isHoursShown false rolls the hours into the minutes, and still round-trips", () => {
175
+ expect(
176
+ formatTimecode(5_400_000, { isHoursShown: false }),
177
+ ).toBe("90:00.000")
178
+
179
+ expect(parsedMilliseconds("90:00.000")).toBe(5_400_000)
180
+
181
+ expect(
182
+ formatTimecode(90_000, { isHoursShown: false }),
183
+ ).toBe("01:30.000")
184
+ })
185
+
186
+ test("millisecondDigits truncates rather than rounding, because a position is not a measurement", () => {
187
+ // 1999 ms into a file is second 1. Rounding would print a seek
188
+ // point the media has not reached.
189
+ expect(
190
+ formatTimecode(1_999, { millisecondDigits: 0 }),
191
+ ).toBe("00:00:01")
192
+
193
+ expect(
194
+ formatTimecode(1_999, { millisecondDigits: 1 }),
195
+ ).toBe("00:00:01.9")
196
+
197
+ expect(
198
+ formatTimecode(1_999, { millisecondDigits: 2 }),
199
+ ).toBe("00:00:01.99")
200
+
201
+ // Out-of-range digit counts are held rather than thrown, so a
202
+ // caller's off-by-one cannot produce `00:00:01.` with a trailing
203
+ // separator and nothing after it.
204
+ expect(
205
+ formatTimecode(1_999, { millisecondDigits: 9 }),
206
+ ).toBe("00:00:01.999")
207
+
208
+ expect(
209
+ formatTimecode(1_999, { millisecondDigits: -1 }),
210
+ ).toBe("00:00:01")
211
+ })
212
+
213
+ test("formatTimecode refuses to print a position that does not exist", () => {
214
+ expect(formatTimecode(-5_000)).toBe("00:00:00.000")
215
+ expect(formatTimecode(Number.NaN)).toBe("00:00:00.000")
216
+
217
+ expect(formatTimecode(Number.POSITIVE_INFINITY)).toBe(
218
+ "00:00:00.000",
219
+ )
220
+ })
221
+
222
+ test("clampTimecode floors at zero even when no minimum is given", () => {
223
+ expect(clampTimecode(-1)).toBe(0)
224
+ expect(clampTimecode(5_000)).toBe(5_000)
225
+ expect(clampTimecode(Number.NaN)).toBe(0)
226
+ })
227
+
228
+ test("clampTimecode holds a position inside the media", () => {
229
+ expect(clampTimecode(5_000, 10_000, 60_000)).toBe(10_000)
230
+ expect(clampTimecode(90_000, 10_000, 60_000)).toBe(60_000)
231
+ expect(clampTimecode(30_000, 10_000, 60_000)).toBe(30_000)
232
+
233
+ // An inverted pair of bounds resolves to the floor rather than
234
+ // silently widening the window a caller asked for.
235
+ expect(clampTimecode(30_000, 60_000, 10_000)).toBe(60_000)
236
+ })
@@ -0,0 +1,391 @@
1
+ /**
2
+ * What a human types into a timecode field, turned into a position
3
+ * in a piece of media — or into a stated reason it was not one.
4
+ *
5
+ * ### Milliseconds, and only milliseconds
6
+ *
7
+ * A media position is a **duration from the start of the file**, so
8
+ * the value is an integer count of milliseconds and nothing else. It
9
+ * is not a `Date`, it is not a clock time, and it is not a string:
10
+ * the only arithmetic anybody does with it is subtraction, and a
11
+ * number is the type that supports it. The props that carry one keep
12
+ * the `Ms` suffix, because a duration's unit is genuinely ambiguous
13
+ * and the fleet has both seconds and milliseconds in use
14
+ * ([decision](../../../../docs/decisions/2026-08-25-an-outbound-http-cache-names-its-fields-fetchedat-and-expiresat.md)).
15
+ *
16
+ * ### The fleet writes the printing half five times and the reading
17
+ * half never
18
+ *
19
+ * `formatTimecode` replaces five hand-rolled `h:mm:ss` printers —
20
+ * queuepilot's `clock` and `toClock`, castkit's `formatTime`, and
21
+ * mux-magic's two modal formatters — each with its own answer to
22
+ * whether the hour is shown and whether the minute is padded.
23
+ * `parseTimecodeInput` has no counterpart at all: nothing in the
24
+ * fleet *reads* a timecode a person typed, which is why the grammar
25
+ * gets the care here rather than being rediscovered per app.
26
+ *
27
+ * ### The rule that governs the grammar: never guess in silence
28
+ *
29
+ * The same three mechanisms `parseDateInput.ts` states, for the same
30
+ * reason:
31
+ *
32
+ * 1. **The grammar is ordered and total.** Each accepting rule is
33
+ * anchored on the *whole* input, the first match wins, and if
34
+ * none matches the answer is a named failure. `1:` is not one
35
+ * minute, and `1:90` is not `2:30`.
36
+ * 2. **An overflow is a failure with a name, not a carry.** `1:90`
37
+ * is refused, and the message offers both legal spellings —
38
+ * `2:30` and `90` — without committing to either. Silently
39
+ * carrying 90 seconds into the minute is the `1:60` trap
40
+ * mux-magic wrote down in
41
+ * `docs/audits/2026-06-29-pre-rename-domain-decisions.md`.
42
+ * 3. **Nothing commits without an echo.** `TimecodeInput` renders
43
+ * the resolved position in full underneath the field, in a live
44
+ * region, before anything commits.
45
+ *
46
+ * ### The largest field present is unbounded; every smaller one is
47
+ * 0–59
48
+ *
49
+ * One sentence covers every bound in the grammar. `90` is ninety
50
+ * **seconds**, `90:00` is ninety **minutes**, and `1:90` is refused
51
+ * because the minute beside it makes 90 a seconds field. That is why
52
+ * a bare number is seconds rather than minutes: seconds is the
53
+ * smallest unit the field prints, so the reading that needs no
54
+ * explanation is the one where the typist appended nothing.
55
+ */
56
+
57
+ const MILLISECONDS_PER_SECOND = 1_000
58
+
59
+ const MILLISECONDS_PER_MINUTE = 60_000
60
+
61
+ const MILLISECONDS_PER_HOUR = 3_600_000
62
+
63
+ /**
64
+ * A clip window, in milliseconds. Keys alphabetised, matching
65
+ * `DateRange`.
66
+ *
67
+ * **Both ends are independently optional**, and the four
68
+ * combinations are four real states rather than three plus an error:
69
+ * no window at all, from here to the end of the media, from the
70
+ * beginning up to here, and the window between two points. An open
71
+ * start is not zero — it is the absence of a choice — which is what
72
+ * lets a half-open window be clamped without being compared.
73
+ */
74
+ export type TimecodeRange = {
75
+ end: null | number
76
+ start: null | number
77
+ }
78
+
79
+ export type TimecodeInputResult =
80
+ | { kind: "empty" }
81
+ | { kind: "timecode"; milliseconds: number }
82
+ | { kind: "unparsed"; reason: string }
83
+
84
+ export type FormatTimecodeOptions = {
85
+ /**
86
+ * `true` — the default — prints the hours field even when it is
87
+ * `00`, so every value the field writes has the same shape and the
88
+ * same width. `false` rolls the hours into the minutes
89
+ * (`5400000` → `90:00.000`), which the grammar reads back
90
+ * unchanged because the largest field present is unbounded.
91
+ */
92
+ isHoursShown?: boolean
93
+ /** `0` through `3`. `0` drops the fraction and its separator. */
94
+ millisecondDigits?: number
95
+ }
96
+
97
+ const padTwo = (value: number) =>
98
+ String(value).padStart(2, "0")
99
+
100
+ /**
101
+ * A position in a piece of media, printed.
102
+ *
103
+ * Zero-padded, and **truncating** rather than rounding: at
104
+ * `millisecondDigits: 0` a position 1999 ms into a file is second 1,
105
+ * because it has not reached second 2 yet. Rounding would print a
106
+ * seek point the media has not played.
107
+ *
108
+ * The default is the canonical `hh:mm:ss.mmm`, and that is what the
109
+ * field writes back on commit. One spelling means a re-read of the
110
+ * field parses to the number that produced it, and a screenshot of
111
+ * the same value is the same picture — neither of which is true of
112
+ * the fleet's five printers, three of which hide the hour and two of
113
+ * which do not pad the minute.
114
+ */
115
+ export const formatTimecode = (
116
+ milliseconds: number,
117
+ {
118
+ isHoursShown = true,
119
+ millisecondDigits = 3,
120
+ }: FormatTimecodeOptions = {},
121
+ ): string => {
122
+ const total = Number.isFinite(milliseconds)
123
+ ? Math.max(0, Math.floor(milliseconds))
124
+ : 0
125
+
126
+ const digits = Math.min(
127
+ 3,
128
+ Math.max(0, Math.floor(millisecondDigits)),
129
+ )
130
+
131
+ const hours = isHoursShown
132
+ ? Math.floor(total / MILLISECONDS_PER_HOUR)
133
+ : 0
134
+
135
+ const minutes = Math.floor(
136
+ (total - hours * MILLISECONDS_PER_HOUR) /
137
+ MILLISECONDS_PER_MINUTE,
138
+ )
139
+
140
+ const seconds = Math.floor(
141
+ (total % MILLISECONDS_PER_MINUTE) /
142
+ MILLISECONDS_PER_SECOND,
143
+ )
144
+
145
+ const fraction =
146
+ digits === 0
147
+ ? ""
148
+ : `.${String(total % MILLISECONDS_PER_SECOND)
149
+ .padStart(3, "0")
150
+ .slice(0, digits)}`
151
+
152
+ return `${isHoursShown ? `${padTwo(hours)}:` : ""}${padTwo(minutes)}:${padTwo(seconds)}${fraction}`
153
+ }
154
+
155
+ /**
156
+ * Hold a position inside the media.
157
+ *
158
+ * The floor is `0` when no `minimum` is given, because a media
159
+ * position before the start of the file does not exist — the same
160
+ * reason the grammar refuses a negative rather than clamping one
161
+ * silently. An inverted pair of bounds resolves to the floor: a
162
+ * caller whose `maximum` sits below its `minimum` has a bug, and
163
+ * quietly widening the window would hide it somewhere nobody looks.
164
+ */
165
+ export const clampTimecode = (
166
+ milliseconds: number,
167
+ minimum?: number,
168
+ maximum?: number,
169
+ ): number => {
170
+ const floor = Math.max(0, minimum ?? 0)
171
+
172
+ if (!Number.isFinite(milliseconds)) {
173
+ return floor
174
+ }
175
+
176
+ const ceiling =
177
+ maximum === undefined || !Number.isFinite(maximum)
178
+ ? Number.POSITIVE_INFINITY
179
+ : Math.max(floor, maximum)
180
+
181
+ return Math.min(Math.max(milliseconds, floor), ceiling)
182
+ }
183
+
184
+ /**
185
+ * The three accepting rules, in the order they are tried.
186
+ *
187
+ * They are disjoint by colon count, so the order is a statement of
188
+ * discipline rather than a tie-breaker — but the discipline is what
189
+ * keeps a fourth rule from being appended as a nearest guess.
190
+ *
191
+ * The fraction separator may be a `.` or a `,`: half of Europe types
192
+ * the second one, both are unambiguous here because a timecode has
193
+ * no thousands separator, and the echo prints the canonical `.` back
194
+ * either way.
195
+ */
196
+ const HOURS_MINUTES_SECONDS =
197
+ /^\+?(\d{1,5}):(\d{1,2}):(\d{1,2})(?:[.,](\d{1,3}))?$/
198
+
199
+ const MINUTES_SECONDS =
200
+ /^\+?(\d{1,7}):(\d{1,2})(?:[.,](\d{1,3}))?$/
201
+
202
+ const SECONDS = /^\+?(\d{1,9})(?:[.,](\d{1,3}))?$/
203
+
204
+ /** Anchored diagnostics, tried only after every accepting rule has failed. */
205
+ const NEGATIVE = /^-/
206
+
207
+ const TOO_MANY_PARTS = /^\+?\d+(?::\d+){3,}(?:[.,]\d+)?$/
208
+
209
+ const LONG_FRACTION = /^\+?\d+(?::\d+){0,2}[.,]\d{4,}$/
210
+
211
+ const unparsed = (reason: string): TimecodeInputResult => ({
212
+ kind: "unparsed",
213
+ reason,
214
+ })
215
+
216
+ /**
217
+ * A field that overflowed, named rather than carried.
218
+ *
219
+ * The message OFFERS both legal spellings and commits to neither,
220
+ * which is the whole difference between this and a parser that
221
+ * "just works": carrying the overflow moves a clip start by a whole
222
+ * minute and nothing ever says so. Offering the carried form is not
223
+ * the same as taking it — the typist reads two options and picks,
224
+ * and either pick is one keystroke away.
225
+ */
226
+ const describeOverflow = ({
227
+ carriedMilliseconds,
228
+ dropped,
229
+ isHoursShown,
230
+ larger,
231
+ typed,
232
+ unit,
233
+ }: {
234
+ carriedMilliseconds: number
235
+ dropped: string
236
+ isHoursShown: boolean
237
+ larger: string
238
+ typed: string
239
+ unit: "minute" | "second"
240
+ }) =>
241
+ `${larger} has 60 ${unit}s, so "${typed}" is not a ${unit}s field. Type ${formatTimecode(
242
+ carriedMilliseconds,
243
+ { isHoursShown, millisecondDigits: 0 },
244
+ )} to carry it, or ${dropped} to mean ${typed} ${unit}s.`
245
+
246
+ const toMilliseconds = ({
247
+ fraction,
248
+ hours,
249
+ minutes,
250
+ seconds,
251
+ }: {
252
+ fraction: string | undefined
253
+ hours?: string
254
+ minutes?: string
255
+ seconds: string
256
+ }): TimecodeInputResult => {
257
+ const total =
258
+ Number(hours ?? 0) * MILLISECONDS_PER_HOUR +
259
+ Number(minutes ?? 0) * MILLISECONDS_PER_MINUTE +
260
+ Number(seconds) * MILLISECONDS_PER_SECOND
261
+
262
+ // The largest field present is unbounded; every smaller one is
263
+ // 0–59. Checked outermost-first, so `1:90:90` reports the minute —
264
+ // the field a reader fixes first.
265
+ if (
266
+ hours !== undefined &&
267
+ minutes !== undefined &&
268
+ Number(minutes) > 59
269
+ ) {
270
+ return unparsed(
271
+ describeOverflow({
272
+ carriedMilliseconds: total,
273
+ dropped: `${minutes}:${seconds}`,
274
+ isHoursShown: true,
275
+ larger: "An hour",
276
+ typed: minutes,
277
+ unit: "minute",
278
+ }),
279
+ )
280
+ }
281
+
282
+ if (minutes !== undefined && Number(seconds) > 59) {
283
+ return unparsed(
284
+ describeOverflow({
285
+ carriedMilliseconds: total,
286
+ dropped: seconds,
287
+ isHoursShown: hours !== undefined,
288
+ larger: "A minute",
289
+ typed: seconds,
290
+ unit: "second",
291
+ }),
292
+ )
293
+ }
294
+
295
+ // `.5` is half a second, not five milliseconds — a fraction is a
296
+ // decimal fraction of a second, so it pads on the RIGHT.
297
+ return {
298
+ kind: "timecode",
299
+ milliseconds:
300
+ total +
301
+ (fraction === undefined
302
+ ? 0
303
+ : Number(fraction.padEnd(3, "0"))),
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Read a typed timecode, or say why it is not one.
309
+ *
310
+ * Takes no options, deliberately. Every knob considered belongs
311
+ * somewhere else: a maximum is the field's clamp at commit rather
312
+ * than a parse failure (two sources for one fact is how a control
313
+ * ends up invalid with nothing saying why), the decimal comma is
314
+ * accepted unconditionally instead of being a locale switch, and a
315
+ * frame rate makes this a different widget. An options bag that
316
+ * means nothing is API a consumer has to read before learning there
317
+ * is nothing in it.
318
+ */
319
+ export const parseTimecodeInput = (
320
+ rawText: string,
321
+ ): TimecodeInputResult => {
322
+ const text = rawText.replace(/\s/g, "")
323
+
324
+ if (text === "") {
325
+ return { kind: "empty" }
326
+ }
327
+
328
+ // A leading `-` is refused before anything else. Every accepting
329
+ // rule below would otherwise fail on it and report the generic
330
+ // message, which tells a typist that `-5` is unreadable rather
331
+ // than that it is out of bounds.
332
+ if (NEGATIVE.test(text)) {
333
+ return unparsed(
334
+ "A timecode is never negative. The start of the media is 0.",
335
+ )
336
+ }
337
+
338
+ const hoursMinutesSeconds =
339
+ HOURS_MINUTES_SECONDS.exec(text)
340
+
341
+ if (
342
+ hoursMinutesSeconds?.[1] !== undefined &&
343
+ hoursMinutesSeconds[2] !== undefined &&
344
+ hoursMinutesSeconds[3] !== undefined
345
+ ) {
346
+ return toMilliseconds({
347
+ fraction: hoursMinutesSeconds[4],
348
+ hours: hoursMinutesSeconds[1],
349
+ minutes: hoursMinutesSeconds[2],
350
+ seconds: hoursMinutesSeconds[3],
351
+ })
352
+ }
353
+
354
+ const minutesSeconds = MINUTES_SECONDS.exec(text)
355
+
356
+ if (
357
+ minutesSeconds?.[1] !== undefined &&
358
+ minutesSeconds[2] !== undefined
359
+ ) {
360
+ return toMilliseconds({
361
+ fraction: minutesSeconds[3],
362
+ minutes: minutesSeconds[1],
363
+ seconds: minutesSeconds[2],
364
+ })
365
+ }
366
+
367
+ const seconds = SECONDS.exec(text)
368
+
369
+ if (seconds?.[1] !== undefined) {
370
+ return toMilliseconds({
371
+ fraction: seconds[2],
372
+ seconds: seconds[1],
373
+ })
374
+ }
375
+
376
+ if (TOO_MANY_PARTS.test(text)) {
377
+ return unparsed(
378
+ "A timecode has three parts at most — hours, minutes, seconds. Try 1:02:03.",
379
+ )
380
+ }
381
+
382
+ if (LONG_FRACTION.test(text)) {
383
+ return unparsed(
384
+ "The fraction is milliseconds — three digits at most. Try 1:02.500.",
385
+ )
386
+ }
387
+
388
+ return unparsed(
389
+ "Not a timecode. Try 90, 1:30, 1:02:03, or 1:02:03.500.",
390
+ )
391
+ }
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 {