@charcuterie/ui 4.3.0 → 4.5.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/dist/ActionTiles/ActionTiles.d.ts +2 -1
- package/dist/ActionTiles/ActionTiles.d.ts.map +1 -1
- package/dist/ActionTiles/ActionTiles.js +4 -2
- package/dist/ActionTiles/ActionTiles.js.map +1 -1
- package/dist/TimecodeInput/TimecodeInput.d.ts +17 -0
- package/dist/TimecodeInput/TimecodeInput.d.ts.map +1 -1
- package/dist/TimecodeInput/TimecodeInput.js +80 -6
- package/dist/TimecodeInput/TimecodeInput.js.map +1 -1
- package/dist/TimecodeInput/timecode.d.ts +21 -8
- package/dist/TimecodeInput/timecode.d.ts.map +1 -1
- package/dist/TimecodeInput/timecode.js +15 -7
- package/dist/TimecodeInput/timecode.js.map +1 -1
- package/package.json +2 -2
- package/src/ActionTiles/ActionTiles.mdx +6 -0
- package/src/ActionTiles/ActionTiles.tsx +4 -2
- package/src/TimecodeInput/TimecodeInput.mdx +46 -6
- package/src/TimecodeInput/TimecodeInput.stories.tsx +28 -6
- package/src/TimecodeInput/TimecodeInput.test.tsx +255 -32
- package/src/TimecodeInput/TimecodeInput.tsx +130 -19
- package/src/TimecodeInput/timecode.test.ts +43 -0
- package/src/TimecodeInput/timecode.ts +30 -12
- package/src/sourceRules.test.ts +7 -0
|
@@ -7,7 +7,8 @@ import { mountStory } from "../mountStory.testHelpers.ts"
|
|
|
7
7
|
import { expectAgentDrivable } from "../testing/index.ts"
|
|
8
8
|
import * as stories from "./TimecodeInput.stories.tsx"
|
|
9
9
|
|
|
10
|
-
const { AllStates, Interactive } =
|
|
10
|
+
const { AllStates, Interactive, Responsive } =
|
|
11
|
+
composeStories(stories)
|
|
11
12
|
|
|
12
13
|
const mountInteractive = async () => {
|
|
13
14
|
const mounted = await mountStory(Interactive)
|
|
@@ -40,9 +41,7 @@ test("a typed position is echoed in full, and does NOT commit until Enter", asyn
|
|
|
40
41
|
// and the field says so in a live region before anything is
|
|
41
42
|
// stored.
|
|
42
43
|
await waitFor(() => {
|
|
43
|
-
expect(
|
|
44
|
-
canvas.getByText("00:01:30.000"),
|
|
45
|
-
).toBeInTheDocument()
|
|
44
|
+
expect(canvas.getByText("00:01:30")).toBeInTheDocument()
|
|
46
45
|
})
|
|
47
46
|
|
|
48
47
|
await expect(
|
|
@@ -53,13 +52,13 @@ test("a typed position is echoed in full, and does NOT commit until Enter", asyn
|
|
|
53
52
|
|
|
54
53
|
await waitFor(() => {
|
|
55
54
|
expect(
|
|
56
|
-
canvas.getByText("Chosen: 00:01:30
|
|
55
|
+
canvas.getByText("Chosen: 00:01:30"),
|
|
57
56
|
).toBeInTheDocument()
|
|
58
57
|
})
|
|
59
58
|
|
|
60
59
|
// Committed in the one canonical spelling, so a re-read of the
|
|
61
60
|
// field parses to the number that produced it.
|
|
62
|
-
await expect(single).toHaveValue("00:01:30
|
|
61
|
+
await expect(single).toHaveValue("00:01:30")
|
|
63
62
|
})
|
|
64
63
|
|
|
65
64
|
test("an overflowed field is refused by name, and the typed text survives", async () => {
|
|
@@ -101,7 +100,7 @@ test("Escape puts the last committed value back", async () => {
|
|
|
101
100
|
|
|
102
101
|
await userEvent.type(single, "90{Enter}")
|
|
103
102
|
|
|
104
|
-
await expect(single).toHaveValue("00:01:30
|
|
103
|
+
await expect(single).toHaveValue("00:01:30")
|
|
105
104
|
|
|
106
105
|
await userEvent.clear(single)
|
|
107
106
|
|
|
@@ -109,7 +108,7 @@ test("Escape puts the last committed value back", async () => {
|
|
|
109
108
|
|
|
110
109
|
await userEvent.keyboard("{Escape}")
|
|
111
110
|
|
|
112
|
-
await expect(single).toHaveValue("00:01:30
|
|
111
|
+
await expect(single).toHaveValue("00:01:30")
|
|
113
112
|
})
|
|
114
113
|
|
|
115
114
|
test("the arrows step by stepMs, and Shift multiplies that by ten", async () => {
|
|
@@ -123,24 +122,24 @@ test("the arrows step by stepMs, and Shift multiplies that by ten", async () =>
|
|
|
123
122
|
// than a draft the echo is reporting on.
|
|
124
123
|
await waitFor(() => {
|
|
125
124
|
expect(
|
|
126
|
-
canvas.getByText("Chosen: 00:00:01
|
|
125
|
+
canvas.getByText("Chosen: 00:00:01"),
|
|
127
126
|
).toBeInTheDocument()
|
|
128
127
|
})
|
|
129
128
|
|
|
130
129
|
await userEvent.keyboard("{Shift>}{ArrowUp}{/Shift}")
|
|
131
130
|
|
|
132
|
-
await expect(single).toHaveValue("00:00:11
|
|
131
|
+
await expect(single).toHaveValue("00:00:11")
|
|
133
132
|
|
|
134
133
|
await userEvent.keyboard("{ArrowDown}")
|
|
135
134
|
|
|
136
|
-
await expect(single).toHaveValue("00:00:10
|
|
135
|
+
await expect(single).toHaveValue("00:00:10")
|
|
137
136
|
|
|
138
137
|
// The floor is the start of the media, not a negative position.
|
|
139
138
|
await userEvent.keyboard(
|
|
140
139
|
"{Shift>}{ArrowDown}{ArrowDown}{/Shift}",
|
|
141
140
|
)
|
|
142
141
|
|
|
143
|
-
await expect(single).toHaveValue("00:00:00
|
|
142
|
+
await expect(single).toHaveValue("00:00:00")
|
|
144
143
|
})
|
|
145
144
|
|
|
146
145
|
test("a position past the end of the media says so, then commits clamped", async () => {
|
|
@@ -152,13 +151,13 @@ test("a position past the end of the media says so, then commits clamped", async
|
|
|
152
151
|
|
|
153
152
|
await waitFor(() => {
|
|
154
153
|
expect(
|
|
155
|
-
canvas.getByText(/commits as 00:45:12
|
|
154
|
+
canvas.getByText(/commits as 00:45:12/),
|
|
156
155
|
).toBeInTheDocument()
|
|
157
156
|
})
|
|
158
157
|
|
|
159
158
|
await userEvent.keyboard("{Enter}")
|
|
160
159
|
|
|
161
|
-
await expect(single).toHaveValue("00:45:12
|
|
160
|
+
await expect(single).toHaveValue("00:45:12")
|
|
162
161
|
})
|
|
163
162
|
|
|
164
163
|
test("an inverted section swaps, rather than throwing away the boundary just typed", async () => {
|
|
@@ -167,14 +166,12 @@ test("an inverted section swaps, rather than throwing away the boundary just typ
|
|
|
167
166
|
|
|
168
167
|
await userEvent.click(end)
|
|
169
168
|
|
|
170
|
-
// The seeded start is 00:05:00
|
|
169
|
+
// The seeded start is 00:05:00, so this end sits before it.
|
|
171
170
|
await userEvent.type(end, "1:00{Enter}")
|
|
172
171
|
|
|
173
172
|
await waitFor(() => {
|
|
174
173
|
expect(
|
|
175
|
-
canvas.getByText(
|
|
176
|
-
"Chosen: 00:01:00.000 to 00:05:00.000",
|
|
177
|
-
),
|
|
174
|
+
canvas.getByText("Chosen: 00:01:00 to 00:05:00"),
|
|
178
175
|
).toBeInTheDocument()
|
|
179
176
|
})
|
|
180
177
|
|
|
@@ -200,7 +197,7 @@ test("a zero-length section is refused by name, because it plays nothing", async
|
|
|
200
197
|
// The start is untouched and the end is still open — nothing was
|
|
201
198
|
// committed, and the text is still what was typed.
|
|
202
199
|
await expect(
|
|
203
|
-
canvas.getByText("Chosen: 00:05:00
|
|
200
|
+
canvas.getByText("Chosen: 00:05:00 to the end"),
|
|
204
201
|
).toBeInTheDocument()
|
|
205
202
|
|
|
206
203
|
await expect(end).toHaveValue("5:00")
|
|
@@ -217,9 +214,7 @@ test("both ends are independently optional, and clearing one leaves the other al
|
|
|
217
214
|
|
|
218
215
|
await waitFor(() => {
|
|
219
216
|
expect(
|
|
220
|
-
canvas.getByText(
|
|
221
|
-
"Chosen: 00:05:00.000 to 00:10:00.000",
|
|
222
|
-
),
|
|
217
|
+
canvas.getByText("Chosen: 00:05:00 to 00:10:00"),
|
|
223
218
|
).toBeInTheDocument()
|
|
224
219
|
})
|
|
225
220
|
|
|
@@ -231,9 +226,7 @@ test("both ends are independently optional, and clearing one leaves the other al
|
|
|
231
226
|
|
|
232
227
|
await waitFor(() => {
|
|
233
228
|
expect(
|
|
234
|
-
canvas.getByText(
|
|
235
|
-
"Chosen: the beginning to 00:10:00.000",
|
|
236
|
-
),
|
|
229
|
+
canvas.getByText("Chosen: the beginning to 00:10:00"),
|
|
237
230
|
).toBeInTheDocument()
|
|
238
231
|
})
|
|
239
232
|
})
|
|
@@ -260,20 +253,250 @@ test("the four states of a section all render as values, not as half-finished in
|
|
|
260
253
|
).toHaveLength(2)
|
|
261
254
|
|
|
262
255
|
await expect(
|
|
263
|
-
canvas.getByText("Chosen: 00:05:00
|
|
256
|
+
canvas.getByText("Chosen: 00:05:00 to the end"),
|
|
264
257
|
).toBeInTheDocument()
|
|
265
258
|
|
|
266
259
|
await expect(
|
|
267
|
-
canvas.getByText(
|
|
268
|
-
"Chosen: the beginning to 00:05:00.000",
|
|
269
|
-
),
|
|
260
|
+
canvas.getByText("Chosen: the beginning to 00:05:00"),
|
|
270
261
|
).toBeInTheDocument()
|
|
271
262
|
|
|
272
263
|
await expect(
|
|
273
|
-
canvas.getByText(
|
|
274
|
-
|
|
275
|
-
|
|
264
|
+
canvas.getByText("Chosen: 00:05:00 to 00:15:00"),
|
|
265
|
+
).toBeInTheDocument()
|
|
266
|
+
|
|
267
|
+
await expectNoAxeViolations(canvasElement)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
test("the echo is a reading of the focused field, so it leaves with the focus", async () => {
|
|
271
|
+
const { canvas, single } = await mountInteractive()
|
|
272
|
+
|
|
273
|
+
await userEvent.click(single)
|
|
274
|
+
|
|
275
|
+
await userEvent.type(single, "90")
|
|
276
|
+
|
|
277
|
+
await waitFor(() => {
|
|
278
|
+
expect(canvas.getByText("00:01:30")).toBeInTheDocument()
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
// Tab commits and takes the focus away. What is left underneath an
|
|
282
|
+
// idle field would be an unlabelled timecode, which reads as a
|
|
283
|
+
// stray value rather than as a report on the field above it.
|
|
284
|
+
await userEvent.tab()
|
|
285
|
+
|
|
286
|
+
await waitFor(() => {
|
|
287
|
+
expect(
|
|
288
|
+
canvas.queryByText("00:01:30"),
|
|
289
|
+
).not.toBeInTheDocument()
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
// The value itself is untouched by any of that: the line is a
|
|
293
|
+
// reading, not the state.
|
|
294
|
+
await expect(single).toHaveValue("00:01:30")
|
|
295
|
+
|
|
296
|
+
await expect(
|
|
297
|
+
canvas.getByText("Chosen: 00:01:30"),
|
|
298
|
+
).toBeInTheDocument()
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
test("an unparsed text keeps its refusal after the focus leaves, because the control is still invalid", async () => {
|
|
302
|
+
const { canvas, canvasElement, single } =
|
|
303
|
+
await mountInteractive()
|
|
304
|
+
|
|
305
|
+
await userEvent.click(single)
|
|
306
|
+
|
|
307
|
+
await userEvent.type(single, "1:90")
|
|
308
|
+
|
|
309
|
+
await userEvent.tab()
|
|
310
|
+
|
|
311
|
+
// `aria-invalid` survives the blur, so the sentence that says why
|
|
312
|
+
// has to survive it too. An invalid control with no message is
|
|
313
|
+
// worse than the stray line the test above removes.
|
|
314
|
+
await expect(single).toHaveAttribute(
|
|
315
|
+
"aria-invalid",
|
|
316
|
+
"true",
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
await expect(
|
|
320
|
+
canvas.getByText(/A minute has 60 seconds/),
|
|
276
321
|
).toBeInTheDocument()
|
|
277
322
|
|
|
323
|
+
await expect(single).toHaveValue("1:90")
|
|
324
|
+
|
|
278
325
|
await expectNoAxeViolations(canvasElement)
|
|
279
326
|
})
|
|
327
|
+
|
|
328
|
+
test("a zero-length refusal is written at the commit, so it survives the blur that wrote it", async () => {
|
|
329
|
+
const { canvas, canvasElement, end } =
|
|
330
|
+
await mountInteractive()
|
|
331
|
+
|
|
332
|
+
await userEvent.click(end)
|
|
333
|
+
|
|
334
|
+
// The seeded start is 00:05:00, so this end has no length. Blur is
|
|
335
|
+
// the commit here, which is the case that would have hidden the
|
|
336
|
+
// complaint the instant it was made.
|
|
337
|
+
await userEvent.type(end, "5:00")
|
|
338
|
+
|
|
339
|
+
await userEvent.tab()
|
|
340
|
+
|
|
341
|
+
await waitFor(() => {
|
|
342
|
+
expect(
|
|
343
|
+
canvas.getByText(/A section needs a length/),
|
|
344
|
+
).toBeInTheDocument()
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
await expect(end).toHaveAttribute("aria-invalid", "true")
|
|
348
|
+
|
|
349
|
+
await expectNoAxeViolations(canvasElement)
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* The container query, asserted rather than assumed — and the
|
|
354
|
+
* accessible name held identical across the two layouts it draws.
|
|
355
|
+
*
|
|
356
|
+
* The window never moves in this test. Each panel of the board is a
|
|
357
|
+
* fixed inline size around `--cq-sm`, which is the only honest way
|
|
358
|
+
* to exercise a query about the component's own box.
|
|
359
|
+
*/
|
|
360
|
+
test("the Narrow View stacks the two fields, and neither layout changes a control's name", async () => {
|
|
361
|
+
const { canvas, canvasElement } =
|
|
362
|
+
await mountStory(Responsive)
|
|
363
|
+
|
|
364
|
+
const narrowStart = expectAgentDrivable(canvas, {
|
|
365
|
+
name: "Section 15rem start",
|
|
366
|
+
role: "textbox",
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
const wideStart = expectAgentDrivable(canvas, {
|
|
370
|
+
name: "Section 34rem start",
|
|
371
|
+
role: "textbox",
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
expectAgentDrivable(canvas, {
|
|
375
|
+
name: "Section 15rem end",
|
|
376
|
+
role: "textbox",
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
expectAgentDrivable(canvas, {
|
|
380
|
+
name: "Section 34rem end",
|
|
381
|
+
role: "textbox",
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
const narrowRow = narrowStart.parentElement
|
|
385
|
+
?.parentElement as HTMLElement
|
|
386
|
+
|
|
387
|
+
const wideRow = wideStart.parentElement
|
|
388
|
+
?.parentElement as HTMLElement
|
|
389
|
+
|
|
390
|
+
await expect(
|
|
391
|
+
globalThis.getComputedStyle(narrowRow).flexDirection,
|
|
392
|
+
).toBe("column")
|
|
393
|
+
|
|
394
|
+
await expect(
|
|
395
|
+
globalThis.getComputedStyle(wideRow).flexDirection,
|
|
396
|
+
).toBe("row")
|
|
397
|
+
|
|
398
|
+
const [
|
|
399
|
+
narrowStartCaption,
|
|
400
|
+
narrowJoiner,
|
|
401
|
+
narrowEndCaption,
|
|
402
|
+
] = Array.from(
|
|
403
|
+
narrowRow.querySelectorAll<HTMLElement>(
|
|
404
|
+
":scope > * > span, :scope > span",
|
|
405
|
+
),
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
const [wideStartCaption, wideJoiner, wideEndCaption] =
|
|
409
|
+
Array.from(
|
|
410
|
+
wideRow.querySelectorAll<HTMLElement>(
|
|
411
|
+
":scope > * > span, :scope > span",
|
|
412
|
+
),
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
// Narrow: two captions, no joining word.
|
|
416
|
+
await expect(narrowStartCaption).toHaveTextContent(
|
|
417
|
+
"Start",
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
await expect(narrowEndCaption).toHaveTextContent("End")
|
|
421
|
+
|
|
422
|
+
await expect(
|
|
423
|
+
globalThis.getComputedStyle(
|
|
424
|
+
narrowStartCaption as HTMLElement,
|
|
425
|
+
).display,
|
|
426
|
+
).not.toBe("none")
|
|
427
|
+
|
|
428
|
+
await expect(
|
|
429
|
+
globalThis.getComputedStyle(narrowJoiner as HTMLElement)
|
|
430
|
+
.display,
|
|
431
|
+
).toBe("none")
|
|
432
|
+
|
|
433
|
+
// Wide: the joining word, no captions.
|
|
434
|
+
await expect(wideJoiner).toHaveTextContent("to")
|
|
435
|
+
|
|
436
|
+
await expect(
|
|
437
|
+
globalThis.getComputedStyle(wideJoiner as HTMLElement)
|
|
438
|
+
.display,
|
|
439
|
+
).not.toBe("none")
|
|
440
|
+
|
|
441
|
+
await expect(
|
|
442
|
+
globalThis.getComputedStyle(
|
|
443
|
+
wideStartCaption as HTMLElement,
|
|
444
|
+
).display,
|
|
445
|
+
).toBe("none")
|
|
446
|
+
|
|
447
|
+
await expect(
|
|
448
|
+
globalThis.getComputedStyle(
|
|
449
|
+
wideEndCaption as HTMLElement,
|
|
450
|
+
).display,
|
|
451
|
+
).toBe("none")
|
|
452
|
+
|
|
453
|
+
// Every one of them is for the eye. A real `<label>` would name the
|
|
454
|
+
// start control a second time and fight the `id` a `Field` clones
|
|
455
|
+
// onto it, so nothing here is named "Start" or "End".
|
|
456
|
+
for (const caption of [
|
|
457
|
+
narrowStartCaption,
|
|
458
|
+
narrowJoiner,
|
|
459
|
+
narrowEndCaption,
|
|
460
|
+
wideStartCaption,
|
|
461
|
+
wideJoiner,
|
|
462
|
+
wideEndCaption,
|
|
463
|
+
]) {
|
|
464
|
+
await expect(caption).toHaveAttribute(
|
|
465
|
+
"aria-hidden",
|
|
466
|
+
"true",
|
|
467
|
+
)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
await expect(
|
|
471
|
+
canvas.queryAllByRole("textbox", {
|
|
472
|
+
name: /^(Start|End)$/,
|
|
473
|
+
}),
|
|
474
|
+
).toHaveLength(0)
|
|
475
|
+
|
|
476
|
+
await expectNoAxeViolations(canvasElement)
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
test("milliseconds are optional to read as well as to type", async () => {
|
|
480
|
+
const { canvas, single } = await mountInteractive()
|
|
481
|
+
|
|
482
|
+
await userEvent.click(single)
|
|
483
|
+
|
|
484
|
+
// No fraction typed, so no fraction written back. You write 3, not
|
|
485
|
+
// 3.000.
|
|
486
|
+
await userEvent.type(single, "10:00{Enter}")
|
|
487
|
+
|
|
488
|
+
await expect(single).toHaveValue("00:10:00")
|
|
489
|
+
|
|
490
|
+
await waitFor(() => {
|
|
491
|
+
expect(
|
|
492
|
+
canvas.getByText("Chosen: 00:10:00"),
|
|
493
|
+
).toBeInTheDocument()
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
await userEvent.clear(single)
|
|
497
|
+
|
|
498
|
+
// A real fraction still prints in full, padded on the right.
|
|
499
|
+
await userEvent.type(single, "10:00.25{Enter}")
|
|
500
|
+
|
|
501
|
+
await expect(single).toHaveValue("00:10:00.250")
|
|
502
|
+
})
|
|
@@ -64,6 +64,21 @@ const INPUT_CLASS =
|
|
|
64
64
|
|
|
65
65
|
type Endpoint = "end" | "start"
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* How this field prints, everywhere it prints — the text it writes
|
|
69
|
+
* back on commit, and the echo that reports what a draft resolves
|
|
70
|
+
* to. The two have to agree, so they read the same constant.
|
|
71
|
+
*
|
|
72
|
+
* `"auto"` rather than the exported default of three digits: a
|
|
73
|
+
* person who types `1:01:00` and tabs away did not ask for
|
|
74
|
+
* `01:01:00.000`, any more than typing `3` asks for `3.000`. A real
|
|
75
|
+
* fraction still prints in full, and the grammar reads both back to
|
|
76
|
+
* the same number.
|
|
77
|
+
*/
|
|
78
|
+
const DISPLAY_FORMAT = {
|
|
79
|
+
millisecondDigits: "auto",
|
|
80
|
+
} as const
|
|
81
|
+
|
|
67
82
|
const toSeededRange = (
|
|
68
83
|
valueMs: number | TimecodeRange | undefined,
|
|
69
84
|
): TimecodeRange =>
|
|
@@ -117,6 +132,23 @@ const toSeededRange = (
|
|
|
117
132
|
* discard the boundary just typed. A **zero-length** section is
|
|
118
133
|
* refused by name: it plays nothing, so swapping would hide a typo
|
|
119
134
|
* behind a window that silently does not exist.
|
|
135
|
+
*
|
|
136
|
+
* ### The Narrow View stacks the two fields
|
|
137
|
+
*
|
|
138
|
+
* Below `--cq-sm` a section is two fields one above the other, under
|
|
139
|
+
* the captions "Start" and "End"; above it, the row with `to`
|
|
140
|
+
* between them. It is a **container** query, so a 390px modal on a
|
|
141
|
+
* 2560px monitor gets the stacked form that a window breakpoint
|
|
142
|
+
* would have denied it. The captions are `aria-hidden`, so the
|
|
143
|
+
* accessible name of each control is the same in both layouts.
|
|
144
|
+
*
|
|
145
|
+
* ### Milliseconds are optional to read as well as to type
|
|
146
|
+
*
|
|
147
|
+
* The grammar has always accepted `1:01:00`. The field now writes it
|
|
148
|
+
* back as `01:01:00` rather than as `01:01:00.000`, through
|
|
149
|
+
* `formatTimecode`'s `millisecondDigits: "auto"`, because a fraction
|
|
150
|
+
* nobody typed is not information. `1:01:00.5` still commits as
|
|
151
|
+
* `01:01:00.500`.
|
|
120
152
|
*/
|
|
121
153
|
export const TimecodeInput = ({
|
|
122
154
|
className,
|
|
@@ -149,16 +181,29 @@ export const TimecodeInput = ({
|
|
|
149
181
|
const toDisplayText = (milliseconds: null | number) =>
|
|
150
182
|
milliseconds === null
|
|
151
183
|
? ""
|
|
152
|
-
: formatTimecode(milliseconds)
|
|
184
|
+
: formatTimecode(milliseconds, DISPLAY_FORMAT)
|
|
153
185
|
|
|
154
186
|
const [texts, setTexts] = useState({
|
|
155
187
|
end: toDisplayText(seededRange.end),
|
|
156
188
|
start: toDisplayText(seededRange.start),
|
|
157
189
|
})
|
|
158
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Which endpoint the keyboard steps and the echo belong to. It
|
|
193
|
+
* SURVIVES blur, deliberately: a refusal has to keep naming the
|
|
194
|
+
* field it is about after the focus has gone somewhere else.
|
|
195
|
+
*/
|
|
159
196
|
const [activeEndpoint, setActiveEndpoint] =
|
|
160
197
|
useState<Endpoint>("start")
|
|
161
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Whether a field of this control holds the focus. Separate from
|
|
201
|
+
* `activeEndpoint` because the two answer different questions —
|
|
202
|
+
* *which* endpoint, and *whether anybody is typing* — and only the
|
|
203
|
+
* second one may hide the echo.
|
|
204
|
+
*/
|
|
205
|
+
const [isFocused, setIsFocused] = useState(false)
|
|
206
|
+
|
|
162
207
|
/**
|
|
163
208
|
* A refusal the grammar cannot see, because it is about the
|
|
164
209
|
* **pair** rather than about either text: a zero-length section.
|
|
@@ -355,14 +400,55 @@ export const TimecodeInput = ({
|
|
|
355
400
|
: parsed.kind === "empty" || clampedPreview === null
|
|
356
401
|
? ""
|
|
357
402
|
: clampedPreview === parsed.milliseconds
|
|
358
|
-
? formatTimecode(
|
|
359
|
-
|
|
403
|
+
? formatTimecode(
|
|
404
|
+
parsed.milliseconds,
|
|
405
|
+
DISPLAY_FORMAT,
|
|
406
|
+
)
|
|
407
|
+
: `${formatTimecode(parsed.milliseconds, DISPLAY_FORMAT)} is outside the media, so it commits as ${formatTimecode(clampedPreview, DISPLAY_FORMAT)}.`
|
|
360
408
|
|
|
361
|
-
|
|
409
|
+
/**
|
|
410
|
+
* A restatement is a reading of the field above it, so it leaves
|
|
411
|
+
* with the focus. Left up, an unlabelled `00:05:00` under an idle
|
|
412
|
+
* control reads as a stray value rather than as a report on one.
|
|
413
|
+
*
|
|
414
|
+
* A REFUSAL stays, and both kinds do. `rangeRefusal` is set on
|
|
415
|
+
* commit — which is exactly blur — so a rule that hid it on blur
|
|
416
|
+
* would mean it was never readable at all. And an unparsed text
|
|
417
|
+
* keeps `aria-invalid` on the control, so the sentence saying why
|
|
418
|
+
* has to stay with it: an invalid field with no message is worse
|
|
419
|
+
* than the stray line this removes.
|
|
420
|
+
*
|
|
421
|
+
* Emptying a live region announces nothing, so the blur costs a
|
|
422
|
+
* screen reader no chatter.
|
|
423
|
+
*/
|
|
424
|
+
const echoText =
|
|
425
|
+
rangeRefusal ??
|
|
426
|
+
(isFocused || parsed.kind === "unparsed"
|
|
427
|
+
? parsedEcho
|
|
428
|
+
: "")
|
|
362
429
|
|
|
363
430
|
const isInvalid =
|
|
364
431
|
rangeRefusal !== null || parsed.kind === "unparsed"
|
|
365
432
|
|
|
433
|
+
/**
|
|
434
|
+
* The Narrow View's caption over one field.
|
|
435
|
+
*
|
|
436
|
+
* Presentational, and `aria-hidden` for the same reason the word
|
|
437
|
+
* `to` is: each input already carries
|
|
438
|
+
* `aria-label="<label> <endpoint>"`, and a real `<label>` here
|
|
439
|
+
* would name the start control a second time and fight the `id` a
|
|
440
|
+
* `Field` above clones onto it. The eye gets a caption; the
|
|
441
|
+
* accessible name is the same in both layouts.
|
|
442
|
+
*/
|
|
443
|
+
const renderCaption = (text: string) => (
|
|
444
|
+
<span
|
|
445
|
+
aria-hidden="true"
|
|
446
|
+
className="text-content-secondary text-sm cq-sm:hidden"
|
|
447
|
+
>
|
|
448
|
+
{text}
|
|
449
|
+
</span>
|
|
450
|
+
)
|
|
451
|
+
|
|
366
452
|
const renderInput = (endpoint: Endpoint) => {
|
|
367
453
|
const ownProps = {
|
|
368
454
|
"aria-describedby": echoId,
|
|
@@ -398,6 +484,8 @@ export const TimecodeInput = ({
|
|
|
398
484
|
// rather than by a keyboard the field cannot choose.
|
|
399
485
|
inputMode: "numeric" as const,
|
|
400
486
|
onBlur: () => {
|
|
487
|
+
setIsFocused(false)
|
|
488
|
+
|
|
401
489
|
commitText(endpoint)
|
|
402
490
|
},
|
|
403
491
|
onChange: (changeEvent: {
|
|
@@ -417,6 +505,8 @@ export const TimecodeInput = ({
|
|
|
417
505
|
}))
|
|
418
506
|
},
|
|
419
507
|
onFocus: () => {
|
|
508
|
+
setIsFocused(true)
|
|
509
|
+
|
|
420
510
|
setActiveEndpoint(endpoint)
|
|
421
511
|
},
|
|
422
512
|
onKeyDown: (
|
|
@@ -444,28 +534,49 @@ export const TimecodeInput = ({
|
|
|
444
534
|
}
|
|
445
535
|
|
|
446
536
|
return (
|
|
537
|
+
// `@container` here, not on a wrapper: the box a caller sized is
|
|
538
|
+
// the box that either has room for two timecodes side by side or
|
|
539
|
+
// does not. A viewport breakpoint reads that backwards — a 390px
|
|
540
|
+
// modal on a 2560px monitor is a Wide View by the window and a
|
|
541
|
+
// Narrow View by every measurement that matters. The cost is the
|
|
542
|
+
// `contain: inline-size` that rides along with it, so this field
|
|
543
|
+
// takes its width from its parent rather than from its content:
|
|
544
|
+
// give it a definite inline size, as every container-query
|
|
545
|
+
// component in this library needs.
|
|
447
546
|
<div
|
|
448
547
|
className={toClassName(
|
|
449
|
-
"flex flex-col gap-1",
|
|
548
|
+
"@container flex flex-col gap-1",
|
|
450
549
|
className,
|
|
451
550
|
)}
|
|
452
551
|
>
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
552
|
+
{isRange ? (
|
|
553
|
+
// Below `--cq-sm` the two fields STACK, each under a caption
|
|
554
|
+
// of its own, because `hh:mm:ss.mmm` twice and the word
|
|
555
|
+
// between them fills a phone-width modal edge to edge with
|
|
556
|
+
// no slack left. Above it, the row is unchanged.
|
|
557
|
+
<div className="flex flex-col gap-2 cq-sm:flex-row cq-sm:items-center cq-sm:gap-2">
|
|
558
|
+
<div className="flex min-w-0 flex-col gap-1 cq-sm:flex-1">
|
|
559
|
+
{renderCaption("Start")}
|
|
560
|
+
|
|
561
|
+
{renderInput("start")}
|
|
562
|
+
</div>
|
|
563
|
+
|
|
564
|
+
<span
|
|
565
|
+
aria-hidden="true"
|
|
566
|
+
className="hidden shrink-0 text-content-secondary text-sm cq-sm:inline"
|
|
567
|
+
>
|
|
568
|
+
to
|
|
569
|
+
</span>
|
|
570
|
+
|
|
571
|
+
<div className="flex min-w-0 flex-col gap-1 cq-sm:flex-1">
|
|
572
|
+
{renderCaption("End")}
|
|
464
573
|
|
|
465
574
|
{renderInput("end")}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
575
|
+
</div>
|
|
576
|
+
</div>
|
|
577
|
+
) : (
|
|
578
|
+
renderInput("start")
|
|
579
|
+
)}
|
|
469
580
|
|
|
470
581
|
{/*
|
|
471
582
|
The anti-silent-guess mechanism, and the reason this field is
|
|
@@ -210,6 +210,49 @@ test("millisecondDigits truncates rather than rounding, because a position is no
|
|
|
210
210
|
).toBe("00:00:01")
|
|
211
211
|
})
|
|
212
212
|
|
|
213
|
+
test('millisecondDigits "auto" prints the fraction only when there is one', () => {
|
|
214
|
+
// The complaint this answers: typing `1:01:00` and tabbing away
|
|
215
|
+
// rewrote the field to `01:01:00.000`. You write 3, not 3.000.
|
|
216
|
+
expect(
|
|
217
|
+
formatTimecode(3_660_000, {
|
|
218
|
+
millisecondDigits: "auto",
|
|
219
|
+
}),
|
|
220
|
+
).toBe("01:01:00")
|
|
221
|
+
|
|
222
|
+
// A real fraction still prints in full, at the same three digits.
|
|
223
|
+
expect(
|
|
224
|
+
formatTimecode(3_660_500, {
|
|
225
|
+
millisecondDigits: "auto",
|
|
226
|
+
}),
|
|
227
|
+
).toBe("01:01:00.500")
|
|
228
|
+
|
|
229
|
+
expect(
|
|
230
|
+
formatTimecode(1, { millisecondDigits: "auto" }),
|
|
231
|
+
).toBe("00:00:00.001")
|
|
232
|
+
|
|
233
|
+
expect(
|
|
234
|
+
formatTimecode(0, { millisecondDigits: "auto" }),
|
|
235
|
+
).toBe("00:00:00")
|
|
236
|
+
|
|
237
|
+
// Both spellings read back to the number that produced them, so
|
|
238
|
+
// the shorter one costs the round trip nothing.
|
|
239
|
+
expect(parsedMilliseconds("01:01:00")).toBe(3_660_000)
|
|
240
|
+
expect(parsedMilliseconds("01:01:00.500")).toBe(3_660_500)
|
|
241
|
+
|
|
242
|
+
// It composes with the other option rather than replacing it.
|
|
243
|
+
expect(
|
|
244
|
+
formatTimecode(90_000, {
|
|
245
|
+
isHoursShown: false,
|
|
246
|
+
millisecondDigits: "auto",
|
|
247
|
+
}),
|
|
248
|
+
).toBe("01:30")
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
test("the DEFAULT is still three digits, because a published export is read by callers this change never saw", () => {
|
|
252
|
+
expect(formatTimecode(3_660_000)).toBe("01:01:00.000")
|
|
253
|
+
expect(formatTimecode(90_000)).toBe("00:01:30.000")
|
|
254
|
+
})
|
|
255
|
+
|
|
213
256
|
test("formatTimecode refuses to print a position that does not exist", () => {
|
|
214
257
|
expect(formatTimecode(-5_000)).toBe("00:00:00.000")
|
|
215
258
|
expect(formatTimecode(Number.NaN)).toBe("00:00:00.000")
|