@celestia-island/hikari 0.55.205 → 0.55.207
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/package.json +1 -1
- package/src/components/HkCrossfade.scss +9 -1
- package/src/components/HkCrossfade.test.tsx +35 -1
- package/src/components/HkCrossfade.tsx +31 -4
- package/src/components/HkFilterTrigger.test.tsx +7 -2
- package/src/components/HkFilterTrigger.tsx +11 -4
- package/src/components/HkIconButtonVars.scss +9 -8
- package/src/components/HkSearchTrigger.tsx +3 -1
- package/src/components/HkSegmentedRadio.scss +60 -0
- package/src/components/HkSegmentedRadio.test.tsx +259 -0
- package/src/components/HkSegmentedRadio.tsx +141 -0
- package/src/components/HkSettingsDialog.scss +43 -16
- package/src/components/HkSettingsDialog.test.tsx +77 -2
- package/src/components/HkSettingsDialog.tsx +28 -12
- package/src/components/HkThemeToggle.test.ts +8 -3
- package/src/components/HkThemeToggle.tsx +3 -1
- package/src/components/HkTransitionContainer.test.tsx +117 -2
- package/src/components/HkTransitionContainer.tsx +164 -11
- package/src/composables/useChromeActionSize.test.ts +65 -0
- package/src/composables/useChromeActionSize.ts +25 -0
- package/src/composables/useReportedTransition.ts +9 -3
- package/src/index.ts +3 -0
package/package.json
CHANGED
|
@@ -39,12 +39,20 @@
|
|
|
39
39
|
* the composite never dips toward the background (the classic
|
|
40
40
|
* double-exposure artifact of symmetric linear fades). The leaving item
|
|
41
41
|
* also drops pointer events: it is dead content and must not intercept
|
|
42
|
-
* clicks while it dissolves under the incoming one.
|
|
42
|
+
* clicks while it dissolves under the incoming one.
|
|
43
|
+
*
|
|
44
|
+
* The enter side additionally honours --hk-crossfade-enter-delay (default
|
|
45
|
+
* 0): the sequencing hook HkTransitionContainer sets per swap (2026-10-27
|
|
46
|
+
* user direction — a height-changing swap holds the new content at
|
|
47
|
+
* opacity 0 until the box is ready, so nothing is clipped by the morph).
|
|
48
|
+
* Declared as a LONGHAND after the shorthand: the `transition` shorthand
|
|
49
|
+
* resets transition-delay to 0, so the order here is load-bearing. */
|
|
43
50
|
.hk-crossfade-enter-active {
|
|
44
51
|
transition:
|
|
45
52
|
opacity
|
|
46
53
|
var(--duration-normal, 0.3s)
|
|
47
54
|
var(--ease-out-expo, cubic-bezier(0.19, 1, 0.22, 1));
|
|
55
|
+
transition-delay: var(--hk-crossfade-enter-delay, 0ms);
|
|
48
56
|
}
|
|
49
57
|
|
|
50
58
|
.hk-crossfade-leave-active {
|
|
@@ -30,6 +30,7 @@ function mountCrossfade(opts: {
|
|
|
30
30
|
initial?: string;
|
|
31
31
|
appear?: boolean;
|
|
32
32
|
disabled?: boolean;
|
|
33
|
+
enterDelay?: number;
|
|
33
34
|
} = {}): Harness {
|
|
34
35
|
const container = document.createElement("div");
|
|
35
36
|
document.body.appendChild(container);
|
|
@@ -45,6 +46,7 @@ function mountCrossfade(opts: {
|
|
|
45
46
|
swapKey: current.value,
|
|
46
47
|
appear: opts.appear ?? false,
|
|
47
48
|
disabled: opts.disabled ?? false,
|
|
49
|
+
enterDelay: opts.enterDelay ?? 0,
|
|
48
50
|
},
|
|
49
51
|
{
|
|
50
52
|
default: () => h("p", { class: "body" }, `body-${current.value}`),
|
|
@@ -181,6 +183,27 @@ describe("HkCrossfade — DOM contract", () => {
|
|
|
181
183
|
assertNoFrozenClasses(settled[0]!);
|
|
182
184
|
});
|
|
183
185
|
|
|
186
|
+
it("enterDelay widens the watchdog budget: a delayed enter mid-flight is not 'stalled'", async () => {
|
|
187
|
+
vi.useFakeTimers();
|
|
188
|
+
freezeRaf();
|
|
189
|
+
// 300ms choreographed hold: the enter fade starts at 300 and runs to
|
|
190
|
+
// 600, so the BASE budget (450) expires mid-enter — the sweep must
|
|
191
|
+
// NOT fire there, or it would strip a live transition's classes.
|
|
192
|
+
const DELAY = 300;
|
|
193
|
+
const { container, set } = mountCrossfade({ enterDelay: DELAY });
|
|
194
|
+
await set("b");
|
|
195
|
+
await vi.advanceTimersByTimeAsync(CROSSFADE_WATCHDOG_MS + 100); // past the base, inside the extended
|
|
196
|
+
// Both generations still held by the (rAF-frozen) engine: no sweep
|
|
197
|
+
// has stripped anything.
|
|
198
|
+
expect(keys(container)).toEqual(["a", "b"]);
|
|
199
|
+
// Past the extended budget the stall sweep bites as usual.
|
|
200
|
+
await vi.advanceTimersByTimeAsync(DELAY + 200);
|
|
201
|
+
const list = items(container);
|
|
202
|
+
expect(list).toHaveLength(1);
|
|
203
|
+
expect(list[0]!.dataset.crossfadeKey).toBe("b");
|
|
204
|
+
assertNoFrozenClasses(list[0]!);
|
|
205
|
+
});
|
|
206
|
+
|
|
184
207
|
it("appear dissolves the first item in; the watchdog un-freezes it under rAF starvation", async () => {
|
|
185
208
|
vi.useFakeTimers();
|
|
186
209
|
freezeRaf();
|
|
@@ -235,6 +258,15 @@ describe("HkCrossfade — stylesheet contract", () => {
|
|
|
235
258
|
const enter = css.match(/\.hk-crossfade-enter-active[^{]*\{[^}]*\}/)?.[0] ?? "";
|
|
236
259
|
expect(enter).toContain("transition: opacity");
|
|
237
260
|
expect(enter).toContain("var(--duration-normal, 0.3s)");
|
|
261
|
+
// The choreography hook (2026-10-27): the enter side honours the
|
|
262
|
+
// per-swap delay property — as a LONGHAND declared after the
|
|
263
|
+
// shorthand (the `transition` shorthand resets transition-delay, so
|
|
264
|
+
// the order in the rule is load-bearing).
|
|
265
|
+
const enterIdx = css.indexOf(".hk-crossfade-enter-active");
|
|
266
|
+
const delayIdx = css.indexOf("transition-delay: var(--hk-crossfade-enter-delay", enterIdx);
|
|
267
|
+
const shorthandIdx = css.indexOf("transition:", enterIdx);
|
|
268
|
+
expect(delayIdx).toBeGreaterThan(-1);
|
|
269
|
+
expect(delayIdx).toBeGreaterThan(shorthandIdx);
|
|
238
270
|
for (const phase of ["enter-from", "leave-to"]) {
|
|
239
271
|
const block = css.match(new RegExp(`\\.hk-crossfade-${phase}[^{]*\\{[^}]*\\}`))?.[0] ?? "";
|
|
240
272
|
expect(block, `hk-crossfade-${phase} rule missing`).toContain("opacity: 0");
|
|
@@ -267,7 +299,9 @@ describe("HkCrossfade — wiring contract", () => {
|
|
|
267
299
|
expect(src).toContain("export const CROSSFADE_WATCHDOG_MS = 450;");
|
|
268
300
|
expect(src).toContain("suppress.value = true;");
|
|
269
301
|
expect(src).toContain("el.remove();");
|
|
270
|
-
|
|
302
|
+
// The report window is a per-arm provider so a choreographed enter
|
|
303
|
+
// (delayed by the host) re-reads the window wide enough each arm.
|
|
304
|
+
expect(src).toContain("const report = useReportedTransition(reportWindow);");
|
|
271
305
|
expect(src).toContain("armReport(); armWatchdog();");
|
|
272
306
|
});
|
|
273
307
|
});
|
|
@@ -62,7 +62,20 @@ const REPORT_MS = CROSSFADE_WATCHDOG_MS;
|
|
|
62
62
|
* the sweep fires into an already-settled DOM and is a no-op. After a
|
|
63
63
|
* real stall it strips the frozen transition classes, removes the stale
|
|
64
64
|
* item Vue can no longer unmount, and degrades to synchronous rendering
|
|
65
|
-
* until the next `swapKey` change re-arms the Transition fresh.
|
|
65
|
+
* until the next `swapKey` change re-arms the Transition fresh. The
|
|
66
|
+
* budget scales by `enterDelay` (below) so a choreographed swap whose
|
|
67
|
+
* enter starts late never looks stalled.
|
|
68
|
+
* - `enterDelay` (ms, default 0) holds the ENTER side at opacity 0 for a
|
|
69
|
+
* beat before its fade runs — the sequencing hook HkTransitionContainer
|
|
70
|
+
* drives (2026-10-27 user direction: a height-changing swap fades the
|
|
71
|
+
* old content out first; the new content only fades in once the box is
|
|
72
|
+
* ready, so nothing is ever clipped by the animating size). The delay
|
|
73
|
+
* itself travels as the `--hk-crossfade-enter-delay` custom property on
|
|
74
|
+
* an ancestor (consumed by the enter-active rule), so it can be set per
|
|
75
|
+
* swap after the incoming content is measured; the prop exists so the
|
|
76
|
+
* watchdog and animation-bus windows KNOW the worst case and arm wide
|
|
77
|
+
* enough. Direct consumers who never touch the property keep the
|
|
78
|
+
* byte-identical concurrent dissolve.
|
|
66
79
|
*/
|
|
67
80
|
export default defineComponent({
|
|
68
81
|
name: "HkCrossfade",
|
|
@@ -71,6 +84,10 @@ export default defineComponent({
|
|
|
71
84
|
tag: { type: String, default: "div" },
|
|
72
85
|
appear: { type: Boolean, default: false },
|
|
73
86
|
disabled: { type: Boolean, default: false },
|
|
87
|
+
/** Enter-side hold (ms) the watchdog/report budgets must cover; the
|
|
88
|
+
* applied delay is the CSS custom property, this only widens the
|
|
89
|
+
* budgets. Keep it at the worst case the host can set. */
|
|
90
|
+
enterDelay: { type: Number, default: 0 },
|
|
74
91
|
},
|
|
75
92
|
setup(props, { slots }) {
|
|
76
93
|
const hostRef = ref<HTMLElement | null>(null);
|
|
@@ -83,8 +100,11 @@ export default defineComponent({
|
|
|
83
100
|
// ── Animation-bus participation ──────────────────────────────────
|
|
84
101
|
// One swap runs an enter AND a leave concurrently; refcount them so
|
|
85
102
|
// the shared track stays armed until BOTH settle (HkListTransition
|
|
86
|
-
// pattern).
|
|
87
|
-
|
|
103
|
+
// pattern). The window widens by the choreographed enter hold — read
|
|
104
|
+
// per arm, so a host that raises `enterDelay` mid-life is covered
|
|
105
|
+
// without a re-setup.
|
|
106
|
+
const reportWindow = () => REPORT_MS + Math.max(props.enterDelay, 0);
|
|
107
|
+
const report = useReportedTransition(reportWindow);
|
|
88
108
|
let pending = 0;
|
|
89
109
|
const armReport = () => {
|
|
90
110
|
pending++;
|
|
@@ -96,13 +116,20 @@ export default defineComponent({
|
|
|
96
116
|
};
|
|
97
117
|
|
|
98
118
|
// ── Swap-stall watchdog ──────────────────────────────────────────
|
|
119
|
+
/** Budget for one arm: the base sweep window plus the choreographed
|
|
120
|
+
* enter hold — a delayed enter is mid-flight, not stalled, when the
|
|
121
|
+
* base budget expires. */
|
|
122
|
+
function budget(): number {
|
|
123
|
+
return CROSSFADE_WATCHDOG_MS + Math.max(props.enterDelay, 0);
|
|
124
|
+
}
|
|
125
|
+
|
|
99
126
|
let watchdog: ReturnType<typeof setTimeout> | null = null;
|
|
100
127
|
|
|
101
128
|
function armWatchdog(): void {
|
|
102
129
|
// Re-arm per swap event (enter or leave start); a healthy swap lets
|
|
103
130
|
// the timer fire into a settled DOM, where the sweep is a no-op.
|
|
104
131
|
if (watchdog !== null) clearTimeout(watchdog);
|
|
105
|
-
watchdog = setTimeout(onWatchdog,
|
|
132
|
+
watchdog = setTimeout(onWatchdog, budget());
|
|
106
133
|
}
|
|
107
134
|
|
|
108
135
|
function sweepFrozenClasses(el: Element): boolean {
|
|
@@ -32,11 +32,16 @@ const triggerButton = (c: HTMLElement) =>
|
|
|
32
32
|
c.querySelector<HTMLButtonElement>(".hk-filter-trigger .hk-icon-button")!;
|
|
33
33
|
|
|
34
34
|
describe("HkFilterTrigger", () => {
|
|
35
|
-
it("renders the filter affordance
|
|
35
|
+
it("renders the filter affordance on the chrome action ladder (ghost class, ladder step)", () => {
|
|
36
36
|
const c = mount(h(HkFilterTrigger, { fields: FIELDS, modelValue: [] }));
|
|
37
37
|
const btn = triggerButton(c);
|
|
38
38
|
expect(btn).toBeTruthy();
|
|
39
|
-
|
|
39
|
+
// The ladder rung is viewport-driven (useChromeActionSize): the test
|
|
40
|
+
// environment's width lands on one of the two rungs — the contract
|
|
41
|
+
// is "the ladder's class", never a hard-coded step.
|
|
42
|
+
expect(
|
|
43
|
+
btn.className.includes("hk-icon-button-28") || btn.className.includes("hk-icon-button-32"),
|
|
44
|
+
).toBe(true);
|
|
40
45
|
expect(btn.className).toContain("hk-icon-button-ghost");
|
|
41
46
|
expect(btn.getAttribute("aria-haspopup")).toBe("dialog");
|
|
42
47
|
// No active dot on an untouched state.
|
|
@@ -12,12 +12,14 @@ import HkIconButton from "./HkIconButton";
|
|
|
12
12
|
import HkPopover, { type PopupPlacement } from "./HkPopover";
|
|
13
13
|
import "./HkFilterTrigger.scss";
|
|
14
14
|
import { useBreakpoint } from "../runtime/useBreakpoint";
|
|
15
|
+
import { useChromeActionSize } from "../composables/useChromeActionSize";
|
|
15
16
|
import { useI18n } from "../i18n/context";
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
|
-
* HkFilterTrigger — the header filter affordance: a ghost
|
|
19
|
-
*
|
|
20
|
-
*
|
|
19
|
+
* HkFilterTrigger — the header filter affordance: a ghost icon button on
|
|
20
|
+
* the chrome action ladder (useChromeActionSize: ghost/32 wide, ghost/28
|
|
21
|
+
* narrow — the same step as the global search and settings buttons, so
|
|
22
|
+
* the whole top-right cluster reads as one row of chrome) anchoring the
|
|
21
23
|
* HkFilterPanel in a popover on desktop and a bottom sheet on mobile
|
|
22
24
|
* (HkPopover sheetOnMobile).
|
|
23
25
|
*
|
|
@@ -50,6 +52,11 @@ export default defineComponent({
|
|
|
50
52
|
setup(props, { emit }) {
|
|
51
53
|
const { t } = useI18n();
|
|
52
54
|
const { isMobile } = useBreakpoint();
|
|
55
|
+
// The chrome ladder (2026-10-24 ruling, one source): wide screens
|
|
56
|
+
// wear ghost/32 like the header's search and settings buttons — a
|
|
57
|
+
// flat 28 here read as a different button family from its own
|
|
58
|
+
// neighbors on desktop (2026-10-27 report).
|
|
59
|
+
const chromeSize = useChromeActionSize();
|
|
53
60
|
const open = ref(false);
|
|
54
61
|
const anchorRef = ref<HTMLElement | null>(null);
|
|
55
62
|
|
|
@@ -63,7 +70,7 @@ export default defineComponent({
|
|
|
63
70
|
<span ref={anchorRef} class="hk-filter-trigger">
|
|
64
71
|
<HkCornerBadge dot={active.value} variant="success" corner="bottom-right" offset={1}>
|
|
65
72
|
<HkIconButton
|
|
66
|
-
size={
|
|
73
|
+
size={chromeSize.value}
|
|
67
74
|
variant="ghost"
|
|
68
75
|
aria-label={label}
|
|
69
76
|
aria-haspopup="dialog"
|
|
@@ -74,14 +74,15 @@
|
|
|
74
74
|
|
|
75
75
|
.hk-icon-button-28 {
|
|
76
76
|
--hi-icon-button-size: 28px;
|
|
77
|
-
/* The header-chrome step carries the sm glyph (16px): its
|
|
78
|
-
*
|
|
79
|
-
* trigger
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
77
|
+
/* The narrow header-chrome step carries the sm glyph (16px): its
|
|
78
|
+
* consumers are header clusters whose visual sibling is a compact
|
|
79
|
+
* trigger (one 28px box with a direct 16px lucide glyph). The xs
|
|
80
|
+
* mapping (12px) rendered those glyphs visibly smaller than the glyphs
|
|
81
|
+
* beside them (user report 2026-09-29: 不像一个尺寸的图标按钮) — the
|
|
82
|
+
* `.svg` rule in HkIconButton.scss forces ANY slotted glyph to this
|
|
83
|
+
* var, so the slot's size-16 attribute never reached the screen. The
|
|
84
|
+
* wide-rung sibling is the 32px block below (the 2026-10-24 chrome
|
|
85
|
+
* ladder; useChromeActionSize picks the rung). */
|
|
85
86
|
--hi-icon-button-icon-size: var(--hi-icon-size-sm);
|
|
86
87
|
}
|
|
87
88
|
|
|
@@ -8,6 +8,7 @@ import HkPopover, { type PopupPlacement } from "./HkPopover";
|
|
|
8
8
|
import HkSearchInput from "./HkSearchInput";
|
|
9
9
|
import { useBreakpoint } from "../runtime/useBreakpoint";
|
|
10
10
|
import { useI18n } from "../i18n/context";
|
|
11
|
+
import { useChromeActionSize } from "../composables/useChromeActionSize";
|
|
11
12
|
import "./HkSearchTrigger.scss";
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -56,6 +57,7 @@ const HkSearchTrigger = defineComponent({
|
|
|
56
57
|
},
|
|
57
58
|
setup(props, { emit }) {
|
|
58
59
|
const { t } = useI18n();
|
|
60
|
+
const chromeSize = useChromeActionSize();
|
|
59
61
|
const { isMobile } = useBreakpoint();
|
|
60
62
|
const open = ref(false);
|
|
61
63
|
const anchorRef = ref<HTMLElement | null>(null);
|
|
@@ -96,7 +98,7 @@ const HkSearchTrigger = defineComponent({
|
|
|
96
98
|
* (2026-10-15: was a hand-rolled primary dot, top-right). */}
|
|
97
99
|
<HkCornerBadge dot={active.value} variant="success" corner="bottom-right" offset={1}>
|
|
98
100
|
<HkIconButton
|
|
99
|
-
size={
|
|
101
|
+
size={chromeSize.value}
|
|
100
102
|
variant="ghost"
|
|
101
103
|
aria-label={label}
|
|
102
104
|
aria-haspopup="dialog"
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HkSegmentedRadio chrome — the single-choice joined strip. One chrome
|
|
3
|
+
* for every filter-popover option group: the two chest popovers this
|
|
4
|
+
* component converges styled their borders, paddings and hover tints
|
|
5
|
+
* independently and drifted apart within one release week. Tokens only
|
|
6
|
+
* (hikari vars with px fallbacks), per the component SCSS convention.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
.hk-segmented-radio {
|
|
10
|
+
display: flex;
|
|
11
|
+
width: 100%;
|
|
12
|
+
overflow: hidden;
|
|
13
|
+
border: 1px solid rgb(var(--color-border));
|
|
14
|
+
border-radius: var(--radius-sm, 4px);
|
|
15
|
+
|
|
16
|
+
&[data-disabled] {
|
|
17
|
+
opacity: 0.5;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.hk-segmented-radio-option {
|
|
22
|
+
flex: 1 1 0;
|
|
23
|
+
min-width: 0;
|
|
24
|
+
appearance: none;
|
|
25
|
+
border: 0;
|
|
26
|
+
border-inline-end: 1px solid rgb(var(--color-border));
|
|
27
|
+
padding: var(--space-6, 0.375rem) var(--space-10, 10px);
|
|
28
|
+
background: transparent;
|
|
29
|
+
color: rgb(var(--color-text) / 78%);
|
|
30
|
+
font-size: var(--text-xs, 12px);
|
|
31
|
+
line-height: 1.4;
|
|
32
|
+
white-space: nowrap;
|
|
33
|
+
cursor: pointer;
|
|
34
|
+
transition:
|
|
35
|
+
background var(--duration-short, 0.15s),
|
|
36
|
+
color var(--duration-short, 0.15s);
|
|
37
|
+
|
|
38
|
+
&:last-child {
|
|
39
|
+
border-inline-end: 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
&:disabled {
|
|
43
|
+
cursor: not-allowed;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
&[data-on] {
|
|
47
|
+
background: rgb(var(--color-primary) / 14%);
|
|
48
|
+
color: rgb(var(--color-primary));
|
|
49
|
+
font-weight: 600;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
&:not([data-on]):not(:disabled):hover {
|
|
53
|
+
background: rgb(var(--color-primary) / 6%);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
&:focus-visible {
|
|
57
|
+
outline: 2px solid rgb(var(--color-primary) / 60%);
|
|
58
|
+
outline-offset: -2px;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, h, nextTick, ref } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkSegmentedRadio from "./HkSegmentedRadio";
|
|
5
|
+
|
|
6
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
7
|
+
|
|
8
|
+
function mount(node: ReturnType<typeof h>) {
|
|
9
|
+
const container = document.createElement("div");
|
|
10
|
+
document.body.appendChild(container);
|
|
11
|
+
const app = createApp({ render: () => node });
|
|
12
|
+
app.mount(container);
|
|
13
|
+
mounts.push({ app, container });
|
|
14
|
+
return container;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
19
|
+
app.unmount();
|
|
20
|
+
container.remove();
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const OPTIONS = [
|
|
25
|
+
{ value: "active", label: "Active only" },
|
|
26
|
+
{ value: "ended", label: "Ended only" },
|
|
27
|
+
{ value: "all", label: "Show all" },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
describe("HkSegmentedRadio", () => {
|
|
31
|
+
it("renders one radio segment per option with radiogroup semantics", () => {
|
|
32
|
+
const c = mount(
|
|
33
|
+
h(HkSegmentedRadio, {
|
|
34
|
+
label: "Node status",
|
|
35
|
+
options: OPTIONS,
|
|
36
|
+
modelValue: "ended",
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
const radios = [...c.querySelectorAll<HTMLButtonElement>(".hk-segmented-radio-option")];
|
|
40
|
+
expect(radios.map((r) => r.textContent?.trim())).toEqual([
|
|
41
|
+
"Active only",
|
|
42
|
+
"Ended only",
|
|
43
|
+
"Show all",
|
|
44
|
+
]);
|
|
45
|
+
const group = c.querySelector(".hk-segmented-radio")!;
|
|
46
|
+
expect(group.getAttribute("role")).toBe("radiogroup");
|
|
47
|
+
expect(group.getAttribute("aria-label")).toBe("Node status");
|
|
48
|
+
// Radio semantics: aria-checked + roving tabindex on the picked one.
|
|
49
|
+
expect(radios[1].getAttribute("aria-checked")).toBe("true");
|
|
50
|
+
expect(radios[1].getAttribute("tabindex")).toBe("0");
|
|
51
|
+
expect(radios[0].getAttribute("aria-checked")).toBe("false");
|
|
52
|
+
expect(radios[0].getAttribute("tabindex")).toBe("-1");
|
|
53
|
+
expect(radios[1].hasAttribute("data-on")).toBe(true);
|
|
54
|
+
expect(radios[0].hasAttribute("data-on")).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("clicking another segment emits exactly its value (host feeds it back)", async () => {
|
|
58
|
+
const emitted: string[] = [];
|
|
59
|
+
// A REACTIVE host: the component is controlled, so the test host
|
|
60
|
+
// must apply what it emits (the real popover does the same).
|
|
61
|
+
const c = mount(
|
|
62
|
+
h({
|
|
63
|
+
setup() {
|
|
64
|
+
const value = ref("active");
|
|
65
|
+
return () =>
|
|
66
|
+
h(HkSegmentedRadio, {
|
|
67
|
+
label: "Node status",
|
|
68
|
+
options: OPTIONS,
|
|
69
|
+
modelValue: value.value,
|
|
70
|
+
"onUpdate:modelValue": (v: string) => {
|
|
71
|
+
emitted.push(v);
|
|
72
|
+
value.value = v;
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
const radios = () => [...c.querySelectorAll<HTMLButtonElement>(".hk-segmented-radio-option")];
|
|
79
|
+
radios()[2].click();
|
|
80
|
+
// Vue re-renders asynchronously: the controlled contract needs the
|
|
81
|
+
// host's modelValue writeback flushed before the next interaction.
|
|
82
|
+
await nextTick();
|
|
83
|
+
radios()[0].click();
|
|
84
|
+
await nextTick();
|
|
85
|
+
expect(emitted).toEqual(["all", "active"]);
|
|
86
|
+
expect(radios()[0].hasAttribute("data-on")).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("keyboard arrows move the pick with wrapping and refocus the new segment", async () => {
|
|
90
|
+
const emitted: string[] = [];
|
|
91
|
+
const c = mount(
|
|
92
|
+
h(
|
|
93
|
+
{
|
|
94
|
+
setup() {
|
|
95
|
+
const value = ref("ended");
|
|
96
|
+
return () =>
|
|
97
|
+
h(HkSegmentedRadio, {
|
|
98
|
+
label: "Node status",
|
|
99
|
+
options: OPTIONS,
|
|
100
|
+
modelValue: value.value,
|
|
101
|
+
"onUpdate:modelValue": (v: string) => {
|
|
102
|
+
emitted.push(v);
|
|
103
|
+
value.value = v;
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
const group = c.querySelector<HTMLElement>(".hk-segmented-radio")!;
|
|
111
|
+
group.dispatchEvent(
|
|
112
|
+
new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }),
|
|
113
|
+
);
|
|
114
|
+
expect(emitted).toEqual(["all"]);
|
|
115
|
+
await nextTick();
|
|
116
|
+
group.dispatchEvent(
|
|
117
|
+
new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }),
|
|
118
|
+
);
|
|
119
|
+
await nextTick();
|
|
120
|
+
expect(emitted).toEqual(["all", "active"]);
|
|
121
|
+
// Home/End jump to the ends.
|
|
122
|
+
group.dispatchEvent(new KeyboardEvent("keydown", { key: "End", bubbles: true }));
|
|
123
|
+
await nextTick();
|
|
124
|
+
expect(emitted).toEqual(["all", "active", "all"]);
|
|
125
|
+
group.dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true }));
|
|
126
|
+
await nextTick();
|
|
127
|
+
expect(emitted).toEqual(["all", "active", "all", "active"]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("disabled disables every segment and the group carries the mark", () => {
|
|
131
|
+
const emitted: string[] = [];
|
|
132
|
+
const c = mount(
|
|
133
|
+
h(HkSegmentedRadio, {
|
|
134
|
+
label: "Node status",
|
|
135
|
+
options: OPTIONS,
|
|
136
|
+
modelValue: "active",
|
|
137
|
+
disabled: true,
|
|
138
|
+
"onUpdate:modelValue": (v: string) => emitted.push(v),
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
const group = c.querySelector(".hk-segmented-radio")!;
|
|
142
|
+
expect(group.hasAttribute("data-disabled")).toBe(true);
|
|
143
|
+
const radios = [...c.querySelectorAll<HTMLButtonElement>(".hk-segmented-radio-option")];
|
|
144
|
+
for (const r of radios) expect(r.disabled).toBe(true);
|
|
145
|
+
radios[1].click();
|
|
146
|
+
expect(emitted).toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("an unpicked model keeps the FIRST segment tabbable (roving entry point)", () => {
|
|
150
|
+
const c = mount(
|
|
151
|
+
h(HkSegmentedRadio, {
|
|
152
|
+
label: "Node status",
|
|
153
|
+
options: OPTIONS,
|
|
154
|
+
modelValue: "",
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
const radios = [...c.querySelectorAll<HTMLButtonElement>(".hk-segmented-radio-option")];
|
|
158
|
+
expect(radios[0].getAttribute("tabindex")).toBe("0");
|
|
159
|
+
expect(radios[1].getAttribute("tabindex")).toBe("-1");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("an unpicked model reads honest edges (ArrowLeft → LAST, ArrowRight → FIRST)", async () => {
|
|
163
|
+
// R3 pin: the index === -1 branches had no coverage — a plain modulo
|
|
164
|
+
// wrap would send ArrowLeft-from-unpicked to the MIDDLE option
|
|
165
|
+
// ((-1 - 1 + 3) % 3 === 1), not the honest last edge.
|
|
166
|
+
const press = (root: HTMLElement, key: string) =>
|
|
167
|
+
root
|
|
168
|
+
.querySelector<HTMLElement>(".hk-segmented-radio")!
|
|
169
|
+
.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true }));
|
|
170
|
+
const host = (push: (v: string) => void) =>
|
|
171
|
+
h({
|
|
172
|
+
setup() {
|
|
173
|
+
const value = ref("");
|
|
174
|
+
return () =>
|
|
175
|
+
h(HkSegmentedRadio, {
|
|
176
|
+
label: "Node status",
|
|
177
|
+
options: OPTIONS,
|
|
178
|
+
modelValue: value.value,
|
|
179
|
+
"onUpdate:modelValue": (v: string) => {
|
|
180
|
+
push(v);
|
|
181
|
+
value.value = v;
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ArrowLeft from unpicked lands on the LAST option.
|
|
188
|
+
const fromLeft: string[] = [];
|
|
189
|
+
const leftRoot = mount(h(host((v) => fromLeft.push(v))));
|
|
190
|
+
press(leftRoot, "ArrowLeft");
|
|
191
|
+
await nextTick();
|
|
192
|
+
expect(fromLeft).toEqual(["all"]);
|
|
193
|
+
|
|
194
|
+
// ArrowRight from unpicked lands on the FIRST option.
|
|
195
|
+
const fromRight: string[] = [];
|
|
196
|
+
const rightRoot = mount(h(host((v) => fromRight.push(v))));
|
|
197
|
+
press(rightRoot, "ArrowRight");
|
|
198
|
+
await nextTick();
|
|
199
|
+
expect(fromRight).toEqual(["active"]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("keyboard nav over duplicate-bearing options walks the DEDUPED list", async () => {
|
|
203
|
+
// Delta-review S2 pin: the onGroupKeydown index math must run over
|
|
204
|
+
// dedupedOptions (raw options with duplicates make the honest-edge
|
|
205
|
+
// math land on a NON-rendered occurrence — ArrowLeft-from-unpicked
|
|
206
|
+
// over [A, A2, B] would emit "A" (hidden), not "B" (visually last)).
|
|
207
|
+
const emitted: string[] = [];
|
|
208
|
+
const c = mount(
|
|
209
|
+
h({
|
|
210
|
+
setup() {
|
|
211
|
+
const value = ref("");
|
|
212
|
+
return () =>
|
|
213
|
+
h(HkSegmentedRadio, {
|
|
214
|
+
label: "Node status",
|
|
215
|
+
options: [
|
|
216
|
+
{ value: "active", label: "Active only" },
|
|
217
|
+
{ value: "active", label: "Active again (dup)" },
|
|
218
|
+
{ value: "all", label: "Show all" },
|
|
219
|
+
],
|
|
220
|
+
modelValue: value.value,
|
|
221
|
+
"onUpdate:modelValue": (v: string) => {
|
|
222
|
+
emitted.push(v);
|
|
223
|
+
value.value = v;
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
},
|
|
227
|
+
}),
|
|
228
|
+
);
|
|
229
|
+
const group = c.querySelector<HTMLElement>(".hk-segmented-radio")!;
|
|
230
|
+
group.dispatchEvent(
|
|
231
|
+
new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true }),
|
|
232
|
+
);
|
|
233
|
+
await nextTick();
|
|
234
|
+
// The last RENDERED segment, not the hidden duplicate.
|
|
235
|
+
expect(emitted).toEqual(["all"]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("duplicate option values collapse to one radio (first wins, one tabbable)", () => {
|
|
239
|
+
// R3 pin: the dedupe filter had no coverage — without it a
|
|
240
|
+
// duplicated value renders two aria-checked radios for one value.
|
|
241
|
+
const c = mount(
|
|
242
|
+
h(HkSegmentedRadio, {
|
|
243
|
+
label: "Node status",
|
|
244
|
+
options: [
|
|
245
|
+
{ value: "active", label: "Active only" },
|
|
246
|
+
{ value: "ended", label: "Ended only" },
|
|
247
|
+
{ value: "active", label: "Active again (dup)" },
|
|
248
|
+
],
|
|
249
|
+
modelValue: "",
|
|
250
|
+
}),
|
|
251
|
+
);
|
|
252
|
+
const radios = [...c.querySelectorAll<HTMLButtonElement>(".hk-segmented-radio-option")];
|
|
253
|
+
// Exactly two segments render — the duplicate "active" is dropped.
|
|
254
|
+
expect(radios.map((r) => r.textContent?.trim())).toEqual(["Active only", "Ended only"]);
|
|
255
|
+
expect(c.querySelectorAll('[data-option="active"]')).toHaveLength(1);
|
|
256
|
+
// And the roving tabindex stays single (unpicked → first segment only).
|
|
257
|
+
expect(radios.filter((r) => r.getAttribute("tabindex") === "0")).toHaveLength(1);
|
|
258
|
+
});
|
|
259
|
+
});
|