@charcuterie/ui 3.33.0 → 4.0.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 (105) 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/Main/Main.d.ts +18 -19
  6. package/dist/Main/Main.d.ts.map +1 -1
  7. package/dist/Main/Main.js +21 -10
  8. package/dist/Main/Main.js.map +1 -1
  9. package/dist/Main/ScrollMemoryProvider.d.ts +38 -0
  10. package/dist/Main/ScrollMemoryProvider.d.ts.map +1 -0
  11. package/dist/Main/ScrollMemoryProvider.js +30 -0
  12. package/dist/Main/ScrollMemoryProvider.js.map +1 -0
  13. package/dist/Main/scrollMemory.d.ts +54 -28
  14. package/dist/Main/scrollMemory.d.ts.map +1 -1
  15. package/dist/Main/scrollMemory.js +81 -40
  16. package/dist/Main/scrollMemory.js.map +1 -1
  17. package/dist/Main/scrollMemoryContext.d.ts +18 -0
  18. package/dist/Main/scrollMemoryContext.d.ts.map +1 -0
  19. package/dist/Main/scrollMemoryContext.js +18 -0
  20. package/dist/Main/scrollMemoryContext.js.map +1 -0
  21. package/dist/Main/useScrollMemory.d.ts +20 -18
  22. package/dist/Main/useScrollMemory.d.ts.map +1 -1
  23. package/dist/Main/useScrollMemory.js +69 -42
  24. package/dist/Main/useScrollMemory.js.map +1 -1
  25. package/dist/RangeSlider/RangeSlider.d.ts +117 -0
  26. package/dist/RangeSlider/RangeSlider.d.ts.map +1 -0
  27. package/dist/RangeSlider/RangeSlider.js +242 -0
  28. package/dist/RangeSlider/RangeSlider.js.map +1 -0
  29. package/dist/RangeSlider/rangeSliderValue.d.ts +102 -0
  30. package/dist/RangeSlider/rangeSliderValue.d.ts.map +1 -0
  31. package/dist/RangeSlider/rangeSliderValue.js +116 -0
  32. package/dist/RangeSlider/rangeSliderValue.js.map +1 -0
  33. package/dist/SegmentedControl/SegmentedControl.d.ts +12 -1
  34. package/dist/SegmentedControl/SegmentedControl.d.ts.map +1 -1
  35. package/dist/SegmentedControl/SegmentedControl.js +3 -3
  36. package/dist/SegmentedControl/SegmentedControl.js.map +1 -1
  37. package/dist/SegmentedControl/SegmentedOption.d.ts +2 -1
  38. package/dist/SegmentedControl/SegmentedOption.d.ts.map +1 -1
  39. package/dist/SegmentedControl/SegmentedOption.js +7 -2
  40. package/dist/SegmentedControl/SegmentedOption.js.map +1 -1
  41. package/dist/Slider/Slider.d.ts +6 -1
  42. package/dist/Slider/Slider.d.ts.map +1 -1
  43. package/dist/Slider/Slider.js +8 -22
  44. package/dist/Slider/Slider.js.map +1 -1
  45. package/dist/TimecodeInput/TimecodeInput.d.ts +88 -0
  46. package/dist/TimecodeInput/TimecodeInput.d.ts.map +1 -0
  47. package/dist/TimecodeInput/TimecodeInput.js +258 -0
  48. package/dist/TimecodeInput/TimecodeInput.js.map +1 -0
  49. package/dist/TimecodeInput/timecode.d.ts +132 -0
  50. package/dist/TimecodeInput/timecode.d.ts.map +1 -0
  51. package/dist/TimecodeInput/timecode.js +246 -0
  52. package/dist/TimecodeInput/timecode.js.map +1 -0
  53. package/dist/index.d.ts +10 -0
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +4 -0
  56. package/dist/index.js.map +1 -1
  57. package/dist/reactRouter/ReactRouterAdapter.d.ts +43 -0
  58. package/dist/reactRouter/ReactRouterAdapter.d.ts.map +1 -0
  59. package/dist/reactRouter/ReactRouterAdapter.js +50 -0
  60. package/dist/reactRouter/ReactRouterAdapter.js.map +1 -0
  61. package/dist/reactRouter/index.d.ts +14 -8
  62. package/dist/reactRouter/index.d.ts.map +1 -1
  63. package/dist/reactRouter/index.js +13 -8
  64. package/dist/reactRouter/index.js.map +1 -1
  65. package/dist/sliderStyles.d.ts +39 -0
  66. package/dist/sliderStyles.d.ts.map +1 -0
  67. package/dist/sliderStyles.js +50 -0
  68. package/dist/sliderStyles.js.map +1 -0
  69. package/package.json +1 -1
  70. package/src/Board/Board.test.tsx +8 -0
  71. package/src/Board/Board.tsx +7 -1
  72. package/src/Main/Main.mdx +35 -7
  73. package/src/Main/Main.stories.tsx +129 -64
  74. package/src/Main/Main.test.tsx +58 -0
  75. package/src/Main/Main.tsx +20 -20
  76. package/src/Main/ScrollMemoryProvider.tsx +48 -0
  77. package/src/Main/scrollMemory.test.ts +70 -16
  78. package/src/Main/scrollMemory.ts +98 -40
  79. package/src/Main/scrollMemoryContext.ts +21 -0
  80. package/src/Main/useScrollMemory.ts +83 -43
  81. package/src/RangeSlider/RangeSlider.mdx +85 -0
  82. package/src/RangeSlider/RangeSlider.stories.tsx +346 -0
  83. package/src/RangeSlider/RangeSlider.test.tsx +452 -0
  84. package/src/RangeSlider/RangeSlider.tsx +595 -0
  85. package/src/RangeSlider/rangeSliderValue.test.ts +217 -0
  86. package/src/RangeSlider/rangeSliderValue.ts +185 -0
  87. package/src/RouterLink/RouterLink.mdx +53 -16
  88. package/src/SegmentedControl/SegmentedControl.stories.tsx +50 -0
  89. package/src/SegmentedControl/SegmentedControl.test.tsx +36 -1
  90. package/src/SegmentedControl/SegmentedControl.tsx +14 -0
  91. package/src/SegmentedControl/SegmentedOption.tsx +7 -0
  92. package/src/Slider/Slider.tsx +23 -29
  93. package/src/TimecodeInput/TimecodeInput.mdx +155 -0
  94. package/src/TimecodeInput/TimecodeInput.stories.tsx +304 -0
  95. package/src/TimecodeInput/TimecodeInput.test.tsx +279 -0
  96. package/src/TimecodeInput/TimecodeInput.tsx +493 -0
  97. package/src/TimecodeInput/timecode.test.ts +236 -0
  98. package/src/TimecodeInput/timecode.ts +391 -0
  99. package/src/index.ts +21 -0
  100. package/src/reactRouter/ReactRouterAdapter.stories.tsx +120 -0
  101. package/src/reactRouter/ReactRouterAdapter.test.tsx +92 -0
  102. package/src/reactRouter/ReactRouterAdapter.tsx +66 -0
  103. package/src/reactRouter/index.ts +14 -8
  104. package/src/sliderStyles.ts +69 -0
  105. package/src/sourceRules.test.ts +29 -3
@@ -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
+ }