@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.205",
3
+ "version": "0.55.207",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -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
- expect(src).toContain("useReportedTransition(REPORT_MS)");
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
- const report = useReportedTransition(REPORT_MS);
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, CROSSFADE_WATCHDOG_MS);
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 as a ghost/28 icon button (the theme toggle's step)", () => {
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
- expect(btn.className).toContain("hk-icon-button-28");
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/28 icon button
19
- * (the same step as the theme toggle and the page refresh — the whole
20
- * top-right cluster reads as one row of chrome) anchoring the
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={28}
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 consumers are
78
- * the header clusters — global search, the pages' refresh, the filter
79
- * trigger — whose visual sibling is HkThemeToggle's own trigger (one
80
- * 28px box with a direct 16px lucide glyph). The xs mapping (12px)
81
- * rendered those glyphs visibly smaller than the glyphs beside them
82
- * (user report 2026-09-29: 不像一个尺寸的图标按钮) — the `.svg` rule
83
- * in HkIconButton.scss forces ANY slotted glyph to this var, so the
84
- * slot's size-16 attribute never reached the screen. */
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={28}
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
+ });