@celestia-island/hikari 0.55.210 → 0.55.211
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/HkTabs.test.ts +38 -0
- package/src/components/HkTabs.tsx +50 -17
- package/src/index.ts +0 -2
- package/src/components/HkSegmentedRadio.scss +0 -60
- package/src/components/HkSegmentedRadio.test.tsx +0 -259
- package/src/components/HkSegmentedRadio.tsx +0 -141
package/package.json
CHANGED
|
@@ -255,6 +255,44 @@ describe("HkTabs segmented variant", () => {
|
|
|
255
255
|
expect(container.querySelector(".hk-tabs-panel")).toBeNull();
|
|
256
256
|
});
|
|
257
257
|
|
|
258
|
+
it("names the track for assistive tech (ariaLabel rides the group)", () => {
|
|
259
|
+
// The former HkSegmentedRadio carried its hosts' dimension names on
|
|
260
|
+
// the radiogroup; the converged control must not lose that.
|
|
261
|
+
const { container } = mountLive({ variant: "segmented", ariaLabel: "Node status" });
|
|
262
|
+
const list = container.querySelector(".hk-tabs-list")!;
|
|
263
|
+
expect(list.getAttribute("aria-label")).toBe("Node status");
|
|
264
|
+
// Pill mode names its tablist the same way.
|
|
265
|
+
const pill = mountLive({ ariaLabel: "Views" });
|
|
266
|
+
expect(pill.container.querySelector(".hk-tabs-list")!.getAttribute("aria-label")).toBe("Views");
|
|
267
|
+
// Negative pin: no ariaLabel → the attribute is absent everywhere; an
|
|
268
|
+
// EMPTY string must not sneak in as a fake name either (R3 M4).
|
|
269
|
+
const bare = mountLive({ variant: "segmented" });
|
|
270
|
+
expect(bare.container.querySelector(".hk-tabs-list")!.getAttribute("aria-label")).toBeNull();
|
|
271
|
+
expect(bare.container.querySelector("[aria-label]")).toBeNull();
|
|
272
|
+
const empty = mountLive({ variant: "segmented", ariaLabel: "" });
|
|
273
|
+
expect(empty.container.querySelector(".hk-tabs-list")!.getAttribute("aria-label")).toBeNull();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("collapses duplicate tab keys to the first occurrence (one control per value)", () => {
|
|
277
|
+
// Ported from the deleted HkSegmentedRadio's guard (R2 2026-10-28):
|
|
278
|
+
// identical ORDERED keys never trip Vue's own duplicate-key warning,
|
|
279
|
+
// so without the collapse a duplicated key renders two aria-checked
|
|
280
|
+
// radios with roving tabindex broken — silently, even in DEV.
|
|
281
|
+
const { container } = mountLive({
|
|
282
|
+
variant: "segmented",
|
|
283
|
+
tabs: [
|
|
284
|
+
{ key: "a", label: "Alpha" },
|
|
285
|
+
{ key: "a", label: "Alpha again (dup)" },
|
|
286
|
+
{ key: "b", label: "Beta" },
|
|
287
|
+
],
|
|
288
|
+
});
|
|
289
|
+
const triggers = [...container.querySelectorAll<HTMLButtonElement>(".hk-tabs-trigger")];
|
|
290
|
+
expect(triggers.map((t) => t.textContent?.trim())).toEqual(["Alpha", "Beta"]);
|
|
291
|
+
expect(container.querySelectorAll('[role="radio"][aria-checked="true"]')).toHaveLength(1);
|
|
292
|
+
// Roving tabindex stays single.
|
|
293
|
+
expect(triggers.filter((t) => t.getAttribute("tabindex") !== "-1")).toHaveLength(1);
|
|
294
|
+
});
|
|
295
|
+
|
|
258
296
|
it("keeps tab semantics for pill (the default)", () => {
|
|
259
297
|
const { container } = mountLive({});
|
|
260
298
|
const list = container.querySelector(".hk-tabs-list")!;
|
|
@@ -111,6 +111,12 @@ export default defineComponent({
|
|
|
111
111
|
block: { type: Boolean, default: false },
|
|
112
112
|
/** Disable the whole strip (every trigger). */
|
|
113
113
|
disabled: { type: Boolean, default: false },
|
|
114
|
+
/** Accessible name of the track (the tablist's / radiogroup's
|
|
115
|
+
* aria-label). A segmented strip is a form control — its filter
|
|
116
|
+
* hosts label the DIMENSION ("Running state", "Node status"), and
|
|
117
|
+
* an unnamed radiogroup inside a named popover is what the former
|
|
118
|
+
* HkSegmentedRadio's `label` prop existed to prevent. */
|
|
119
|
+
ariaLabel: { type: String, default: undefined },
|
|
114
120
|
/** Render tab panel slots below the strip. Segmented mode pickers
|
|
115
121
|
* have no panels — panels are skipped for that variant. */
|
|
116
122
|
renderPanels: { type: Boolean, default: true },
|
|
@@ -172,15 +178,41 @@ export default defineComponent({
|
|
|
172
178
|
const isSegmented = computed(() => props.variant === "segmented");
|
|
173
179
|
const isBlock = computed(() => isSegmented.value && props.block);
|
|
174
180
|
const mergeSet = computed(() => new Set(props.mergeKeys ?? []));
|
|
181
|
+
/** First-wins collapse of duplicate keys plus a DEV alarm. The
|
|
182
|
+
* contract says keys are unique; a duplicate renders TWO
|
|
183
|
+
* aria-checked controls for one value with roving tabindex broken,
|
|
184
|
+
* and Vue's own keyed-patch warning never fires for identical
|
|
185
|
+
* ORDERED keys (they sync 1:1), so the alarm lives here. Ported
|
|
186
|
+
* from the deleted HkSegmentedRadio with its pin tests when the
|
|
187
|
+
* duplicate component was removed (R2, 2026-10-28). Every index —
|
|
188
|
+
* merged runs, slot units, refs map, keyboard nav, the indicator —
|
|
189
|
+
* derives from THIS list, so the collapse keeps them all
|
|
190
|
+
* consistent. */
|
|
191
|
+
const tabs = computed(() => {
|
|
192
|
+
const seen = new Set<string>();
|
|
193
|
+
const list = props.tabs.filter((tb) => {
|
|
194
|
+
if (seen.has(tb.key)) return false;
|
|
195
|
+
seen.add(tb.key);
|
|
196
|
+
return true;
|
|
197
|
+
});
|
|
198
|
+
if (import.meta.env.DEV && list.length !== props.tabs.length) {
|
|
199
|
+
// eslint-disable-next-line no-console -- dev-only drift alarm
|
|
200
|
+
console.warn(
|
|
201
|
+
"[HkTabs] duplicate tab keys dropped:",
|
|
202
|
+
props.tabs.map((tb) => tb.key).join(", "),
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
return list;
|
|
206
|
+
});
|
|
175
207
|
/** Merged keys actually present in `tabs`, in tab order. */
|
|
176
208
|
const mergedRun = computed(() =>
|
|
177
|
-
|
|
209
|
+
tabs.value.filter((tb) => mergeSet.value.has(tb.key)).map((tb) => tb.key),
|
|
178
210
|
);
|
|
179
211
|
/** The contract is ONE contiguous run; a scattered selection would
|
|
180
212
|
* silently swallow the middle options, so degrade to plain
|
|
181
213
|
* triggers instead. */
|
|
182
214
|
const mergedContiguous = computed(() => {
|
|
183
|
-
const idxs =
|
|
215
|
+
const idxs = tabs.value
|
|
184
216
|
.map((tb, i) => (mergeSet.value.has(tb.key) ? i : -1))
|
|
185
217
|
.filter((i) => i >= 0);
|
|
186
218
|
return idxs.length <= 1 || idxs[idxs.length - 1] - idxs[0] === idxs.length - 1;
|
|
@@ -213,7 +245,7 @@ export default defineComponent({
|
|
|
213
245
|
const slotMode = computed(
|
|
214
246
|
() =>
|
|
215
247
|
hasMergedCell.value ||
|
|
216
|
-
|
|
248
|
+
tabs.value.some((tb) => slotSpan(tb) !== 1),
|
|
217
249
|
);
|
|
218
250
|
/** Total slot units the strip is divided into while in slot mode:
|
|
219
251
|
* every rendered option contributes its clamped span, and the
|
|
@@ -221,7 +253,7 @@ export default defineComponent({
|
|
|
221
253
|
* those triggers). Matches the grid track count in HkTabs.scss. */
|
|
222
254
|
const slotUnits = computed(() => {
|
|
223
255
|
let units = 0;
|
|
224
|
-
for (const tb of
|
|
256
|
+
for (const tb of tabs.value) {
|
|
225
257
|
if (isMergedTab(tb)) continue;
|
|
226
258
|
units += slotSpan(tb);
|
|
227
259
|
}
|
|
@@ -236,11 +268,11 @@ export default defineComponent({
|
|
|
236
268
|
// merged): keep the first enabled trigger tabbable so the group
|
|
237
269
|
// stays reachable — otherwise every trigger would be tabindex=-1.
|
|
238
270
|
const fallbackTabbableIdx = computed(() => {
|
|
239
|
-
const hasActive =
|
|
271
|
+
const hasActive = tabs.value.some(
|
|
240
272
|
(tb) => tb.key === props.modelValue && isNavigable(tb),
|
|
241
273
|
);
|
|
242
274
|
if (hasActive) return -1;
|
|
243
|
-
return
|
|
275
|
+
return tabs.value.findIndex((tb) => isNavigable(tb));
|
|
244
276
|
});
|
|
245
277
|
|
|
246
278
|
function resolvedActionLabel(action: TabsEndAction): string {
|
|
@@ -264,9 +296,9 @@ export default defineComponent({
|
|
|
264
296
|
* (falling back to the first option's render — trigger OR cell —
|
|
265
297
|
* while no geometry exists). */
|
|
266
298
|
function activeFrameEl(): HTMLElement | undefined {
|
|
267
|
-
const idx =
|
|
299
|
+
const idx = tabs.value.findIndex((tb) => tb.key === props.modelValue);
|
|
268
300
|
const i = idx >= 0 ? idx : 0;
|
|
269
|
-
const tab =
|
|
301
|
+
const tab = tabs.value[i];
|
|
270
302
|
if (tab && isMergedTab(tab)) {
|
|
271
303
|
return mergedCellRef.value ?? undefined;
|
|
272
304
|
}
|
|
@@ -387,18 +419,18 @@ export default defineComponent({
|
|
|
387
419
|
void nextTick(() => triggerRefs.value.get(idx)?.focus());
|
|
388
420
|
}
|
|
389
421
|
function selectIndex(idx: number) {
|
|
390
|
-
const tb =
|
|
422
|
+
const tb = tabs.value[idx];
|
|
391
423
|
if (!tb || !isNavigable(tb) || props.disabled || tb.key === props.modelValue) return;
|
|
392
424
|
emit("update:modelValue", tb.key);
|
|
393
425
|
focusTrigger(idx);
|
|
394
426
|
}
|
|
395
427
|
function moveSelection(from: number, delta: number) {
|
|
396
|
-
const n =
|
|
428
|
+
const n = tabs.value.length;
|
|
397
429
|
if (n === 0) return;
|
|
398
430
|
let idx = from;
|
|
399
431
|
for (let step = 0; step < n; step += 1) {
|
|
400
432
|
idx = (idx + delta + n) % n;
|
|
401
|
-
if (isNavigable(
|
|
433
|
+
if (isNavigable(tabs.value[idx])) {
|
|
402
434
|
selectIndex(idx);
|
|
403
435
|
return;
|
|
404
436
|
}
|
|
@@ -410,7 +442,7 @@ export default defineComponent({
|
|
|
410
442
|
// its keys.
|
|
411
443
|
const target = e.target as HTMLElement | null;
|
|
412
444
|
if (!target || !target.closest(".hk-tabs-trigger")) return;
|
|
413
|
-
const current = Math.max(0,
|
|
445
|
+
const current = Math.max(0, tabs.value.findIndex((tb) => tb.key === props.modelValue));
|
|
414
446
|
switch (e.key) {
|
|
415
447
|
case "ArrowRight":
|
|
416
448
|
case "ArrowDown":
|
|
@@ -421,13 +453,13 @@ export default defineComponent({
|
|
|
421
453
|
moveSelection(current, -1);
|
|
422
454
|
break;
|
|
423
455
|
case "Home": {
|
|
424
|
-
const first =
|
|
456
|
+
const first = tabs.value.findIndex((tb) => isNavigable(tb));
|
|
425
457
|
if (first >= 0) selectIndex(first);
|
|
426
458
|
break;
|
|
427
459
|
}
|
|
428
460
|
case "End": {
|
|
429
|
-
for (let i =
|
|
430
|
-
if (isNavigable(
|
|
461
|
+
for (let i = tabs.value.length - 1; i >= 0; i -= 1) {
|
|
462
|
+
if (isNavigable(tabs.value[i])) {
|
|
431
463
|
selectIndex(i);
|
|
432
464
|
break;
|
|
433
465
|
}
|
|
@@ -447,7 +479,7 @@ export default defineComponent({
|
|
|
447
479
|
* cell — merged options' panels are not rendered while merged
|
|
448
480
|
* (unchanged contract; the whole container drops out instead). */
|
|
449
481
|
const panelTab = props.renderPanels && !isSegmented.value
|
|
450
|
-
?
|
|
482
|
+
? tabs.value.find((tb) => tb.key === props.modelValue && !isMergedTab(tb))
|
|
451
483
|
: undefined;
|
|
452
484
|
const listChildren = [
|
|
453
485
|
<div
|
|
@@ -457,7 +489,7 @@ export default defineComponent({
|
|
|
457
489
|
data-ready={indicatorReady.value || undefined}
|
|
458
490
|
/>,
|
|
459
491
|
];
|
|
460
|
-
|
|
492
|
+
tabs.value.forEach((tab, idx) => {
|
|
461
493
|
if (isMergedTab(tab)) {
|
|
462
494
|
// The merged run renders as ONE combined cell at its first
|
|
463
495
|
// option's position — a real in-flow child of the track (joins
|
|
@@ -530,6 +562,7 @@ export default defineComponent({
|
|
|
530
562
|
: undefined
|
|
531
563
|
}
|
|
532
564
|
role={isSegmented.value ? "radiogroup" : "tablist"}
|
|
565
|
+
aria-label={props.ariaLabel || undefined}
|
|
533
566
|
onKeydown={onKeydown}
|
|
534
567
|
>
|
|
535
568
|
{/* Tagless group: the track stays a plain element (ref/CSS/
|
package/src/index.ts
CHANGED
|
@@ -673,8 +673,6 @@ export { default as HkPopupSelect } from "./components/HkPopupSelect";
|
|
|
673
673
|
export { default as HkProgressBar } from "./components/HkProgressBar";
|
|
674
674
|
export { default as HkProgressDialog } from "./components/HkProgressDialog";
|
|
675
675
|
export { default as HkRadio } from "./components/HkRadio";
|
|
676
|
-
export { default as HkSegmentedRadio } from "./components/HkSegmentedRadio";
|
|
677
|
-
export type { HkSegmentedRadioOption } from "./components/HkSegmentedRadio";
|
|
678
676
|
export { default as HkScrollContainer } from "./components/HkScrollContainer";
|
|
679
677
|
export { default as HkSearchInput } from "./components/HkSearchInput";
|
|
680
678
|
export { default as HkSplash } from "./components/HkSplash";
|
|
@@ -1,60 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,259 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
import { computed, defineComponent, nextTick, ref, type PropType } from "vue";
|
|
2
|
-
|
|
3
|
-
import "./HkSegmentedRadio.scss";
|
|
4
|
-
|
|
5
|
-
/** One joined segment of the single-choice strip. */
|
|
6
|
-
export interface HkSegmentedRadioOption {
|
|
7
|
-
/** Stable identity — emitted back through `update:modelValue`. */
|
|
8
|
-
value: string;
|
|
9
|
-
label: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* HkSegmentedRadio — the single-choice joined strip for filter popovers
|
|
14
|
-
* and compact pickers: a flat bordered track of full-label segments, the
|
|
15
|
-
* picked one carrying the primary tint (multi-select's sibling is
|
|
16
|
-
* HkPillToggleGroup, whose toggle semantics cannot express "exactly
|
|
17
|
-
* one"; HkTabs owns the view-switching indicator strip — this component
|
|
18
|
-
* is the FORM control in that gap, radio semantics throughout).
|
|
19
|
-
*
|
|
20
|
-
* Grammar ported from the two chest filter popovers that hand-rolled
|
|
21
|
-
* this control independently (history TopicFilterMenu 2026-10-21, cruise
|
|
22
|
-
* CruiseNodeFilterMenu 2026-10-26) with DIFFERENT paddings and hover
|
|
23
|
-
* tints — the point of the component is that they converge on one
|
|
24
|
-
* chrome (#demiurge.673 follow-up, 2026-10-27).
|
|
25
|
-
*
|
|
26
|
-
* Keyboard: a true radiogroup — roving tabindex on the picked segment,
|
|
27
|
-
* Arrow/Home/End to move (wrapping), Enter/Space to pick.
|
|
28
|
-
*/
|
|
29
|
-
export default defineComponent({
|
|
30
|
-
name: "HkSegmentedRadio",
|
|
31
|
-
props: {
|
|
32
|
-
/** Accessible name of the group (the radiogroup's aria-label). */
|
|
33
|
-
label: { type: String, required: true },
|
|
34
|
-
options: {
|
|
35
|
-
type: Array as PropType<HkSegmentedRadioOption[]>,
|
|
36
|
-
default: () => [],
|
|
37
|
-
},
|
|
38
|
-
/** The picked value — single source of truth, never mutated here. */
|
|
39
|
-
modelValue: { type: String, default: "" },
|
|
40
|
-
disabled: { type: Boolean, default: false },
|
|
41
|
-
},
|
|
42
|
-
emits: {
|
|
43
|
-
"update:modelValue": (_value: string) => true,
|
|
44
|
-
},
|
|
45
|
-
setup(props, { emit }) {
|
|
46
|
-
const groupRef = ref<HTMLElement | null>(null);
|
|
47
|
-
const picked = computed(() =>
|
|
48
|
-
props.options.find((item) => item.value === props.modelValue),
|
|
49
|
-
);
|
|
50
|
-
// Duplicate values would render two aria-checked radios (two
|
|
51
|
-
// tabbable segments) and hazard the keyed patch — keep the first
|
|
52
|
-
// occurrence and say so in dev.
|
|
53
|
-
const dedupedOptions = computed(() => {
|
|
54
|
-
const seen = new Set<string>();
|
|
55
|
-
return props.options.filter((item) => {
|
|
56
|
-
if (seen.has(item.value)) return false;
|
|
57
|
-
seen.add(item.value);
|
|
58
|
-
return true;
|
|
59
|
-
});
|
|
60
|
-
});
|
|
61
|
-
if (import.meta.env.DEV && dedupedOptions.value.length !== props.options.length) {
|
|
62
|
-
// eslint-disable-next-line no-console -- dev-only drift alarm
|
|
63
|
-
console.warn(
|
|
64
|
-
"[HkSegmentedRadio] duplicate option values dropped:",
|
|
65
|
-
props.options.map((o) => o.value).join(", "),
|
|
66
|
-
);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function choose(value: string): void {
|
|
70
|
-
if (value !== props.modelValue) emit("update:modelValue", value);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function onGroupKeydown(event: KeyboardEvent): void {
|
|
74
|
-
// Walk the DEDUPED list: index math over raw options counts
|
|
75
|
-
// non-rendered duplicates and can land on a hidden occurrence.
|
|
76
|
-
const items = dedupedOptions.value;
|
|
77
|
-
if (items.length === 0) return;
|
|
78
|
-
const index = items.findIndex((item) => item.value === props.modelValue);
|
|
79
|
-
let next: number;
|
|
80
|
-
if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
|
|
81
|
-
// Nothing picked yet: the edges are honest neighbors (last /
|
|
82
|
-
// first), not the element at the wrap of index -1.
|
|
83
|
-
next = index === -1
|
|
84
|
-
? items.length - 1
|
|
85
|
-
: (index - 1 + items.length) % items.length;
|
|
86
|
-
} else if (event.key === "ArrowRight" || event.key === "ArrowDown") {
|
|
87
|
-
next = index === -1 ? 0 : (index + 1) % items.length;
|
|
88
|
-
} else if (event.key === "Home") {
|
|
89
|
-
next = 0;
|
|
90
|
-
} else if (event.key === "End") {
|
|
91
|
-
next = items.length - 1;
|
|
92
|
-
} else {
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
event.preventDefault();
|
|
96
|
-
const value = items[next]!.value;
|
|
97
|
-
choose(value);
|
|
98
|
-
void nextTick(() => {
|
|
99
|
-
// Walk the rendered buttons comparing dataset — a quoted
|
|
100
|
-
// attribute selector with CSS.escape throws on quote-bearing
|
|
101
|
-
// values in happy-dom and misses NUL-bearing ones, which would
|
|
102
|
-
// kill the refocus as an unhandled rejection.
|
|
103
|
-
const target = Array.from(
|
|
104
|
-
groupRef.value?.querySelectorAll<HTMLElement>("[data-option]") ?? [],
|
|
105
|
-
).find((el) => el.dataset.option === value);
|
|
106
|
-
target?.focus();
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
return () => (
|
|
111
|
-
<div
|
|
112
|
-
ref={groupRef}
|
|
113
|
-
class="hk-segmented-radio"
|
|
114
|
-
role="radiogroup"
|
|
115
|
-
aria-label={props.label}
|
|
116
|
-
data-disabled={props.disabled || undefined}
|
|
117
|
-
onKeydown={onGroupKeydown}
|
|
118
|
-
>
|
|
119
|
-
{dedupedOptions.value.map((item) => {
|
|
120
|
-
const active = item.value === props.modelValue;
|
|
121
|
-
return (
|
|
122
|
-
<button
|
|
123
|
-
key={item.value}
|
|
124
|
-
type="button"
|
|
125
|
-
role="radio"
|
|
126
|
-
aria-checked={active}
|
|
127
|
-
tabindex={picked.value ? (active ? 0 : -1) : item === props.options[0] ? 0 : -1}
|
|
128
|
-
class="hk-segmented-radio-option"
|
|
129
|
-
data-option={item.value}
|
|
130
|
-
data-on={active || undefined}
|
|
131
|
-
disabled={props.disabled}
|
|
132
|
-
onClick={() => choose(item.value)}
|
|
133
|
-
>
|
|
134
|
-
{item.label}
|
|
135
|
-
</button>
|
|
136
|
-
);
|
|
137
|
-
})}
|
|
138
|
-
</div>
|
|
139
|
-
);
|
|
140
|
-
},
|
|
141
|
-
});
|