@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,120 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+ import {
3
+ MemoryRouter,
4
+ Route,
5
+ Routes,
6
+ useNavigate,
7
+ } from "react-router"
8
+
9
+ import { Button } from "../Button/Button.tsx"
10
+ import { Card } from "../Card/Card.tsx"
11
+ import { Header } from "../Header/Header.tsx"
12
+ import { Main } from "../Main/Main.tsx"
13
+ import { Shell } from "../Shell/Shell.tsx"
14
+ import { TextLink } from "../TextLink/TextLink.tsx"
15
+ import { ReactRouterAdapter } from "./ReactRouterAdapter.tsx"
16
+
17
+ /**
18
+ * The one component an app on react-router renders at its root.
19
+ *
20
+ * `Main.stories.tsx` proves the scroll memory against a hand-rolled
21
+ * history stack, which is the right subject for the mechanism. This
22
+ * one proves the **wiring**: a real router, a real `useLocation()`,
23
+ * and both seams arriving from one component — the piece that ships
24
+ * broken silently, because a page whose links still work and whose
25
+ * list still scrolls looks fine.
26
+ *
27
+ * `MemoryRouter` rather than `BrowserRouter`, so the story does not
28
+ * write to the Storybook page's own history.
29
+ */
30
+ const meta = {
31
+ component: ReactRouterAdapter,
32
+ parameters: {
33
+ layout: "fullscreen",
34
+ },
35
+ title: "Components/Layout/ReactRouterAdapter",
36
+ } satisfies Meta<typeof ReactRouterAdapter>
37
+
38
+ export default meta
39
+
40
+ type Story = StoryObj<typeof meta>
41
+
42
+ const EPISODE_TITLES = Array.from(
43
+ { length: 40 },
44
+ (_unused, index) => `Episode ${index + 1}`,
45
+ )
46
+
47
+ const BackControl = () => {
48
+ const navigate = useNavigate()
49
+
50
+ return (
51
+ <Button
52
+ appearance="outline"
53
+ onClick={() => {
54
+ void navigate(-1)
55
+ }}
56
+ size="sm"
57
+ >
58
+ Back
59
+ </Button>
60
+ )
61
+ }
62
+
63
+ const EpisodeList = () => (
64
+ <Main>
65
+ <div className="flex flex-col gap-3">
66
+ {EPISODE_TITLES.map((title) => (
67
+ <Card heading={title} key={title} padding="sm">
68
+ <TextLink href={`/library/${title}`}>
69
+ Open {title}
70
+ </TextLink>
71
+ </Card>
72
+ ))}
73
+ </div>
74
+ </Main>
75
+ )
76
+
77
+ const EpisodePage = () => (
78
+ <Main>
79
+ <Card heading="Episode">
80
+ <p className="text-content-secondary text-sm">
81
+ Short enough that the scrollport collapses behind
82
+ it. Press Back.
83
+ </p>
84
+ </Card>
85
+ </Main>
86
+ )
87
+
88
+ /**
89
+ * Scroll the list, press a link, then press Back.
90
+ *
91
+ * Nothing on this page is told about scrolling or about the router.
92
+ * `TextLink` becomes a soft navigation and `Main` keeps the
93
+ * reader's place because `ReactRouterAdapter` is above them both.
94
+ */
95
+ export const Default: Story = {
96
+ render: () => (
97
+ <MemoryRouter initialEntries={["/library"]}>
98
+ <ReactRouterAdapter>
99
+ <Shell>
100
+ <Header
101
+ actions={<BackControl />}
102
+ heading="Library"
103
+ />
104
+
105
+ <Routes>
106
+ <Route
107
+ element={<EpisodeList />}
108
+ path="/library"
109
+ />
110
+
111
+ <Route
112
+ element={<EpisodePage />}
113
+ path="/library/:episode"
114
+ />
115
+ </Routes>
116
+ </Shell>
117
+ </ReactRouterAdapter>
118
+ </MemoryRouter>
119
+ ),
120
+ }
@@ -0,0 +1,92 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, userEvent, waitFor } from "storybook/test"
3
+ import { afterEach, test } from "vitest"
4
+
5
+ import { forgetScrollOffsets } from "../Main/scrollMemory.ts"
6
+ import { mountStory } from "../mountStory.testHelpers.ts"
7
+ import { expectAgentDrivable } from "../testing/index.ts"
8
+ import {
9
+ DESKTOP,
10
+ setViewport,
11
+ } from "../viewport.testHelpers.ts"
12
+ import * as stories from "./ReactRouterAdapter.stories.tsx"
13
+
14
+ const { Default } = composeStories(stories)
15
+
16
+ // The offsets are module state, so they outlive a mount. A test
17
+ // must not.
18
+ afterEach(() => {
19
+ forgetScrollOffsets()
20
+ })
21
+
22
+ /**
23
+ * The wiring, against a real router.
24
+ *
25
+ * `Main.test.tsx` proves the scroll mechanism against a hand-rolled
26
+ * history stack — the right subject for a `ResizeObserver` and a
27
+ * clamped offset. What it cannot prove is that the entry reaches
28
+ * `Main` at all when the value comes from `useLocation()` rather
29
+ * than from a story's own `useState`. That is the seam every app
30
+ * now depends on, and it is the kind that fails quietly: the links
31
+ * still navigate and the list still scrolls.
32
+ */
33
+ test("an app renders one component and gets both seams", async () => {
34
+ await setViewport(DESKTOP)
35
+
36
+ const { canvas } = await mountStory(Default)
37
+
38
+ const main = canvas.getByRole("main")
39
+
40
+ // The link seam: react-router's `<Link>` renders an `<a href>`,
41
+ // so this is also what an unwired app looks like — the assertion
42
+ // that separates them is the absence of a page load below.
43
+ const link = expectAgentDrivable(canvas, {
44
+ name: "Open Episode 12",
45
+ role: "link",
46
+ })
47
+
48
+ await expect(link).toHaveAttribute(
49
+ "href",
50
+ "/library/Episode 12",
51
+ )
52
+
53
+ main.scrollTop = 900
54
+
55
+ await waitFor(async () => {
56
+ await expect(main.scrollTop).toBe(900)
57
+ })
58
+
59
+ await new Promise((resolve) => {
60
+ globalThis.requestAnimationFrame(() => {
61
+ globalThis.requestAnimationFrame(resolve)
62
+ })
63
+ })
64
+
65
+ await userEvent.click(link)
66
+
67
+ // A soft navigation: the route changed inside the same document.
68
+ // A plain `<a>` would have left the story mounted and unchanged.
69
+ //
70
+ // `waitFor`, because the route commit is a state update and the
71
+ // click resolves before it. It passed locally and failed on CI
72
+ // for exactly that reason.
73
+ await waitFor(async () => {
74
+ await expect(
75
+ canvas.getByRole("heading", { name: "Episode" }),
76
+ ).toBeVisible()
77
+ })
78
+
79
+ await userEvent.click(
80
+ expectAgentDrivable(canvas, {
81
+ name: "Back",
82
+ role: "button",
83
+ }),
84
+ )
85
+
86
+ // The scroll seam, from the same one component.
87
+ await waitFor(async () => {
88
+ await expect(canvas.getByRole("main").scrollTop).toBe(
89
+ 900,
90
+ )
91
+ })
92
+ })
@@ -0,0 +1,66 @@
1
+ import type { ReactNode } from "react"
2
+ import { useLocation } from "react-router"
3
+
4
+ import { ScrollMemoryProvider } from "../Main/ScrollMemoryProvider.tsx"
5
+ import { RouterLinkProvider } from "../RouterLink/RouterLinkProvider.tsx"
6
+ import { ReactRouterLink } from "./ReactRouterLink.tsx"
7
+
8
+ export type ReactRouterAdapterProps = {
9
+ children?: ReactNode
10
+ }
11
+
12
+ /**
13
+ * Every seam this library has, wired to react-router, in one
14
+ * component at the app root.
15
+ *
16
+ * ```tsx
17
+ * <BrowserRouter>
18
+ * <ReactRouterAdapter>
19
+ * <Routes>…</Routes>
20
+ * </ReactRouterAdapter>
21
+ * </BrowserRouter>
22
+ * ```
23
+ *
24
+ * It must sit **inside** the router, because it reads
25
+ * `useLocation()`, and **outside** the routes, because what it
26
+ * provides belongs to the whole app.
27
+ *
28
+ * ## Why one component and not two providers
29
+ *
30
+ * There are two seams today — the link seam and the scroll memory —
31
+ * and the fleet proved that a per-app opt-in does not reach the
32
+ * fleet. `Main`'s scroll memory shipped as a `scrollKey` prop, one
33
+ * app wired it, and the other three kept losing the reader's place
34
+ * with the library sitting right there supporting it. A seam an app
35
+ * has to know about is a seam most apps will not have.
36
+ *
37
+ * So the root wiring is one name to remember, and a seam added later
38
+ * arrives in every app that already renders this on its next
39
+ * release. `RouterLinkProvider`, `ReactRouterLink` and
40
+ * `ScrollMemoryProvider` all stay exported for an app that wants one
41
+ * half, or that renders its own root for another reason.
42
+ *
43
+ * ## What it does NOT do
44
+ *
45
+ * It does not render a router, and it takes no route table. This
46
+ * library has no opinion about either, and an app that wants
47
+ * `createBrowserRouter` should keep it.
48
+ */
49
+ export const ReactRouterAdapter = ({
50
+ children,
51
+ }: ReactRouterAdapterProps): ReactNode => {
52
+ const location = useLocation()
53
+
54
+ return (
55
+ <RouterLinkProvider link={ReactRouterLink}>
56
+ <ScrollMemoryProvider
57
+ entry={{
58
+ key: location.key,
59
+ path: location.pathname,
60
+ }}
61
+ >
62
+ {children}
63
+ </ScrollMemoryProvider>
64
+ </RouterLinkProvider>
65
+ )
66
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * `@charcuterie/ui/react-router` — the react-router adapter for the
3
- * link seam.
2
+ * `@charcuterie/ui/react-router` — the react-router adapter for this
3
+ * library's seams.
4
4
  *
5
5
  * **A subpath export with an optional peer dependency**, and both
6
6
  * halves of that matter. The base package must stay router-free: the
@@ -13,17 +13,23 @@
13
13
  * asserts exactly that — the main entry is measured for what it
14
14
  * reaches, so this cannot leak into it by accident.
15
15
  *
16
- * The wiring is one provider at the app root; `Guides/Routing` in
16
+ * The wiring is one component at the app root; `Guides/Routing` in
17
17
  * Storybook is the copy-pasteable version.
18
18
  *
19
19
  * ```tsx
20
- * import { RouterLinkProvider } from "@charcuterie/ui"
21
- * import { ReactRouterLink } from "@charcuterie/ui/react-router"
20
+ * import { ReactRouterAdapter } from "@charcuterie/ui/react-router"
22
21
  *
23
- * <RouterLinkProvider link={ReactRouterLink}>
24
- * <RouterProvider router={router} />
25
- * </RouterLinkProvider>
22
+ * <BrowserRouter>
23
+ * <ReactRouterAdapter>
24
+ * <Routes>…</Routes>
25
+ * </ReactRouterAdapter>
26
+ * </BrowserRouter>
26
27
  * ```
28
+ *
29
+ * `ReactRouterLink` stays exported for an app that wires
30
+ * `RouterLinkProvider` itself.
27
31
  */
28
32
 
33
+ export type { ReactRouterAdapterProps } from "./ReactRouterAdapter.tsx"
34
+ export { ReactRouterAdapter } from "./ReactRouterAdapter.tsx"
29
35
  export { ReactRouterLink } from "./ReactRouterLink.tsx"
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The paint `Slider` and `RangeSlider` share.
3
+ *
4
+ * The two are separate components because their **focus models** are
5
+ * different — one `role="slider"` on the track against two of them in
6
+ * a `role="group"`, one tab stop against two
7
+ * ([decision](../../../docs/decisions/2026-09-01-a-two-thumb-range-is-its-own-component-and-shares-the-bar.md)).
8
+ * What is identical is the bar: a sunken rounded track, an intent
9
+ * fill inside it, and a round thumb pulled back by half its own
10
+ * width.
11
+ *
12
+ * Held here rather than copied for the reason `tileStyles.ts` exists:
13
+ * "the two look the same" spelled as two class strings is a promise
14
+ * that survives exactly one edit. `RangeSlider.test.tsx` compares the
15
+ * two thumbs as **computed** styles, so a class-name refactor cannot
16
+ * quietly break it either.
17
+ *
18
+ * No focus ring here. It is the one part of the bar the two do not
19
+ * share — `Slider` rings the track, because the track is the widget;
20
+ * `RangeSlider` rings each thumb, because each thumb is.
21
+ */
22
+
23
+ export type SliderSize = "lg" | "md" | "sm"
24
+
25
+ /** The bar itself. A `sm` bar is 2px; that is not a hit target. */
26
+ export const SLIDER_TRACK_SIZE_CLASS: Record<
27
+ SliderSize,
28
+ string
29
+ > = {
30
+ sm: "h-1",
31
+ md: "h-2",
32
+ lg: "h-3",
33
+ }
34
+
35
+ export const SLIDER_THUMB_SIZE_CLASS: Record<
36
+ SliderSize,
37
+ string
38
+ > = {
39
+ sm: "size-3",
40
+ md: "size-4",
41
+ lg: "size-5",
42
+ }
43
+
44
+ /** Half of each `SLIDER_THUMB_SIZE_CLASS`, as the inline-start pull-back. */
45
+ export const SLIDER_THUMB_OFFSET: Record<
46
+ SliderSize,
47
+ string
48
+ > = {
49
+ sm: "-0.375rem",
50
+ md: "-0.5rem",
51
+ lg: "-0.625rem",
52
+ }
53
+
54
+ /**
55
+ * The row the bar sits in, which is taller than the bar: a 2px track
56
+ * is not a pointer target, and a finger is 44px. The height comes
57
+ * from the shared control-size system, so `[data-density]` decides
58
+ * it — never `MIN_TOUCH_TARGET_CLASS`, which would make this control
59
+ * taller than the ones beside it at desktop density
60
+ * ([decision](../../../docs/decisions/2026-08-05-controls-share-one-height-no-per-component-touch-floor.md)).
61
+ */
62
+ export const SLIDER_ROW_CLASS =
63
+ "relative flex w-full touch-none cursor-pointer items-center rounded-full min-h-(--control-height-sm)"
64
+
65
+ export const SLIDER_TRACK_CLASS =
66
+ "w-full overflow-hidden rounded-full bg-surface-sunken"
67
+
68
+ export const SLIDER_THUMB_CLASS =
69
+ "absolute rounded-full border-2 border-surface-raised shadow-sm"
@@ -375,8 +375,10 @@ test("the barrel is the only place components are re-exported", async () => {
375
375
  // and `ButtonLink`, both a real `<a href>`, differing in paint
376
376
  // rather than semantics: +2 -> 38, on top of `AdaptiveGrid`'s 36.
377
377
  // `AnchorLink`,
378
- // `RouterLinkProvider`, and `ReactRouterLink` are the seam rather
379
- // than components anybody stories, and live in `RouterLink/` and
378
+ // `RouterLinkProvider`, `ReactRouterLink`, `ScrollMemoryProvider`
379
+ // and `ReactRouterAdapter` are the router seam rather
380
+ // than components anybody stories, and live in `RouterLink/`,
381
+ // `Main/` and
380
382
  // `reactRouter/` — outside this count by the `<Name>/<Name>.tsx`
381
383
  // rule, exactly as `Overlay/`'s parts are.
382
384
  // The unified app shell adds four: `Shell`, `Header`, `Rail`,
@@ -545,7 +547,31 @@ test("the barrel is the only place components are re-exported", async () => {
545
547
  // what it deliberately does not cover — a tile that navigates is
546
548
  // not a radio, and giving one `aria-checked` would be worse than
547
549
  // the paint it replaced.
548
- expect(componentNames.length).toBe(62)
550
+ //
551
+ // `TimecodeInput` — a typed media position, with an `isRange` mode
552
+ // for a section of a file — is +1 -> 63. `timecode.ts` is its
553
+ // member and stays out of this count by the `<Name>/<Name>.tsx`
554
+ // rule, while still being exported from the barrel: the fleet
555
+ // spells the *printing* half five times over (queuepilot twice,
556
+ // castkit, mux-magic twice) and a read-only surface needs the
557
+ // formatter without the field.
558
+ //
559
+ // `RangeSlider` is +1 -> 64. `Slider`'s own record said a two-thumb
560
+ // range is *"a different widget with its own focus model"*, and the
561
+ // focus model is exactly what makes it a component rather than an
562
+ // `isRange` mode: one `role="slider"` on the track becomes two of
563
+ // them on the thumbs inside a `role="group"`, and one tab stop
564
+ // becomes two. So this shares the ARITHMETIC (`sliderValue.ts`,
565
+ // called rather than copied) and the BAR (`sliderStyles.ts`, the
566
+ // same extraction `tileStyles.ts` is), and nothing else.
567
+ // `rangeSliderValue.ts` is its member and stays out of this count
568
+ // by the `<Name>/<Name>.tsx` rule. It lands beside `TimecodeInput`
569
+ // rather than instead of it: the two halves of QueuePilot's clip
570
+ // editor are a typed field and a dragged bar over one value, and
571
+ // `isRange` is right there and wrong here for a reason that is
572
+ // written down — a second text field is still a text field, while a
573
+ // second thumb cannot share one `aria-valuenow`.
574
+ expect(componentNames.length).toBe(64)
549
575
 
550
576
  for (const name of componentNames) {
551
577
  expect(barrel).toContain(`export { ${name} }`)