@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.
- package/dist/ActionTiles/ActionTiles.d.ts +6 -4
- package/dist/ActionTiles/ActionTiles.d.ts.map +1 -1
- package/dist/ActionTiles/ActionTiles.js +13 -4
- package/dist/ActionTiles/ActionTiles.js.map +1 -1
- package/dist/PortraitTiles/PortraitTiles.d.ts +4 -2
- package/dist/PortraitTiles/PortraitTiles.d.ts.map +1 -1
- package/dist/PortraitTiles/PortraitTiles.js +2 -2
- package/dist/PortraitTiles/PortraitTiles.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 +8 -2
- package/src/ActionTiles/ActionTiles.stories.tsx +3 -2
- package/src/ActionTiles/ActionTiles.test.tsx +6 -5
- package/src/ActionTiles/ActionTiles.tsx +15 -8
- package/src/PortraitTiles/PortraitTiles.tsx +7 -5
- 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
|
@@ -4,7 +4,7 @@ import type {
|
|
|
4
4
|
} from "@charcuterie/tokens"
|
|
5
5
|
import {
|
|
6
6
|
CATEGORICAL_INDEX_COUNT,
|
|
7
|
-
|
|
7
|
+
CATEGORICAL_SEQUENCE,
|
|
8
8
|
} from "@charcuterie/tokens"
|
|
9
9
|
import type { CSSProperties, ReactNode } from "react"
|
|
10
10
|
|
|
@@ -39,8 +39,8 @@ export type ActionTileItem = {
|
|
|
39
39
|
* set — a destination that is red everywhere else in the app, a
|
|
40
40
|
* pair that must not drift apart when a third tile is inserted
|
|
41
41
|
* between them. An overridden tile keeps its hue and the rest go
|
|
42
|
-
* on walking
|
|
43
|
-
* naming all of them.
|
|
42
|
+
* on walking `CATEGORICAL_SEQUENCE` from their own positions, so
|
|
43
|
+
* naming one does not force naming all of them.
|
|
44
44
|
*
|
|
45
45
|
* Ignored when the set is `accent="none"`.
|
|
46
46
|
*/
|
|
@@ -157,15 +157,20 @@ const STACK_GAP_CLASS: Record<ControlSize, string> = {
|
|
|
157
157
|
* two-tile set the same colour twice. A tile set is short, ordered,
|
|
158
158
|
* and written out in the source; the eye reads it as a row, so the
|
|
159
159
|
* palette is walked as one.
|
|
160
|
+
*
|
|
161
|
+
* `CATEGORICAL_SEQUENCE`, not `CATEGORICAL_INDEXES`. The ring is
|
|
162
|
+
* hue-ordered for the swatch picker's sake, so walking it in order
|
|
163
|
+
* hands a two-tile set the two hardest colours in the family to
|
|
164
|
+
* tell apart — 34 degrees, the tightest pair the palette has. The
|
|
165
|
+
* sequence visits the same ten in an order that keeps every
|
|
166
|
+
* neighbour 105 degrees or more away.
|
|
160
167
|
*/
|
|
161
168
|
const getTileCategorical = (
|
|
162
169
|
item: ActionTileItem,
|
|
163
170
|
position: number,
|
|
164
171
|
): CategoricalIndex =>
|
|
165
172
|
item.categorical ??
|
|
166
|
-
|
|
167
|
-
position % CATEGORICAL_INDEX_COUNT
|
|
168
|
-
] as CategoricalIndex)
|
|
173
|
+
CATEGORICAL_SEQUENCE[position % CATEGORICAL_INDEX_COUNT]
|
|
169
174
|
|
|
170
175
|
/**
|
|
171
176
|
* A set of ACTIONS drawn as tiles — a bordered card carrying a
|
|
@@ -211,8 +216,10 @@ const getTileCategorical = (
|
|
|
211
216
|
* accent-edge pseudo-element so a tile and a card on the same page
|
|
212
217
|
* are the same bar rather than two that nearly match. Its icon takes
|
|
213
218
|
* the same hue, and the box hovers in it too. The hue comes from the
|
|
214
|
-
* ten-wide categorical palette,
|
|
215
|
-
*
|
|
219
|
+
* ten-wide categorical palette, walked in `CATEGORICAL_SEQUENCE`
|
|
220
|
+
* rather than 1..10 — the ring is hue-ordered for the swatch
|
|
221
|
+
* picker's sake, so taking it in order would hand a two-tile set
|
|
222
|
+
* red and orange. A set of eight needs no colour props at all. See
|
|
216
223
|
* [the tile paint record](../../../../docs/decisions/2026-09-02-an-action-tile-is-coloured-and-the-icon-sits-beside-the-name.md)
|
|
217
224
|
* for the four other paints that were drawn and rejected.
|
|
218
225
|
*
|
|
@@ -4,7 +4,7 @@ import type {
|
|
|
4
4
|
} from "@charcuterie/tokens"
|
|
5
5
|
import {
|
|
6
6
|
CATEGORICAL_INDEX_COUNT,
|
|
7
|
-
|
|
7
|
+
CATEGORICAL_SEQUENCE,
|
|
8
8
|
} from "@charcuterie/tokens"
|
|
9
9
|
import type { CSSProperties, ReactNode } from "react"
|
|
10
10
|
|
|
@@ -45,8 +45,10 @@ import { getPortraitColourProperties } from "./portraitColour.ts"
|
|
|
45
45
|
* palette hues cannot represent that, and swapping them in would
|
|
46
46
|
* have made the app disagree with the cards on the fridge.
|
|
47
47
|
*
|
|
48
|
-
* Neither is a default. A portrait that names no colour takes
|
|
49
|
-
*
|
|
48
|
+
* Neither is a default. A portrait that names no colour takes its
|
|
49
|
+
* position's entry in `CATEGORICAL_SEQUENCE` — the palette walked so
|
|
50
|
+
* neighbours contrast, rather than the hue-ordered ring, which would
|
|
51
|
+
* put red beside orange.
|
|
50
52
|
*/
|
|
51
53
|
type PortraitTileColour =
|
|
52
54
|
| { categorical?: CategoricalIndex; color?: never }
|
|
@@ -273,9 +275,9 @@ const getPortraitCategorical = (
|
|
|
273
275
|
): CategoricalIndex | null =>
|
|
274
276
|
item.color === undefined
|
|
275
277
|
? (item.categorical ??
|
|
276
|
-
|
|
278
|
+
CATEGORICAL_SEQUENCE[
|
|
277
279
|
position % CATEGORICAL_INDEX_COUNT
|
|
278
|
-
]
|
|
280
|
+
])
|
|
279
281
|
: null
|
|
280
282
|
|
|
281
283
|
/**
|
|
@@ -74,11 +74,27 @@ Three mechanisms keep a grammar that permissive from being dangerous:
|
|
|
74
74
|
the field, in a `role="status"` live region wired into the input's `aria-describedby`.
|
|
75
75
|
Commit is Enter or blur; a keystroke never commits.
|
|
76
76
|
|
|
77
|
+
The echo is a **reading of the focused field**, so it leaves when the focus does. A
|
|
78
|
+
restatement left under an idle control is an unlabelled timecode floating on the page, which
|
|
79
|
+
reads as a value rather than as a report on the one above it.
|
|
80
|
+
|
|
81
|
+
A **refusal** is the other case and it stays up, both kinds of it. The zero-length complaint
|
|
82
|
+
is written at the commit — which *is* the blur — so a rule that cleared it on blur would mean
|
|
83
|
+
it was never readable. And a text the grammar cannot read keeps `aria-invalid` on the
|
|
84
|
+
control, so the sentence saying why has to stay with it: an invalid field with no message is
|
|
85
|
+
worse than the stray line this removes. Emptying a live region announces nothing, so none of
|
|
86
|
+
this makes a screen reader chatty.
|
|
87
|
+
|
|
77
88
|
**The largest field present is unbounded; every smaller one is 0–59.** That one sentence is
|
|
78
89
|
every bound in the grammar. It is also why a bare number is seconds: seconds is the smallest
|
|
79
90
|
unit the field prints, so the reading that needs no explanation is the one where the typist
|
|
80
91
|
appended nothing.
|
|
81
92
|
|
|
93
|
+
**Milliseconds are optional to read as well as to type.** The grammar has always accepted
|
|
94
|
+
`1:01:00`, and the field now writes it back as `01:01:00` rather than as `01:01:00.000` — you
|
|
95
|
+
write 3, not 3.000. A real fraction still prints in full: `1:01:00.5` commits as
|
|
96
|
+
`01:01:00.500`, because a fraction pads on the right.
|
|
97
|
+
|
|
82
98
|
Refused by name: a negative, a seconds or minutes field of 60 or more with a larger field
|
|
83
99
|
beside it, more than three colon-separated parts, a fraction longer than three digits, and
|
|
84
100
|
anything the accepting rules do not match. A refused value leaves the text **exactly as
|
|
@@ -132,6 +148,25 @@ its text is refused — which are exactly the states the echo exists for. A spin
|
|
|
132
148
|
value is a widget that under-reports itself to a screen reader in its two most important
|
|
133
149
|
states.
|
|
134
150
|
|
|
151
|
+
## The Narrow View stacks the two fields
|
|
152
|
+
|
|
153
|
+
A section is `[start] to [end]` in a row while there is room for it. Below `--cq-sm` the two
|
|
154
|
+
fields **stack**, and the `to` is replaced by a "Start" caption over the first and an "End"
|
|
155
|
+
caption over the second. Two `hh:mm:ss.mmm` fields and a word between them fill a 390px modal
|
|
156
|
+
edge to edge with no slack, which is where this was reported from.
|
|
157
|
+
|
|
158
|
+
It is a **container** query, not a window breakpoint. The box that ran out of room is this
|
|
159
|
+
field's own, and a 390px dialog on a 2560px monitor is the Wide View by every media query
|
|
160
|
+
ever written. The cost of `container-type: inline-size` is the `contain: inline-size` that
|
|
161
|
+
rides with it, so `TimecodeInput` takes its width from its parent rather than from its
|
|
162
|
+
content — give it a definite inline size, as with every container-query component here.
|
|
163
|
+
|
|
164
|
+
The captions are `aria-hidden`, exactly as the `to` always was. Each input already carries
|
|
165
|
+
`aria-label="<label> start"` / `"<label> end"`, and a real `<label>` would name the start
|
|
166
|
+
control a second time and fight the `id` a `Field` above clones onto it. **The accessible
|
|
167
|
+
name of each control is identical in both layouts**, and `TimecodeInput.test.tsx` asserts it
|
|
168
|
+
at two container widths.
|
|
169
|
+
|
|
135
170
|
<Canvas of={TimecodeInputStories.Responsive} />
|
|
136
171
|
|
|
137
172
|
## The formatter is exported, because the fleet writes it repeatedly
|
|
@@ -143,13 +178,18 @@ whether the hour is shown and whether the minute is padded. A read-only surface
|
|
|
143
178
|
the exported one rather than spell a sixth.
|
|
144
179
|
|
|
145
180
|
```ts
|
|
146
|
-
formatTimecode(3_723_500)
|
|
147
|
-
formatTimecode(90_000, { isHoursShown: false })
|
|
148
|
-
formatTimecode(1_999, { millisecondDigits: 0 })
|
|
181
|
+
formatTimecode(3_723_500) // "01:02:03.500"
|
|
182
|
+
formatTimecode(90_000, { isHoursShown: false }) // "01:30.000"
|
|
183
|
+
formatTimecode(1_999, { millisecondDigits: 0 }) // "00:00:01" — truncates
|
|
184
|
+
formatTimecode(3_660_000, { millisecondDigits: "auto" }) // "01:01:00" — no fraction to print
|
|
185
|
+
formatTimecode(3_660_500, { millisecondDigits: "auto" }) // "01:01:00.500"
|
|
149
186
|
```
|
|
150
187
|
|
|
151
|
-
The default is the canonical `hh:mm:ss.mmm
|
|
152
|
-
|
|
153
|
-
|
|
188
|
+
The **default is the canonical `hh:mm:ss.mmm`** and it stays that way, because a published
|
|
189
|
+
export is read by callers no change here can see. `TimecodeInput` opts in to `"auto"` for
|
|
190
|
+
both the text it writes back and its own echo, so the two agree on the page.
|
|
191
|
+
|
|
192
|
+
Either way it is one spelling per value: a re-read of the field parses to the number that
|
|
193
|
+
produced it, and a screenshot of the same value is the same picture.
|
|
154
194
|
|
|
155
195
|
<Canvas of={TimecodeInputStories.Interactive} />
|
|
@@ -20,6 +20,12 @@ import { formatTimecode } from "./timecode.ts"
|
|
|
20
20
|
*/
|
|
21
21
|
const DURATION_MS = 2_712_000
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* The demo readout prints a committed value the way the field
|
|
25
|
+
* writes it — `millisecondDigits: "auto"` — so one position is one
|
|
26
|
+
* spelling everywhere on the page. `formatTimecode`'s own default is
|
|
27
|
+
* still the full `hh:mm:ss.mmm`, and the docs page shows both.
|
|
28
|
+
*/
|
|
23
29
|
const describeValue = (
|
|
24
30
|
value: null | number | TimecodeRange,
|
|
25
31
|
) => {
|
|
@@ -28,7 +34,9 @@ const describeValue = (
|
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
if (typeof value === "number") {
|
|
31
|
-
return formatTimecode(value
|
|
37
|
+
return formatTimecode(value, {
|
|
38
|
+
millisecondDigits: "auto",
|
|
39
|
+
})
|
|
32
40
|
}
|
|
33
41
|
|
|
34
42
|
// A section with neither end set is the same absence `onChange`
|
|
@@ -37,7 +45,7 @@ const describeValue = (
|
|
|
37
45
|
return "—"
|
|
38
46
|
}
|
|
39
47
|
|
|
40
|
-
return `${value.start === null ? "the beginning" : formatTimecode(value.start)} to ${value.end === null ? "the end" : formatTimecode(value.end)}`
|
|
48
|
+
return `${value.start === null ? "the beginning" : formatTimecode(value.start, { millisecondDigits: "auto" })} to ${value.end === null ? "the end" : formatTimecode(value.end, { millisecondDigits: "auto" })}`
|
|
41
49
|
}
|
|
42
50
|
|
|
43
51
|
const meta = {
|
|
@@ -97,6 +105,13 @@ const TimecodeHarness = ({
|
|
|
97
105
|
* it before anything commits — `90` is ninety seconds, `1:30` is the
|
|
98
106
|
* same position spelled the other way, `1:02:03.500` is the full
|
|
99
107
|
* form. Enter or blur commits; Escape puts the last value back.
|
|
108
|
+
*
|
|
109
|
+
* The line is a reading of the field, so it leaves when the focus
|
|
110
|
+
* does. A refusal stays up, because a control that is still marked
|
|
111
|
+
* invalid needs the sentence that says why.
|
|
112
|
+
*
|
|
113
|
+
* Milliseconds are optional both ways: `1:01:00` commits as
|
|
114
|
+
* `01:01:00`, not as `01:01:00.000`.
|
|
100
115
|
*/
|
|
101
116
|
export const Default: Story = {
|
|
102
117
|
args: { label: "Start at" },
|
|
@@ -257,9 +272,14 @@ export const AllStates: Story = {
|
|
|
257
272
|
}
|
|
258
273
|
|
|
259
274
|
/**
|
|
260
|
-
* The field at three container widths
|
|
261
|
-
*
|
|
262
|
-
*
|
|
275
|
+
* The field at three container widths, which is the only honest way
|
|
276
|
+
* to story a container query — the window never moves.
|
|
277
|
+
*
|
|
278
|
+
* Below `--cq-sm` the section becomes the **Narrow View**: the two
|
|
279
|
+
* fields stack, and the word `to` is replaced by a "Start" caption
|
|
280
|
+
* over the first and an "End" caption over the second. Two
|
|
281
|
+
* `hh:mm:ss.mmm` fields and a word between them fill a phone-width
|
|
282
|
+
* modal edge to edge, which is where this was reported from.
|
|
263
283
|
*/
|
|
264
284
|
export const Responsive: Story = {
|
|
265
285
|
args: { label: "Start at" },
|
|
@@ -286,7 +306,9 @@ export const Responsive: Story = {
|
|
|
286
306
|
*
|
|
287
307
|
* In the section below, type an end that sits before the start and
|
|
288
308
|
* the pair **swaps**; type an end equal to the start and it is
|
|
289
|
-
* **refused**, because a section with no length plays nothing.
|
|
309
|
+
* **refused**, because a section with no length plays nothing. The
|
|
310
|
+
* refusal is readable after the field has been left, which is when
|
|
311
|
+
* a commit-time complaint is written.
|
|
290
312
|
*/
|
|
291
313
|
export const Interactive: Story = {
|
|
292
314
|
args: { label: "Start at" },
|
|
@@ -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
|
+
})
|