@charcuterie/ui 4.2.1 → 4.4.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.
@@ -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(parsed.milliseconds)
359
- : `${formatTimecode(parsed.milliseconds)} is outside the media, so it commits as ${formatTimecode(clampedPreview)}.`
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
- const echoText = rangeRefusal ?? parsedEcho
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
- <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>
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
- ) : null}
468
- </div>
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")
@@ -90,8 +90,17 @@ export type FormatTimecodeOptions = {
90
90
  * unchanged because the largest field present is unbounded.
91
91
  */
92
92
  isHoursShown?: boolean
93
- /** `0` through `3`. `0` drops the fraction and its separator. */
94
- millisecondDigits?: number
93
+ /**
94
+ * `0` through `3`. `0` drops the fraction and its separator.
95
+ *
96
+ * `"auto"` prints the fraction only when there is one to print: a
97
+ * whole number of seconds comes back as `01:01:00`, and half a
98
+ * second more comes back as `01:01:00.500`. It is how a decimal
99
+ * number is written everywhere else — you write `3`, not `3.000` —
100
+ * and the grammar reads both spellings back to the number that
101
+ * produced them, because its fraction is optional.
102
+ */
103
+ millisecondDigits?: "auto" | number
95
104
  }
96
105
 
97
106
  const padTwo = (value: number) =>
@@ -105,12 +114,16 @@ const padTwo = (value: number) =>
105
114
  * because it has not reached second 2 yet. Rounding would print a
106
115
  * seek point the media has not played.
107
116
  *
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.
117
+ * The default is the canonical `hh:mm:ss.mmm`. `TimecodeInput` asks
118
+ * for `millisecondDigits: "auto"` instead, so a whole second is
119
+ * written back as `01:01:00` rather than as `01:01:00.000` — a
120
+ * person who typed no fraction did not ask for one, and both
121
+ * spellings re-read to the number that produced them.
122
+ *
123
+ * Either way it is ONE spelling per value, which is what the fleet's
124
+ * five printers never were: three of them hide the hour and two do
125
+ * not pad the minute, so the same position is a different picture in
126
+ * each app.
114
127
  */
115
128
  export const formatTimecode = (
116
129
  milliseconds: number,
@@ -123,10 +136,15 @@ export const formatTimecode = (
123
136
  ? Math.max(0, Math.floor(milliseconds))
124
137
  : 0
125
138
 
126
- const digits = Math.min(
127
- 3,
128
- Math.max(0, Math.floor(millisecondDigits)),
129
- )
139
+ const digits =
140
+ millisecondDigits === "auto"
141
+ ? total % MILLISECONDS_PER_SECOND === 0
142
+ ? 0
143
+ : 3
144
+ : Math.min(
145
+ 3,
146
+ Math.max(0, Math.floor(millisecondDigits)),
147
+ )
130
148
 
131
149
  const hours = isHoursShown
132
150
  ? Math.floor(total / MILLISECONDS_PER_HOUR)
@@ -251,6 +251,13 @@ test("a container-query component is never storied in a shrink-to-fit cell", ()
251
251
  // measured in Docket, before any of the steps contained
252
252
  // anything.
253
253
  "Stepper",
254
+ // `TimecodeInput` queries its own box to decide whether a section
255
+ // is `[start] to [end]` in a row or two captioned fields stacked.
256
+ // The width that decides it is a modal's, not a window's: the
257
+ // report came from a 390px dialog, and the same dialog on a
258
+ // 2560px monitor is the Wide View by every media query and the
259
+ // Narrow View by the only measurement that matters.
260
+ "TimecodeInput",
254
261
  ])
255
262
 
256
263
  // `StoryCell`s are never nested, so the lazy match really does