@charcuterie/ui 3.33.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/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/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/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/index.ts +18 -0
- package/src/sliderStyles.ts +69 -0
- package/src/sourceRules.test.ts +25 -1
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as TimecodeInputStories from "./TimecodeInput.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={TimecodeInputStories} />
|
|
6
|
+
|
|
7
|
+
# TimecodeInput
|
|
8
|
+
|
|
9
|
+
A text field for a position in a piece of media — `hh:mm:ss.mmm` — with an `isRange` mode
|
|
10
|
+
for a section of a file. The consumer that shaped it is **QueuePilot**, which plays only a
|
|
11
|
+
chosen part of a video
|
|
12
|
+
([decision](../../../../docs/decisions/2026-09-01-a-timecode-is-milliseconds-and-a-section-is-a-mode.md)).
|
|
13
|
+
|
|
14
|
+
<Canvas of={TimecodeInputStories.Default} />
|
|
15
|
+
|
|
16
|
+
## Props
|
|
17
|
+
|
|
18
|
+
<Controls />
|
|
19
|
+
|
|
20
|
+
## The value is milliseconds, and the props say so
|
|
21
|
+
|
|
22
|
+
`onChange` reports an integer count of milliseconds from the start of the file. Not a
|
|
23
|
+
`Date`, not a clock time, not a string. A media position is a **duration**, the only
|
|
24
|
+
arithmetic anybody does with it is subtraction, and a number is the type that supports it.
|
|
25
|
+
|
|
26
|
+
Every prop carrying one keeps the `Ms` suffix — `durationMs`, `maxValueMs`, `minValueMs`,
|
|
27
|
+
`stepMs`, `valueMs` — because a duration's unit is genuinely ambiguous and this fleet has
|
|
28
|
+
both seconds and milliseconds in use. A `*At` timestamp is the opposite case and takes no
|
|
29
|
+
suffix.
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
<TimecodeInput
|
|
33
|
+
durationMs={file.durationMs} // the default maximum
|
|
34
|
+
label="Start at"
|
|
35
|
+
onChange={(value) => { setStartMs(value) }} // number | null
|
|
36
|
+
valueMs={startMs} // seeds; Charcuterie owns it from then on
|
|
37
|
+
/>
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Why this is not `<input type="time">`
|
|
41
|
+
|
|
42
|
+
Two reasons, and neither is taste.
|
|
43
|
+
|
|
44
|
+
A `time` input's value is a wall-clock `HH:MM` string capped at 24 hours, its smallest step
|
|
45
|
+
is a second unless `step` is fractional, and it has no way to say "1.5 hours into a file" as
|
|
46
|
+
a duration rather than as half past one. A clip boundary is milliseconds; the platform
|
|
47
|
+
control cannot carry them.
|
|
48
|
+
|
|
49
|
+
And it is painted by the operating system, out of `::-webkit-datetime-edit-*`
|
|
50
|
+
pseudo-elements no token reaches inside — the same mechanism that deprecated the native
|
|
51
|
+
`Select` and that kept `Slider` off `<input type="range">`.
|
|
52
|
+
|
|
53
|
+
## Typing is the whole interface, so nothing guesses in silence
|
|
54
|
+
|
|
55
|
+
| Typed | Resolves to |
|
|
56
|
+
| --- | --- |
|
|
57
|
+
| `90` | `00:01:30.000` — a bare number is **seconds** |
|
|
58
|
+
| `1:30` | `00:01:30.000` |
|
|
59
|
+
| `90:00` | `01:30:00.000` — the largest field present is unbounded |
|
|
60
|
+
| `1:02:03` | `01:02:03.000` |
|
|
61
|
+
| `1:02:03.500`, `1:02:03,500` | `01:02:03.500` |
|
|
62
|
+
| `+90` | `00:01:30.000` — a leading `+` is tolerated |
|
|
63
|
+
|
|
64
|
+
Three mechanisms keep a grammar that permissive from being dangerous:
|
|
65
|
+
|
|
66
|
+
1. **The grammar is ordered and total.** Each rule is anchored on the *whole* input, the
|
|
67
|
+
first match wins, and no match is a stated failure. `1:` is not one minute.
|
|
68
|
+
2. **An overflow is a failure with a name, not a carry.** `1:90` comes back as *A minute
|
|
69
|
+
has 60 seconds, so "90" is not a seconds field. Type 02:30 to carry it, or 90 to mean 90
|
|
70
|
+
seconds.* Both legal spellings are offered and neither is taken. Carrying it silently
|
|
71
|
+
moves the position by a whole minute, which is the `1:60` trap mux-magic wrote down in
|
|
72
|
+
2023.
|
|
73
|
+
3. **Nothing commits without an echo.** The resolved position is rendered in full underneath
|
|
74
|
+
the field, in a `role="status"` live region wired into the input's `aria-describedby`.
|
|
75
|
+
Commit is Enter or blur; a keystroke never commits.
|
|
76
|
+
|
|
77
|
+
**The largest field present is unbounded; every smaller one is 0–59.** That one sentence is
|
|
78
|
+
every bound in the grammar. It is also why a bare number is seconds: seconds is the smallest
|
|
79
|
+
unit the field prints, so the reading that needs no explanation is the one where the typist
|
|
80
|
+
appended nothing.
|
|
81
|
+
|
|
82
|
+
Refused by name: a negative, a seconds or minutes field of 60 or more with a larger field
|
|
83
|
+
beside it, more than three colon-separated parts, a fraction longer than three digits, and
|
|
84
|
+
anything the accepting rules do not match. A refused value leaves the text **exactly as
|
|
85
|
+
typed**, marks the field invalid and says why.
|
|
86
|
+
|
|
87
|
+
<Canvas of={TimecodeInputStories.AllVariants} />
|
|
88
|
+
|
|
89
|
+
## A section is `isRange`, and both of its ends are optional
|
|
90
|
+
|
|
91
|
+
`{ end, start }` has four real states, not three plus an error:
|
|
92
|
+
|
|
93
|
+
| `start` | `end` | Means |
|
|
94
|
+
| --- | --- | --- |
|
|
95
|
+
| `null` | `null` | no window at all |
|
|
96
|
+
| set | `null` | from here to the end of the media |
|
|
97
|
+
| `null` | set | from the beginning, stopping here |
|
|
98
|
+
| set | set | the window between them |
|
|
99
|
+
|
|
100
|
+
So there is no `isEndOpen` prop — `null` already says it — and clearing one end never
|
|
101
|
+
touches the other. An open start is the **absence of a choice**, not zero, which is why a
|
|
102
|
+
half-open section is clamped but never compared.
|
|
103
|
+
|
|
104
|
+
Picking an end before the start **swaps**, following `DatePicker`: throwing the pair away
|
|
105
|
+
would discard the boundary just typed. An end **equal** to the start is **refused** by name,
|
|
106
|
+
which is where this parts company with a date range: a zero-length section plays nothing, so
|
|
107
|
+
swapping it would hide a typo behind a window that silently does not exist.
|
|
108
|
+
|
|
109
|
+
There is no `error` prop. `Field` and `FieldGroup` own the semantic error; this component
|
|
110
|
+
owns only the echo.
|
|
111
|
+
|
|
112
|
+
<Canvas of={TimecodeInputStories.AllStates} />
|
|
113
|
+
|
|
114
|
+
## Keyboard
|
|
115
|
+
|
|
116
|
+
| Key | Does |
|
|
117
|
+
| --- | --- |
|
|
118
|
+
| Enter | commits the typed text |
|
|
119
|
+
| Tab / blur | commits the typed text |
|
|
120
|
+
| Escape | puts the last committed value back |
|
|
121
|
+
| ArrowUp / ArrowDown | steps by `stepMs` (default 1000) and **commits** |
|
|
122
|
+
| Shift + ArrowUp / ArrowDown | steps by ten times `stepMs` |
|
|
123
|
+
|
|
124
|
+
A step commits, and that is not an exception to "a keystroke never commits" — it is the
|
|
125
|
+
other side of it. Typing is a draft the echo reports on; a step is a whole gesture on a
|
|
126
|
+
value that already exists, the same act as clicking a day in `DatePicker`'s calendar. A
|
|
127
|
+
stepper that needed Enter afterwards is a stepper nobody presses twice.
|
|
128
|
+
|
|
129
|
+
The control keeps the plain `textbox` role. It is **not** a `role="spinbutton"`: that role
|
|
130
|
+
promises an `aria-valuenow`, and this field has no value at all while it is empty or while
|
|
131
|
+
its text is refused — which are exactly the states the echo exists for. A spinbutton with no
|
|
132
|
+
value is a widget that under-reports itself to a screen reader in its two most important
|
|
133
|
+
states.
|
|
134
|
+
|
|
135
|
+
<Canvas of={TimecodeInputStories.Responsive} />
|
|
136
|
+
|
|
137
|
+
## The formatter is exported, because the fleet writes it repeatedly
|
|
138
|
+
|
|
139
|
+
`formatTimecode`, `parseTimecodeInput` and `clampTimecode` come out of the barrel. The
|
|
140
|
+
printing half is written five times across the fleet — queuepilot's `clock` and `toClock`,
|
|
141
|
+
castkit's `formatTime`, and mux-magic's two modal formatters — each with its own answer to
|
|
142
|
+
whether the hour is shown and whether the minute is padded. A read-only surface should call
|
|
143
|
+
the exported one rather than spell a sixth.
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
formatTimecode(3_723_500) // "01:02:03.500"
|
|
147
|
+
formatTimecode(90_000, { isHoursShown: false }) // "01:30.000"
|
|
148
|
+
formatTimecode(1_999, { millisecondDigits: 0 }) // "00:00:01" — truncates
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The default is the canonical `hh:mm:ss.mmm`, and it is what the field writes back on commit:
|
|
152
|
+
one spelling means a re-read of the field parses to the number that produced it, and a
|
|
153
|
+
screenshot of the same value is the same picture.
|
|
154
|
+
|
|
155
|
+
<Canvas of={TimecodeInputStories.Interactive} />
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
2
|
+
import type { ReactNode } from "react"
|
|
3
|
+
import { useState } from "react"
|
|
4
|
+
|
|
5
|
+
import { controlSizeArgType } from "../argTypes.storyHelpers.ts"
|
|
6
|
+
import {
|
|
7
|
+
ContainerBoard,
|
|
8
|
+
StoryCell,
|
|
9
|
+
StoryGrid,
|
|
10
|
+
StorySection,
|
|
11
|
+
} from "../board.storyHelpers.tsx"
|
|
12
|
+
import { Field } from "../Field/Field.tsx"
|
|
13
|
+
import { TimecodeInput } from "./TimecodeInput.tsx"
|
|
14
|
+
import type { TimecodeRange } from "./timecode.ts"
|
|
15
|
+
import { formatTimecode } from "./timecode.ts"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A made-up 45 minute 12 second file, so every demo reads the same
|
|
19
|
+
* numbers and a screenshot of one is the same picture next week.
|
|
20
|
+
*/
|
|
21
|
+
const DURATION_MS = 2_712_000
|
|
22
|
+
|
|
23
|
+
const describeValue = (
|
|
24
|
+
value: null | number | TimecodeRange,
|
|
25
|
+
) => {
|
|
26
|
+
if (value === null) {
|
|
27
|
+
return "—"
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (typeof value === "number") {
|
|
31
|
+
return formatTimecode(value)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A section with neither end set is the same absence `onChange`
|
|
35
|
+
// reports as `null`, so the demo reads it the same way.
|
|
36
|
+
if (value.end === null && value.start === null) {
|
|
37
|
+
return "—"
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return `${value.start === null ? "the beginning" : formatTimecode(value.start)} to ${value.end === null ? "the end" : formatTimecode(value.end)}`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const meta = {
|
|
44
|
+
title: "Components/Controls/TimecodeInput",
|
|
45
|
+
component: TimecodeInput,
|
|
46
|
+
args: {
|
|
47
|
+
durationMs: DURATION_MS,
|
|
48
|
+
isDisabled: false,
|
|
49
|
+
isRange: false,
|
|
50
|
+
label: "Start at",
|
|
51
|
+
size: "md",
|
|
52
|
+
stepMs: 1_000,
|
|
53
|
+
},
|
|
54
|
+
argTypes: {
|
|
55
|
+
size: controlSizeArgType,
|
|
56
|
+
},
|
|
57
|
+
parameters: { layout: "padded" },
|
|
58
|
+
} satisfies Meta<typeof TimecodeInput>
|
|
59
|
+
|
|
60
|
+
export default meta
|
|
61
|
+
|
|
62
|
+
type Story = StoryObj<typeof meta>
|
|
63
|
+
|
|
64
|
+
const TimecodeHarness = ({
|
|
65
|
+
...inputProps
|
|
66
|
+
}: {
|
|
67
|
+
durationMs?: number
|
|
68
|
+
isDisabled?: boolean
|
|
69
|
+
isRange?: boolean
|
|
70
|
+
label: string
|
|
71
|
+
maxValueMs?: number
|
|
72
|
+
minValueMs?: number
|
|
73
|
+
stepMs?: number
|
|
74
|
+
valueMs?: number | TimecodeRange
|
|
75
|
+
}): ReactNode => {
|
|
76
|
+
const [chosen, setChosen] = useState<
|
|
77
|
+
null | number | TimecodeRange
|
|
78
|
+
>(inputProps.valueMs ?? null)
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div className="flex flex-col gap-1">
|
|
82
|
+
<TimecodeInput
|
|
83
|
+
{...inputProps}
|
|
84
|
+
durationMs={inputProps.durationMs ?? DURATION_MS}
|
|
85
|
+
onChange={setChosen}
|
|
86
|
+
/>
|
|
87
|
+
|
|
88
|
+
<p className="text-content-muted text-xs">
|
|
89
|
+
Chosen: {describeValue(chosen)}
|
|
90
|
+
</p>
|
|
91
|
+
</div>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The field, empty. Type a position and the line underneath resolves
|
|
97
|
+
* it before anything commits — `90` is ninety seconds, `1:30` is the
|
|
98
|
+
* same position spelled the other way, `1:02:03.500` is the full
|
|
99
|
+
* form. Enter or blur commits; Escape puts the last value back.
|
|
100
|
+
*/
|
|
101
|
+
export const Default: Story = {
|
|
102
|
+
args: { label: "Start at" },
|
|
103
|
+
render: () => <TimecodeHarness label="Start at" />,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Single and section, a bounded window, a coarse step, and the field
|
|
108
|
+
* inside a `Field`.
|
|
109
|
+
*
|
|
110
|
+
* `isRange` is a **mode**, not a sibling component — the same call
|
|
111
|
+
* `DatePicker` made, and for the same reason: a section has no ARIA
|
|
112
|
+
* role of its own to be named after.
|
|
113
|
+
*/
|
|
114
|
+
export const AllVariants: Story = {
|
|
115
|
+
args: { label: "Start at" },
|
|
116
|
+
render: () => (
|
|
117
|
+
<StoryGrid columns={2}>
|
|
118
|
+
<StoryCell align="stretch" label="single (default)">
|
|
119
|
+
<TimecodeHarness label="Start at" />
|
|
120
|
+
</StoryCell>
|
|
121
|
+
|
|
122
|
+
<StoryCell align="stretch" label="isRange">
|
|
123
|
+
<TimecodeHarness isRange label="Play section" />
|
|
124
|
+
</StoryCell>
|
|
125
|
+
|
|
126
|
+
<StoryCell
|
|
127
|
+
align="stretch"
|
|
128
|
+
label="minValueMs / maxValueMs"
|
|
129
|
+
>
|
|
130
|
+
<TimecodeHarness
|
|
131
|
+
label="Skip the recap until"
|
|
132
|
+
maxValueMs={180_000}
|
|
133
|
+
minValueMs={30_000}
|
|
134
|
+
/>
|
|
135
|
+
</StoryCell>
|
|
136
|
+
|
|
137
|
+
<StoryCell align="stretch" label="stepMs={60000}">
|
|
138
|
+
<TimecodeHarness
|
|
139
|
+
label="Chapter mark"
|
|
140
|
+
stepMs={60_000}
|
|
141
|
+
valueMs={600_000}
|
|
142
|
+
/>
|
|
143
|
+
</StoryCell>
|
|
144
|
+
|
|
145
|
+
<StoryCell align="stretch" label="inside a Field">
|
|
146
|
+
<Field
|
|
147
|
+
description="Anything typeable: 90, 1:30, 1:02:03.500."
|
|
148
|
+
label="Resume from"
|
|
149
|
+
>
|
|
150
|
+
<TimecodeInput
|
|
151
|
+
durationMs={DURATION_MS}
|
|
152
|
+
label="Resume from"
|
|
153
|
+
/>
|
|
154
|
+
</Field>
|
|
155
|
+
</StoryCell>
|
|
156
|
+
|
|
157
|
+
<StoryCell align="stretch" label='size="lg"'>
|
|
158
|
+
<TimecodeInput
|
|
159
|
+
durationMs={DURATION_MS}
|
|
160
|
+
label="Large"
|
|
161
|
+
size="lg"
|
|
162
|
+
valueMs={62_500}
|
|
163
|
+
/>
|
|
164
|
+
</StoryCell>
|
|
165
|
+
</StoryGrid>
|
|
166
|
+
),
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Empty, filled, disabled — and the four states a section really
|
|
171
|
+
* has. Both ends are independently optional, so "from here to the
|
|
172
|
+
* end" and "from the beginning, stopping here" are values rather
|
|
173
|
+
* than half-finished input.
|
|
174
|
+
*/
|
|
175
|
+
export const AllStates: Story = {
|
|
176
|
+
args: { label: "Start at" },
|
|
177
|
+
render: () => (
|
|
178
|
+
<StorySection title="States">
|
|
179
|
+
<StoryGrid columns={2}>
|
|
180
|
+
<StoryCell align="stretch" label="empty">
|
|
181
|
+
<TimecodeHarness label="Empty" />
|
|
182
|
+
</StoryCell>
|
|
183
|
+
|
|
184
|
+
<StoryCell align="stretch" label="filled">
|
|
185
|
+
<TimecodeHarness
|
|
186
|
+
label="Filled"
|
|
187
|
+
valueMs={62_500}
|
|
188
|
+
/>
|
|
189
|
+
</StoryCell>
|
|
190
|
+
|
|
191
|
+
<StoryCell align="stretch" label="isDisabled">
|
|
192
|
+
<TimecodeHarness
|
|
193
|
+
isDisabled
|
|
194
|
+
label="Locked"
|
|
195
|
+
valueMs={62_500}
|
|
196
|
+
/>
|
|
197
|
+
</StoryCell>
|
|
198
|
+
|
|
199
|
+
<StoryCell
|
|
200
|
+
align="stretch"
|
|
201
|
+
label="section: no window"
|
|
202
|
+
>
|
|
203
|
+
<TimecodeHarness
|
|
204
|
+
isRange
|
|
205
|
+
label="No window"
|
|
206
|
+
valueMs={{ end: null, start: null }}
|
|
207
|
+
/>
|
|
208
|
+
</StoryCell>
|
|
209
|
+
|
|
210
|
+
<StoryCell
|
|
211
|
+
align="stretch"
|
|
212
|
+
label="section: open end"
|
|
213
|
+
>
|
|
214
|
+
<TimecodeHarness
|
|
215
|
+
isRange
|
|
216
|
+
label="From here on"
|
|
217
|
+
valueMs={{ end: null, start: 300_000 }}
|
|
218
|
+
/>
|
|
219
|
+
</StoryCell>
|
|
220
|
+
|
|
221
|
+
<StoryCell
|
|
222
|
+
align="stretch"
|
|
223
|
+
label="section: open start"
|
|
224
|
+
>
|
|
225
|
+
<TimecodeHarness
|
|
226
|
+
isRange
|
|
227
|
+
label="Up to here"
|
|
228
|
+
valueMs={{ end: 300_000, start: null }}
|
|
229
|
+
/>
|
|
230
|
+
</StoryCell>
|
|
231
|
+
|
|
232
|
+
<StoryCell align="stretch" label="section: both">
|
|
233
|
+
<TimecodeHarness
|
|
234
|
+
isRange
|
|
235
|
+
label="Between"
|
|
236
|
+
valueMs={{ end: 900_000, start: 300_000 }}
|
|
237
|
+
/>
|
|
238
|
+
</StoryCell>
|
|
239
|
+
|
|
240
|
+
<StoryCell
|
|
241
|
+
align="stretch"
|
|
242
|
+
label="error (type “1:90”)"
|
|
243
|
+
>
|
|
244
|
+
<Field
|
|
245
|
+
error="Pick a position the field can read."
|
|
246
|
+
label="Refused"
|
|
247
|
+
>
|
|
248
|
+
<TimecodeInput
|
|
249
|
+
durationMs={DURATION_MS}
|
|
250
|
+
label="Refused"
|
|
251
|
+
/>
|
|
252
|
+
</Field>
|
|
253
|
+
</StoryCell>
|
|
254
|
+
</StoryGrid>
|
|
255
|
+
</StorySection>
|
|
256
|
+
),
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The field at three container widths. Two inputs and a word between
|
|
261
|
+
* them is the layout that runs out of room first, so the section
|
|
262
|
+
* mode is the one worth looking at narrow.
|
|
263
|
+
*/
|
|
264
|
+
export const Responsive: Story = {
|
|
265
|
+
args: { label: "Start at" },
|
|
266
|
+
render: () => (
|
|
267
|
+
<ContainerBoard>
|
|
268
|
+
{(width) => (
|
|
269
|
+
<TimecodeInput
|
|
270
|
+
durationMs={DURATION_MS}
|
|
271
|
+
isRange
|
|
272
|
+
label={`Section ${width}`}
|
|
273
|
+
/>
|
|
274
|
+
)}
|
|
275
|
+
</ContainerBoard>
|
|
276
|
+
),
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The complete keyboard path.
|
|
281
|
+
*
|
|
282
|
+
* Tab to the field and type — the line underneath resolves what you
|
|
283
|
+
* typed, and nothing has committed. Enter or Tab commits. Escape
|
|
284
|
+
* puts the last committed value back. ArrowUp and ArrowDown step by
|
|
285
|
+
* `stepMs`, and Shift multiplies that by ten.
|
|
286
|
+
*
|
|
287
|
+
* In the section below, type an end that sits before the start and
|
|
288
|
+
* the pair **swaps**; type an end equal to the start and it is
|
|
289
|
+
* **refused**, because a section with no length plays nothing.
|
|
290
|
+
*/
|
|
291
|
+
export const Interactive: Story = {
|
|
292
|
+
args: { label: "Start at" },
|
|
293
|
+
render: () => (
|
|
294
|
+
<div className="flex flex-col gap-4">
|
|
295
|
+
<TimecodeHarness label="Start at" />
|
|
296
|
+
|
|
297
|
+
<TimecodeHarness
|
|
298
|
+
isRange
|
|
299
|
+
label="Play section"
|
|
300
|
+
valueMs={{ end: null, start: 300_000 }}
|
|
301
|
+
/>
|
|
302
|
+
</div>
|
|
303
|
+
),
|
|
304
|
+
}
|