@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.
- package/README.md +30 -0
- package/dist/AdaptiveGrid/useAdaptiveColumns.d.ts +16 -0
- package/dist/AdaptiveGrid/useAdaptiveColumns.d.ts.map +1 -1
- package/dist/AdaptiveGrid/useAdaptiveColumns.js +1 -0
- package/dist/AdaptiveGrid/useAdaptiveColumns.js.map +1 -1
- package/dist/Board/Board.d.ts.map +1 -1
- package/dist/Board/Board.js +2 -2
- package/dist/Board/Board.js.map +1 -1
- package/dist/RangeSlider/RangeSlider.d.ts +117 -0
- package/dist/RangeSlider/RangeSlider.d.ts.map +1 -0
- package/dist/RangeSlider/RangeSlider.js +242 -0
- package/dist/RangeSlider/RangeSlider.js.map +1 -0
- package/dist/RangeSlider/rangeSliderValue.d.ts +102 -0
- package/dist/RangeSlider/rangeSliderValue.d.ts.map +1 -0
- package/dist/RangeSlider/rangeSliderValue.js +116 -0
- package/dist/RangeSlider/rangeSliderValue.js.map +1 -0
- package/dist/SegmentedControl/SegmentedControl.d.ts +12 -1
- package/dist/SegmentedControl/SegmentedControl.d.ts.map +1 -1
- package/dist/SegmentedControl/SegmentedControl.js +3 -3
- package/dist/SegmentedControl/SegmentedControl.js.map +1 -1
- package/dist/SegmentedControl/SegmentedOption.d.ts +2 -1
- package/dist/SegmentedControl/SegmentedOption.d.ts.map +1 -1
- package/dist/SegmentedControl/SegmentedOption.js +7 -2
- package/dist/SegmentedControl/SegmentedOption.js.map +1 -1
- package/dist/Slider/Slider.d.ts +6 -1
- package/dist/Slider/Slider.d.ts.map +1 -1
- package/dist/Slider/Slider.js +8 -22
- package/dist/Slider/Slider.js.map +1 -1
- package/dist/TimecodeInput/TimecodeInput.d.ts +88 -0
- package/dist/TimecodeInput/TimecodeInput.d.ts.map +1 -0
- package/dist/TimecodeInput/TimecodeInput.js +258 -0
- package/dist/TimecodeInput/TimecodeInput.js.map +1 -0
- package/dist/TimecodeInput/timecode.d.ts +132 -0
- package/dist/TimecodeInput/timecode.d.ts.map +1 -0
- package/dist/TimecodeInput/timecode.js +246 -0
- package/dist/TimecodeInput/timecode.js.map +1 -0
- package/dist/VirtualizedGrid/VirtualizedGrid.d.ts +35 -0
- package/dist/VirtualizedGrid/VirtualizedGrid.d.ts.map +1 -1
- package/dist/VirtualizedGrid/VirtualizedGrid.js +37 -1
- package/dist/VirtualizedGrid/VirtualizedGrid.js.map +1 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/sliderStyles.d.ts +39 -0
- package/dist/sliderStyles.d.ts.map +1 -0
- package/dist/sliderStyles.js +50 -0
- package/dist/sliderStyles.js.map +1 -0
- package/package.json +1 -1
- package/src/AdaptiveGrid/useAdaptiveColumns.ts +17 -0
- package/src/Board/Board.test.tsx +8 -0
- package/src/Board/Board.tsx +7 -1
- package/src/RangeSlider/RangeSlider.mdx +85 -0
- package/src/RangeSlider/RangeSlider.stories.tsx +346 -0
- package/src/RangeSlider/RangeSlider.test.tsx +452 -0
- package/src/RangeSlider/RangeSlider.tsx +595 -0
- package/src/RangeSlider/rangeSliderValue.test.ts +217 -0
- package/src/RangeSlider/rangeSliderValue.ts +185 -0
- package/src/SegmentedControl/SegmentedControl.stories.tsx +50 -0
- package/src/SegmentedControl/SegmentedControl.test.tsx +36 -1
- package/src/SegmentedControl/SegmentedControl.tsx +14 -0
- package/src/SegmentedControl/SegmentedOption.tsx +7 -0
- package/src/Slider/Slider.tsx +23 -29
- package/src/TimecodeInput/TimecodeInput.mdx +155 -0
- package/src/TimecodeInput/TimecodeInput.stories.tsx +304 -0
- package/src/TimecodeInput/TimecodeInput.test.tsx +279 -0
- package/src/TimecodeInput/TimecodeInput.tsx +493 -0
- package/src/TimecodeInput/timecode.test.ts +236 -0
- package/src/TimecodeInput/timecode.ts +391 -0
- package/src/VirtualizedGrid/VirtualizedGrid.mdx +21 -0
- package/src/VirtualizedGrid/VirtualizedGrid.stories.tsx +64 -0
- package/src/VirtualizedGrid/VirtualizedGrid.test.tsx +122 -3
- package/src/VirtualizedGrid/VirtualizedGrid.tsx +37 -1
- package/src/index.ts +18 -0
- package/src/sliderStyles.ts +69 -0
- package/src/sourceRules.test.ts +25 -1
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
import { useUniqueId } from "@charcuterie/logic"
|
|
2
|
+
import type { ControlSize } from "@charcuterie/tokens"
|
|
3
|
+
import type {
|
|
4
|
+
KeyboardEvent as ReactKeyboardEvent,
|
|
5
|
+
ReactNode,
|
|
6
|
+
} from "react"
|
|
7
|
+
import { useState } from "react"
|
|
8
|
+
|
|
9
|
+
import { CONTROL_SIZE_CLASS } from "../controlStyles.ts"
|
|
10
|
+
import {
|
|
11
|
+
DISABLED_CLASS,
|
|
12
|
+
FOCUS_RING_CLASS,
|
|
13
|
+
} from "../intentStyles.ts"
|
|
14
|
+
import type { SlotProps } from "../slotProps.ts"
|
|
15
|
+
import { mergeSlotProps } from "../slotProps.ts"
|
|
16
|
+
import { toClassName } from "../toClassName.ts"
|
|
17
|
+
import type { TimecodeRange } from "./timecode.ts"
|
|
18
|
+
import {
|
|
19
|
+
clampTimecode,
|
|
20
|
+
formatTimecode,
|
|
21
|
+
parseTimecodeInput,
|
|
22
|
+
} from "./timecode.ts"
|
|
23
|
+
|
|
24
|
+
export type TimecodeInputProps = SlotProps & {
|
|
25
|
+
className?: string
|
|
26
|
+
/**
|
|
27
|
+
* How long the media is. It is the default `maxValueMs`, so a
|
|
28
|
+
* consumer that knows the file's length gets the clamp without
|
|
29
|
+
* stating a bound twice.
|
|
30
|
+
*/
|
|
31
|
+
durationMs?: number
|
|
32
|
+
isDisabled?: boolean
|
|
33
|
+
/**
|
|
34
|
+
* Section mode — a start and an end. A **mode, not a sibling
|
|
35
|
+
* component**, following `DatePicker`'s `isRange` and `Combobox`'s
|
|
36
|
+
* `isMultiple`.
|
|
37
|
+
*/
|
|
38
|
+
isRange?: boolean
|
|
39
|
+
/**
|
|
40
|
+
* Required. In section mode it names **both** inputs, as
|
|
41
|
+
* "<label> start" and "<label> end", so the two stay separately
|
|
42
|
+
* addressable by a screen reader and by an agent.
|
|
43
|
+
*/
|
|
44
|
+
label: string
|
|
45
|
+
/** The end of the allowed window. Defaults to `durationMs`. */
|
|
46
|
+
maxValueMs?: number
|
|
47
|
+
/** The start of the allowed window. Defaults to `0`. */
|
|
48
|
+
minValueMs?: number
|
|
49
|
+
/**
|
|
50
|
+
* Milliseconds in single mode, a `{ end, start }` of them in
|
|
51
|
+
* section mode, and `null` when both ends are cleared.
|
|
52
|
+
*/
|
|
53
|
+
onChange?: (value: null | number | TimecodeRange) => void
|
|
54
|
+
placeholder?: string
|
|
55
|
+
size?: ControlSize
|
|
56
|
+
/** How far ArrowUp and ArrowDown move. Shift multiplies it by ten. */
|
|
57
|
+
stepMs?: number
|
|
58
|
+
/** **Initial** only, like every other value prop in this library. */
|
|
59
|
+
valueMs?: number | TimecodeRange
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const INPUT_CLASS =
|
|
63
|
+
"w-full min-w-0 rounded-md border border-border-default bg-surface-raised text-content-primary transition-colors duration-(--duration-fast) ease-standard placeholder:text-content-muted hover:border-border-strong aria-invalid:border-intent-danger-border"
|
|
64
|
+
|
|
65
|
+
type Endpoint = "end" | "start"
|
|
66
|
+
|
|
67
|
+
const toSeededRange = (
|
|
68
|
+
valueMs: number | TimecodeRange | undefined,
|
|
69
|
+
): TimecodeRange =>
|
|
70
|
+
valueMs === undefined
|
|
71
|
+
? { end: null, start: null }
|
|
72
|
+
: typeof valueMs === "number"
|
|
73
|
+
? { end: null, start: valueMs }
|
|
74
|
+
: valueMs
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A timecode field: a text input a person can actually type a media
|
|
78
|
+
* position into, with an `isRange` mode for a section of a file.
|
|
79
|
+
*
|
|
80
|
+
* ### Why this is a text field and not `<input type="time">`
|
|
81
|
+
*
|
|
82
|
+
* Two reasons and neither is taste.
|
|
83
|
+
*
|
|
84
|
+
* A `time` input's smallest step is a **second** unless `step` is
|
|
85
|
+
* fractional, its value is a wall-clock `HH:MM` string capped at 24
|
|
86
|
+
* hours, and it has no way to express "1.5 hours into a file" as a
|
|
87
|
+
* duration rather than as half past one. A clip boundary in a video
|
|
88
|
+
* editor is milliseconds; the platform control cannot carry them.
|
|
89
|
+
*
|
|
90
|
+
* And it is painted by the operating system, from
|
|
91
|
+
* `::-webkit-datetime-edit-*` pseudo-elements no token reaches
|
|
92
|
+
* inside — the same mechanism that
|
|
93
|
+
* [deprecated the native `Select`](../../../../docs/decisions/2026-08-20-native-select-is-deprecated-and-the-platform-hatch-is-closed.md)
|
|
94
|
+
* and that kept `Slider` off
|
|
95
|
+
* [`<input type="range">`](../../../../docs/decisions/2026-08-22-the-slider-is-a-div-with-role-slider-not-an-input-range.md).
|
|
96
|
+
* A `<input type="text">` this component owns every pixel of is the
|
|
97
|
+
* settled answer to that shape of question.
|
|
98
|
+
*
|
|
99
|
+
* ### Typing is the whole interface, so nothing guesses in silence
|
|
100
|
+
*
|
|
101
|
+
* `90`, `1:30`, `1:02:03` and `1:02:03.500` all resolve, and the
|
|
102
|
+
* resolution is echoed underneath the field, in a live region, before
|
|
103
|
+
* anything commits. Commit is Enter or blur; a keystroke never
|
|
104
|
+
* commits, a refused value leaves the text exactly as typed, and
|
|
105
|
+
* Escape puts back the last committed value. `timecode.ts` is the
|
|
106
|
+
* grammar and the reasoning.
|
|
107
|
+
*
|
|
108
|
+
* ### Both ends of a section are independently optional
|
|
109
|
+
*
|
|
110
|
+
* `{ end, start }` has four real states, not three plus an error: no
|
|
111
|
+
* window, from here to the end of the media, from the beginning up to
|
|
112
|
+
* here, and the window between two points. So there is no
|
|
113
|
+
* `isEndOpen` prop — `null` says it — and clearing one end never
|
|
114
|
+
* touches the other.
|
|
115
|
+
*
|
|
116
|
+
* An **inverted** section swaps, because throwing the pair away would
|
|
117
|
+
* discard the boundary just typed. A **zero-length** section is
|
|
118
|
+
* refused by name: it plays nothing, so swapping would hide a typo
|
|
119
|
+
* behind a window that silently does not exist.
|
|
120
|
+
*/
|
|
121
|
+
export const TimecodeInput = ({
|
|
122
|
+
className,
|
|
123
|
+
durationMs,
|
|
124
|
+
isDisabled = false,
|
|
125
|
+
isRange = false,
|
|
126
|
+
label,
|
|
127
|
+
maxValueMs,
|
|
128
|
+
minValueMs,
|
|
129
|
+
onChange,
|
|
130
|
+
placeholder = "e.g. 1:02:03.500",
|
|
131
|
+
size = "md",
|
|
132
|
+
stepMs = 1_000,
|
|
133
|
+
valueMs,
|
|
134
|
+
...receivedSlotProps
|
|
135
|
+
}: TimecodeInputProps): ReactNode => {
|
|
136
|
+
const baseId = useUniqueId()
|
|
137
|
+
|
|
138
|
+
const minimum = minValueMs ?? 0
|
|
139
|
+
|
|
140
|
+
const maximum = maxValueMs ?? durationMs
|
|
141
|
+
|
|
142
|
+
const [seededRange] = useState(() =>
|
|
143
|
+
toSeededRange(valueMs),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
const [range, setRange] =
|
|
147
|
+
useState<TimecodeRange>(seededRange)
|
|
148
|
+
|
|
149
|
+
const toDisplayText = (milliseconds: null | number) =>
|
|
150
|
+
milliseconds === null
|
|
151
|
+
? ""
|
|
152
|
+
: formatTimecode(milliseconds)
|
|
153
|
+
|
|
154
|
+
const [texts, setTexts] = useState({
|
|
155
|
+
end: toDisplayText(seededRange.end),
|
|
156
|
+
start: toDisplayText(seededRange.start),
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
const [activeEndpoint, setActiveEndpoint] =
|
|
160
|
+
useState<Endpoint>("start")
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* A refusal the grammar cannot see, because it is about the
|
|
164
|
+
* **pair** rather than about either text: a zero-length section.
|
|
165
|
+
* Cleared by the next keystroke, so an edit always gets a fresh
|
|
166
|
+
* reading rather than an old complaint.
|
|
167
|
+
*/
|
|
168
|
+
const [rangeRefusal, setRangeRefusal] = useState<
|
|
169
|
+
null | string
|
|
170
|
+
>(null)
|
|
171
|
+
|
|
172
|
+
const parsed = parseTimecodeInput(texts[activeEndpoint])
|
|
173
|
+
|
|
174
|
+
const echoId = `${baseId}-echo`
|
|
175
|
+
|
|
176
|
+
const report = (nextRange: TimecodeRange) => {
|
|
177
|
+
setRange(nextRange)
|
|
178
|
+
|
|
179
|
+
if (isRange) {
|
|
180
|
+
onChange?.(
|
|
181
|
+
nextRange.end === null && nextRange.start === null
|
|
182
|
+
? null
|
|
183
|
+
: nextRange,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
onChange?.(nextRange.start)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const writeEndpoint = (
|
|
193
|
+
endpoint: Endpoint,
|
|
194
|
+
milliseconds: null | number,
|
|
195
|
+
) => {
|
|
196
|
+
if (!isRange) {
|
|
197
|
+
setTexts({
|
|
198
|
+
end: "",
|
|
199
|
+
start: toDisplayText(milliseconds),
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
setRangeRefusal(null)
|
|
203
|
+
|
|
204
|
+
report({ end: null, start: milliseconds })
|
|
205
|
+
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const nextRange = {
|
|
210
|
+
...range,
|
|
211
|
+
[endpoint]: milliseconds,
|
|
212
|
+
} as TimecodeRange
|
|
213
|
+
|
|
214
|
+
// Only a pair can be compared. An open start is the absence of a
|
|
215
|
+
// choice rather than zero, so a section that only says where to
|
|
216
|
+
// stop is clamped and accepted — and stays able to complain the
|
|
217
|
+
// moment a start arrives that really does sit after it.
|
|
218
|
+
const isPair =
|
|
219
|
+
nextRange.end !== null && nextRange.start !== null
|
|
220
|
+
|
|
221
|
+
if (isPair && nextRange.end === nextRange.start) {
|
|
222
|
+
setRangeRefusal(
|
|
223
|
+
`A section needs a length, and this one starts and ends at ${toDisplayText(milliseconds)}.`,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const isBackwards =
|
|
230
|
+
isPair &&
|
|
231
|
+
(nextRange.end ?? 0) < (nextRange.start ?? 0)
|
|
232
|
+
|
|
233
|
+
const settled = isBackwards
|
|
234
|
+
? { end: nextRange.start, start: nextRange.end }
|
|
235
|
+
: nextRange
|
|
236
|
+
|
|
237
|
+
setTexts(
|
|
238
|
+
isBackwards
|
|
239
|
+
? {
|
|
240
|
+
end: toDisplayText(settled.end),
|
|
241
|
+
start: toDisplayText(settled.start),
|
|
242
|
+
}
|
|
243
|
+
: (previous) => ({
|
|
244
|
+
...previous,
|
|
245
|
+
[endpoint]: toDisplayText(milliseconds),
|
|
246
|
+
}),
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
setRangeRefusal(null)
|
|
250
|
+
|
|
251
|
+
report(settled)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const commitText = (endpoint: Endpoint) => {
|
|
255
|
+
const result = parseTimecodeInput(texts[endpoint])
|
|
256
|
+
|
|
257
|
+
if (result.kind === "empty") {
|
|
258
|
+
writeEndpoint(endpoint, null)
|
|
259
|
+
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (result.kind === "unparsed") {
|
|
264
|
+
// The text stays exactly as typed. Clearing it throws away
|
|
265
|
+
// what the person has to edit; rewriting it to the last good
|
|
266
|
+
// value discards what they meant.
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
writeEndpoint(
|
|
271
|
+
endpoint,
|
|
272
|
+
clampTimecode(result.milliseconds, minimum, maximum),
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* ArrowUp and ArrowDown, which **do** commit.
|
|
278
|
+
*
|
|
279
|
+
* That is not an exception to "a keystroke never commits" — it is
|
|
280
|
+
* the other side of it. Typing is a draft the echo reports on;
|
|
281
|
+
* a step is a whole gesture on a value that already exists, the
|
|
282
|
+
* same act as clicking a day in `DatePicker`'s grid, and a
|
|
283
|
+
* stepper that needed Enter afterwards would be a stepper nobody
|
|
284
|
+
* pressed twice.
|
|
285
|
+
*/
|
|
286
|
+
const stepEndpoint = (
|
|
287
|
+
endpoint: Endpoint,
|
|
288
|
+
direction: -1 | 1,
|
|
289
|
+
isCoarse: boolean,
|
|
290
|
+
) => {
|
|
291
|
+
const result = parseTimecodeInput(texts[endpoint])
|
|
292
|
+
|
|
293
|
+
const base =
|
|
294
|
+
result.kind === "timecode"
|
|
295
|
+
? result.milliseconds
|
|
296
|
+
: (range[endpoint] ?? 0)
|
|
297
|
+
|
|
298
|
+
writeEndpoint(
|
|
299
|
+
endpoint,
|
|
300
|
+
clampTimecode(
|
|
301
|
+
base + stepMs * (isCoarse ? 10 : 1) * direction,
|
|
302
|
+
minimum,
|
|
303
|
+
maximum,
|
|
304
|
+
),
|
|
305
|
+
)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const handleKeyDown = (
|
|
309
|
+
keyEvent: ReactKeyboardEvent<HTMLInputElement>,
|
|
310
|
+
endpoint: Endpoint,
|
|
311
|
+
) => {
|
|
312
|
+
if (keyEvent.key === "Enter") {
|
|
313
|
+
keyEvent.preventDefault()
|
|
314
|
+
|
|
315
|
+
commitText(endpoint)
|
|
316
|
+
|
|
317
|
+
return
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (keyEvent.key === "Escape") {
|
|
321
|
+
keyEvent.preventDefault()
|
|
322
|
+
|
|
323
|
+
setRangeRefusal(null)
|
|
324
|
+
|
|
325
|
+
setTexts((previous) => ({
|
|
326
|
+
...previous,
|
|
327
|
+
[endpoint]: toDisplayText(range[endpoint]),
|
|
328
|
+
}))
|
|
329
|
+
|
|
330
|
+
return
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (
|
|
334
|
+
keyEvent.key === "ArrowUp" ||
|
|
335
|
+
keyEvent.key === "ArrowDown"
|
|
336
|
+
) {
|
|
337
|
+
keyEvent.preventDefault()
|
|
338
|
+
|
|
339
|
+
stepEndpoint(
|
|
340
|
+
endpoint,
|
|
341
|
+
keyEvent.key === "ArrowUp" ? 1 : -1,
|
|
342
|
+
keyEvent.shiftKey,
|
|
343
|
+
)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const clampedPreview =
|
|
348
|
+
parsed.kind === "timecode"
|
|
349
|
+
? clampTimecode(parsed.milliseconds, minimum, maximum)
|
|
350
|
+
: null
|
|
351
|
+
|
|
352
|
+
const parsedEcho =
|
|
353
|
+
parsed.kind === "unparsed"
|
|
354
|
+
? parsed.reason
|
|
355
|
+
: parsed.kind === "empty" || clampedPreview === null
|
|
356
|
+
? ""
|
|
357
|
+
: clampedPreview === parsed.milliseconds
|
|
358
|
+
? formatTimecode(parsed.milliseconds)
|
|
359
|
+
: `${formatTimecode(parsed.milliseconds)} is outside the media, so it commits as ${formatTimecode(clampedPreview)}.`
|
|
360
|
+
|
|
361
|
+
const echoText = rangeRefusal ?? parsedEcho
|
|
362
|
+
|
|
363
|
+
const isInvalid =
|
|
364
|
+
rangeRefusal !== null || parsed.kind === "unparsed"
|
|
365
|
+
|
|
366
|
+
const renderInput = (endpoint: Endpoint) => {
|
|
367
|
+
const ownProps = {
|
|
368
|
+
"aria-describedby": echoId,
|
|
369
|
+
"aria-invalid":
|
|
370
|
+
activeEndpoint === endpoint && isInvalid
|
|
371
|
+
? true
|
|
372
|
+
: undefined,
|
|
373
|
+
"aria-label": isRange
|
|
374
|
+
? `${label} ${endpoint}`
|
|
375
|
+
: label,
|
|
376
|
+
autoComplete: "off" as const,
|
|
377
|
+
className: toClassName(
|
|
378
|
+
INPUT_CLASS,
|
|
379
|
+
CONTROL_SIZE_CLASS[size],
|
|
380
|
+
FOCUS_RING_CLASS,
|
|
381
|
+
DISABLED_CLASS,
|
|
382
|
+
),
|
|
383
|
+
disabled: isDisabled,
|
|
384
|
+
// A `Field` above mints an `id` for its `<label htmlFor>` and
|
|
385
|
+
// clones it down. Preferring it over the generated one is what
|
|
386
|
+
// keeps that label pointing at something — the generated id is
|
|
387
|
+
// referenced by nothing here, so there is nothing to lose by
|
|
388
|
+
// standing aside for a caller's.
|
|
389
|
+
id:
|
|
390
|
+
endpoint === "start"
|
|
391
|
+
? (receivedSlotProps.id ?? `${baseId}-start`)
|
|
392
|
+
: `${baseId}-end`,
|
|
393
|
+
// A timecode is digits and two separators, so the numeric
|
|
394
|
+
// keypad is the right soft keyboard — and a bare seconds count
|
|
395
|
+
// is a first-class form of the grammar precisely because iOS
|
|
396
|
+
// gives that keypad no colon. `90` and `5400` are complete
|
|
397
|
+
// timecodes, so the phone case is answered by the grammar
|
|
398
|
+
// rather than by a keyboard the field cannot choose.
|
|
399
|
+
inputMode: "numeric" as const,
|
|
400
|
+
onBlur: () => {
|
|
401
|
+
commitText(endpoint)
|
|
402
|
+
},
|
|
403
|
+
onChange: (changeEvent: {
|
|
404
|
+
currentTarget: { value: string }
|
|
405
|
+
}) => {
|
|
406
|
+
// Read out of the event NOW. React nulls `currentTarget`
|
|
407
|
+
// once the handler returns, and a `setTexts` updater runs
|
|
408
|
+
// after that — so reaching for it inside the updater is a
|
|
409
|
+
// `Cannot read properties of null` at the next render.
|
|
410
|
+
const nextText = changeEvent.currentTarget.value
|
|
411
|
+
|
|
412
|
+
setRangeRefusal(null)
|
|
413
|
+
|
|
414
|
+
setTexts((previous) => ({
|
|
415
|
+
...previous,
|
|
416
|
+
[endpoint]: nextText,
|
|
417
|
+
}))
|
|
418
|
+
},
|
|
419
|
+
onFocus: () => {
|
|
420
|
+
setActiveEndpoint(endpoint)
|
|
421
|
+
},
|
|
422
|
+
onKeyDown: (
|
|
423
|
+
keyEvent: ReactKeyboardEvent<HTMLInputElement>,
|
|
424
|
+
) => {
|
|
425
|
+
handleKeyDown(keyEvent, endpoint)
|
|
426
|
+
},
|
|
427
|
+
placeholder,
|
|
428
|
+
type: "text" as const,
|
|
429
|
+
value: texts[endpoint],
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return (
|
|
433
|
+
<input
|
|
434
|
+
// A `Field` above clones `id`, `aria-describedby`,
|
|
435
|
+
// `aria-invalid` and `required` onto this component. They
|
|
436
|
+
// belong on the control, and only on the first one — a
|
|
437
|
+
// `<label for>` names one control.
|
|
438
|
+
{...(endpoint === "start"
|
|
439
|
+
? mergeSlotProps(receivedSlotProps, ownProps)
|
|
440
|
+
: ownProps)}
|
|
441
|
+
key={endpoint}
|
|
442
|
+
/>
|
|
443
|
+
)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return (
|
|
447
|
+
<div
|
|
448
|
+
className={toClassName(
|
|
449
|
+
"flex flex-col gap-1",
|
|
450
|
+
className,
|
|
451
|
+
)}
|
|
452
|
+
>
|
|
453
|
+
<div className="flex items-center gap-2">
|
|
454
|
+
{renderInput("start")}
|
|
455
|
+
|
|
456
|
+
{isRange ? (
|
|
457
|
+
<>
|
|
458
|
+
<span
|
|
459
|
+
aria-hidden="true"
|
|
460
|
+
className="shrink-0 text-content-secondary text-sm"
|
|
461
|
+
>
|
|
462
|
+
to
|
|
463
|
+
</span>
|
|
464
|
+
|
|
465
|
+
{renderInput("end")}
|
|
466
|
+
</>
|
|
467
|
+
) : null}
|
|
468
|
+
</div>
|
|
469
|
+
|
|
470
|
+
{/*
|
|
471
|
+
The anti-silent-guess mechanism, and the reason this field is
|
|
472
|
+
allowed to accept `90` at all. A live region, so a resolution
|
|
473
|
+
is announced while typing, and wired into the input's
|
|
474
|
+
`aria-describedby` so it is read again on focus. A grammar
|
|
475
|
+
this permissive without this line is a field that quietly
|
|
476
|
+
starts a clip a minute late.
|
|
477
|
+
*/}
|
|
478
|
+
<p
|
|
479
|
+
aria-live="polite"
|
|
480
|
+
className={toClassName(
|
|
481
|
+
"min-h-5 text-sm",
|
|
482
|
+
isInvalid
|
|
483
|
+
? "text-intent-danger-content"
|
|
484
|
+
: "text-content-secondary",
|
|
485
|
+
)}
|
|
486
|
+
id={echoId}
|
|
487
|
+
role="status"
|
|
488
|
+
>
|
|
489
|
+
{echoText}
|
|
490
|
+
</p>
|
|
491
|
+
</div>
|
|
492
|
+
)
|
|
493
|
+
}
|