@celestia-island/hikari 0.55.210 → 0.55.212
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/HkMinimap.collapse.test.tsx +149 -0
- package/src/components/HkMinimap.scss +34 -0
- package/src/components/HkMinimap.tsx +70 -18
- package/src/components/HkTabs.test.ts +38 -0
- package/src/components/HkTabs.tsx +50 -17
- package/src/components/HkTreemapStage.scss +114 -0
- package/src/components/HkTreemapStage.test.tsx +283 -0
- package/src/components/HkTreemapStage.tsx +382 -0
- package/src/i18n/locales/ar/components.json +2 -0
- package/src/i18n/locales/de/components.json +2 -0
- package/src/i18n/locales/en/components.json +2 -0
- package/src/i18n/locales/es/components.json +2 -0
- package/src/i18n/locales/fr/components.json +2 -0
- package/src/i18n/locales/ja/components.json +2 -0
- package/src/i18n/locales/ko/components.json +2 -0
- package/src/i18n/locales/pt/components.json +2 -0
- package/src/i18n/locales/ru/components.json +2 -0
- package/src/i18n/locales/zh-Hans/components.json +2 -0
- package/src/i18n/locales/zh-Hant/components.json +2 -0
- package/src/index.ts +1 -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
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, h, nextTick } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkMinimap from "./HkMinimap";
|
|
5
|
+
|
|
6
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
7
|
+
|
|
8
|
+
function mount(props: Record<string, unknown>) {
|
|
9
|
+
const container = document.createElement("div");
|
|
10
|
+
document.body.appendChild(container);
|
|
11
|
+
const app = createApp({ render: () => h(HkMinimap, props) });
|
|
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 BOXES = [
|
|
25
|
+
{ id: "a", bounds: { x: 0, y: 0, w: 10, h: 10 }, color: "#f00" },
|
|
26
|
+
{ id: "b", bounds: { x: 10, y: 0, w: 10, h: 10 }, color: "#0f0" },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
describe("HkMinimap collapse (user direction 2026-10-27)", () => {
|
|
30
|
+
it("a non-collapsible minimap renders the card and NO minimize button", () => {
|
|
31
|
+
const c = mount({ boxes: BOXES });
|
|
32
|
+
expect(c.querySelector(".hk-minimap--collapsed")).toBeNull();
|
|
33
|
+
expect(c.querySelector(".hk-mm-collapse-btn")).toBeNull();
|
|
34
|
+
expect(c.querySelector("svg.hk-minimap-svg")).toBeTruthy();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("collapsible renders the minimize button in the zoom bar", () => {
|
|
38
|
+
const c = mount({ boxes: BOXES, collapsible: true });
|
|
39
|
+
expect(c.querySelector(".hk-mm-collapse-btn")).toBeTruthy();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("collapsing swaps the card for the semi-transparent map-icon button", async () => {
|
|
43
|
+
const c = mount({ boxes: BOXES, collapsible: true });
|
|
44
|
+
(c.querySelector(".hk-mm-collapse-btn") as HTMLElement).click();
|
|
45
|
+
await nextTick();
|
|
46
|
+
expect(c.querySelector("svg.hk-minimap-svg")).toBeNull();
|
|
47
|
+
const glyph = c.querySelector<HTMLButtonElement>(".hk-minimap--collapsed");
|
|
48
|
+
expect(glyph).toBeTruthy();
|
|
49
|
+
// The collapsed glyph announces itself and is translucent at rest.
|
|
50
|
+
expect(glyph!.getAttribute("aria-label")).toBe("Show minimap");
|
|
51
|
+
expect(glyph!.className).toContain("hk-minimap--collapsed");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("clicking the collapsed glyph expands the card again (uncontrolled)", async () => {
|
|
55
|
+
const c = mount({ boxes: BOXES, collapsible: true });
|
|
56
|
+
(c.querySelector(".hk-mm-collapse-btn") as HTMLElement).click();
|
|
57
|
+
await nextTick();
|
|
58
|
+
(c.querySelector(".hk-minimap--collapsed") as HTMLElement).click();
|
|
59
|
+
await nextTick();
|
|
60
|
+
expect(c.querySelector("svg.hk-minimap-svg")).toBeTruthy();
|
|
61
|
+
expect(c.querySelector(".hk-minimap--collapsed")).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("the controlled mode honours the collapsed prop and emits updates", async () => {
|
|
65
|
+
const seen: boolean[] = [];
|
|
66
|
+
const container = document.createElement("div");
|
|
67
|
+
document.body.appendChild(container);
|
|
68
|
+
const app = createApp({
|
|
69
|
+
data: () => ({ collapsed: false }),
|
|
70
|
+
render() {
|
|
71
|
+
return h(HkMinimap, {
|
|
72
|
+
boxes: BOXES,
|
|
73
|
+
collapsible: true,
|
|
74
|
+
collapsed: (this as { collapsed: boolean }).collapsed,
|
|
75
|
+
"onUpdate:collapsed": (v: boolean) => {
|
|
76
|
+
seen.push(v);
|
|
77
|
+
(this as { collapsed: boolean }).collapsed = v;
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
app.mount(container);
|
|
83
|
+
mounts.push({ app, container });
|
|
84
|
+
|
|
85
|
+
(container.querySelector(".hk-mm-collapse-btn") as HTMLElement).click();
|
|
86
|
+
await nextTick();
|
|
87
|
+
expect(seen).toEqual([true]);
|
|
88
|
+
// The controlled prop drove the swap.
|
|
89
|
+
expect(container.querySelector(".hk-minimap--collapsed")).toBeTruthy();
|
|
90
|
+
|
|
91
|
+
(container.querySelector(".hk-minimap--collapsed") as HTMLElement).click();
|
|
92
|
+
await nextTick();
|
|
93
|
+
expect(seen).toEqual([true, false]);
|
|
94
|
+
expect(container.querySelector("svg.hk-minimap-svg")).toBeTruthy();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("drag-to-pan SURVIVES a collapse→expand cycle (the R2/R3 S1)", async () => {
|
|
98
|
+
// The old imperative listeners bound once at mount to the mount-time
|
|
99
|
+
// element; the collapsed swap replaced the node and the re-expanded
|
|
100
|
+
// card never got them back. The handlers now ride the vnodes, so the
|
|
101
|
+
// gesture works on whatever element renders.
|
|
102
|
+
const c = mount({ boxes: BOXES, collapsible: true });
|
|
103
|
+
const card = c.querySelector(".hk-minimap") as HTMLElement;
|
|
104
|
+
const before = c.querySelector("svg.hk-minimap-svg")!;
|
|
105
|
+
card.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, clientX: 10, clientY: 10 }));
|
|
106
|
+
card.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, clientX: 20, clientY: 14 }));
|
|
107
|
+
card.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
|
|
108
|
+
expect(c.querySelector(".hk-minimap")).toBeTruthy(); // gesture ran
|
|
109
|
+
void before;
|
|
110
|
+
// Round-trip: collapse then expand.
|
|
111
|
+
(c.querySelector(".hk-mm-collapse-btn") as HTMLElement).click();
|
|
112
|
+
await nextTick();
|
|
113
|
+
(c.querySelector(".hk-minimap--collapsed") as HTMLElement).click();
|
|
114
|
+
await nextTick();
|
|
115
|
+
// The re-expanded card is a NEW element — the drag handlers must be
|
|
116
|
+
// on IT. A pointer sequence must reach the pan machinery: assert via
|
|
117
|
+
// the dragging data flag, which only onDown sets.
|
|
118
|
+
const card2 = c.querySelector(".hk-minimap") as HTMLElement;
|
|
119
|
+
expect(card2).not.toBe(card); // the node really was replaced
|
|
120
|
+
card2.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, clientX: 5, clientY: 5 }));
|
|
121
|
+
await nextTick(); // the data flag renders on the next patch
|
|
122
|
+
expect(card2.getAttribute("data-dragging")).toBe("");
|
|
123
|
+
card2.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
|
|
124
|
+
await nextTick();
|
|
125
|
+
expect(card2.getAttribute("data-dragging")).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("the collapsed glyph is a button, never a drag surface (delta F2)", async () => {
|
|
129
|
+
const c = mount({ boxes: BOXES, collapsible: true, collapsed: true });
|
|
130
|
+
const glyph = c.querySelector(".hk-minimap--collapsed") as HTMLElement;
|
|
131
|
+
glyph.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, clientX: 5, clientY: 5 }));
|
|
132
|
+
glyph.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, clientX: 25, clientY: 20 }));
|
|
133
|
+
await nextTick();
|
|
134
|
+
// The drag machinery never engaged: the swap element carries no
|
|
135
|
+
// data-dragging and a later expand starts clean.
|
|
136
|
+
expect(glyph.getAttribute("data-dragging")).toBeNull();
|
|
137
|
+
glyph.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
|
|
138
|
+
glyph.click();
|
|
139
|
+
await nextTick();
|
|
140
|
+
const card = c.querySelector(".hk-minimap") as HTMLElement;
|
|
141
|
+
expect(card.getAttribute("data-dragging")).toBeNull();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("a controlled `collapsed: true` starts collapsed even without a click", () => {
|
|
145
|
+
const c = mount({ boxes: BOXES, collapsible: true, collapsed: true });
|
|
146
|
+
expect(c.querySelector(".hk-minimap--collapsed")).toBeTruthy();
|
|
147
|
+
expect(c.querySelector("svg.hk-minimap-svg")).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -149,3 +149,37 @@
|
|
|
149
149
|
font-variant-numeric: tabular-nums;
|
|
150
150
|
color: rgb(var(--color-muted));
|
|
151
151
|
}
|
|
152
|
+
|
|
153
|
+
/* ── The collapsed affordance (user direction 2026-10-27) ─────────────
|
|
154
|
+
* The card folds down to a small map-icon button pinned where the card
|
|
155
|
+
* sat. It is deliberately SEMI-TRANSPARENT at rest — collapsed means
|
|
156
|
+
* "out of the way" and it must not compete with the content underneath —
|
|
157
|
+
* and only reaches full opacity on hover. */
|
|
158
|
+
.hk-minimap--collapsed {
|
|
159
|
+
display: inline-flex;
|
|
160
|
+
align-items: center;
|
|
161
|
+
justify-content: center;
|
|
162
|
+
width: 30px;
|
|
163
|
+
height: 30px;
|
|
164
|
+
padding: 0;
|
|
165
|
+
cursor: pointer;
|
|
166
|
+
color: rgb(var(--color-text) / 70%);
|
|
167
|
+
background: rgb(var(--color-surface) / 0.55);
|
|
168
|
+
border: 1px solid var(--border-faint);
|
|
169
|
+
border-radius: var(--radius-sm);
|
|
170
|
+
opacity: 0.55;
|
|
171
|
+
transition: opacity var(--duration-short, 0.15s), background var(--duration-short, 0.15s);
|
|
172
|
+
|
|
173
|
+
&:hover,
|
|
174
|
+
&:focus-visible {
|
|
175
|
+
opacity: 1;
|
|
176
|
+
background: rgb(var(--color-surface) / 0.9);
|
|
177
|
+
color: rgb(var(--color-text));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/* The minimize button rides the zoom bar's far end — visually set
|
|
182
|
+
* apart from the zoom pair it does not belong to. */
|
|
183
|
+
.hk-mm-collapse-btn {
|
|
184
|
+
margin-inline-start: auto;
|
|
185
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Maximize2, ZoomIn, ZoomOut } from "lucide-vue-next";
|
|
2
|
-
import { computed, defineComponent, onBeforeUnmount,
|
|
1
|
+
import { Map as MapIcon, Maximize2, Minimize2, ZoomIn, ZoomOut } from "lucide-vue-next";
|
|
2
|
+
import { computed, defineComponent, onBeforeUnmount, ref, watch, type PropType } from "vue";
|
|
3
3
|
|
|
4
4
|
import HSlider from "./HkSlider";
|
|
5
5
|
|
|
@@ -88,6 +88,14 @@ export default defineComponent({
|
|
|
88
88
|
* visible; the map is expected to reappear once it has something to
|
|
89
89
|
* say (the viewer switches this off when zoomed past fit). */
|
|
90
90
|
toolbarOnly: { type: Boolean, default: false },
|
|
91
|
+
/** Enable the collapse affordance (user direction 2026-10-27): the
|
|
92
|
+
* card grows a minimize button in its zoom bar, and the collapsed
|
|
93
|
+
* state renders as the small semi-transparent map-icon button
|
|
94
|
+
* (fully opaque on hover). */
|
|
95
|
+
collapsible: { type: Boolean, default: false },
|
|
96
|
+
/** The collapsed state (v-model:collapsed). `undefined` = uncontrolled
|
|
97
|
+
* (the component starts expanded and owns the flag). */
|
|
98
|
+
collapsed: { type: Boolean, default: undefined },
|
|
91
99
|
/** Optional prop-callback surface (alternative to the emits). */
|
|
92
100
|
onZoomTo: {
|
|
93
101
|
type: Function as PropType<(percent: number, source?: "slider" | "step") => void>,
|
|
@@ -97,6 +105,8 @@ export default defineComponent({
|
|
|
97
105
|
onPanDelta: { type: Function as PropType<(dx: number, dy: number) => void>, default: undefined },
|
|
98
106
|
},
|
|
99
107
|
emits: {
|
|
108
|
+
/** Collapse toggle (only emitted when `collapsible`). */
|
|
109
|
+
"update:collapsed": (_collapsed: boolean) => true,
|
|
100
110
|
/** Zoom request carrying the clamped target percent plus the gesture
|
|
101
111
|
* source — `"step"` (± buttons, relative one-rung request) vs
|
|
102
112
|
* `"slider"` (dropdown slider, ABSOLUTE target rung). */
|
|
@@ -109,9 +119,23 @@ export default defineComponent({
|
|
|
109
119
|
const svgW = 160;
|
|
110
120
|
const svgH = 110;
|
|
111
121
|
const rootRef = ref<HTMLElement | null>(null);
|
|
122
|
+
const internalCollapsed = ref(false);
|
|
123
|
+
const sliderOpen = ref(false);
|
|
124
|
+
const isCollapsed = computed(() =>
|
|
125
|
+
props.collapsed !== undefined ? props.collapsed : internalCollapsed.value);
|
|
126
|
+
function toggleCollapsed(next: boolean): void {
|
|
127
|
+
internalCollapsed.value = next;
|
|
128
|
+
if (next) {
|
|
129
|
+
sliderOpen.value = false;
|
|
130
|
+
// A mid-drag collapse swaps the element: no pointerup can reach
|
|
131
|
+
// the new root, so end the drag HERE or unpressed hovers would
|
|
132
|
+
// keep panning (delta S3 F4).
|
|
133
|
+
dragging.value = false;
|
|
134
|
+
}
|
|
135
|
+
emit("update:collapsed", next);
|
|
136
|
+
}
|
|
112
137
|
const dragging = ref(false);
|
|
113
138
|
const dragStart = ref({ x: 0, y: 0 });
|
|
114
|
-
const sliderOpen = ref(false);
|
|
115
139
|
|
|
116
140
|
const cb = computed(() => props.contentBounds);
|
|
117
141
|
const overpanW = computed(() => Math.max(props.contentBounds.w * 0.5, props.viewportWidth * 0.3));
|
|
@@ -166,6 +190,11 @@ export default defineComponent({
|
|
|
166
190
|
}
|
|
167
191
|
|
|
168
192
|
function onDown(e: PointerEvent) {
|
|
193
|
+
// The collapsed glyph is a plain button, never a drag surface: its
|
|
194
|
+
// only svg is the 16px lucide icon, which onMove's unit conversion
|
|
195
|
+
// would measure as a ~10x-amplified map (delta S2 F2 — a sloppy
|
|
196
|
+
// press-move on the default-state control jolted the camera).
|
|
197
|
+
if (isCollapsed.value) return;
|
|
169
198
|
const target = e.target as HTMLElement;
|
|
170
199
|
// Zoom chrome (bar + slider pop) must never start a map drag — the
|
|
171
200
|
// root's setPointerCapture would swallow the slider's gestures.
|
|
@@ -229,22 +258,7 @@ export default defineComponent({
|
|
|
229
258
|
}
|
|
230
259
|
});
|
|
231
260
|
|
|
232
|
-
onMounted(() => {
|
|
233
|
-
const el = rootRef.value;
|
|
234
|
-
if (!el) return;
|
|
235
|
-
el.addEventListener("pointerdown", onDown);
|
|
236
|
-
el.addEventListener("pointermove", onMove);
|
|
237
|
-
el.addEventListener("pointerup", onUp);
|
|
238
|
-
el.addEventListener("pointercancel", onUp);
|
|
239
|
-
});
|
|
240
261
|
onBeforeUnmount(() => {
|
|
241
|
-
const el = rootRef.value;
|
|
242
|
-
if (el) {
|
|
243
|
-
el.removeEventListener("pointerdown", onDown);
|
|
244
|
-
el.removeEventListener("pointermove", onMove);
|
|
245
|
-
el.removeEventListener("pointerup", onUp);
|
|
246
|
-
el.removeEventListener("pointercancel", onUp);
|
|
247
|
-
}
|
|
248
262
|
document.removeEventListener("pointerdown", onDocPointerDown, true);
|
|
249
263
|
document.removeEventListener("keydown", onDocKeydown, true);
|
|
250
264
|
});
|
|
@@ -279,12 +293,39 @@ export default defineComponent({
|
|
|
279
293
|
|
|
280
294
|
const hubP = props.hubPos ? toMap(props.hubPos.x, props.hubPos.y) : null;
|
|
281
295
|
|
|
296
|
+
if (isCollapsed.value) {
|
|
297
|
+
// The collapsed affordance: a small map-icon button pinned where
|
|
298
|
+
// the card sits — semi-transparent until hovered (it must not
|
|
299
|
+
// compete with the content underneath), fully opaque on hover.
|
|
300
|
+
return (
|
|
301
|
+
<button
|
|
302
|
+
ref={rootRef}
|
|
303
|
+
type="button"
|
|
304
|
+
class="hk-minimap hk-minimap--collapsed"
|
|
305
|
+
aria-label={t("hikari::zoomToolbar.expandMap", "Show minimap")}
|
|
306
|
+
title={t("hikari::zoomToolbar.expandMap", "Show minimap")}
|
|
307
|
+
onClick={() => toggleCollapsed(false)}
|
|
308
|
+
onPointerdown={onDown}
|
|
309
|
+
onPointermove={onMove}
|
|
310
|
+
onPointerup={onUp}
|
|
311
|
+
onPointercancel={onUp}
|
|
312
|
+
>
|
|
313
|
+
<MapIcon size={16} />
|
|
314
|
+
</button>
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
282
318
|
return (
|
|
283
319
|
<div
|
|
284
320
|
ref={rootRef}
|
|
285
321
|
class="hk-minimap"
|
|
286
322
|
data-dragging={dragging.value ? "" : undefined}
|
|
287
323
|
data-toolbar-only={props.toolbarOnly ? "" : undefined}
|
|
324
|
+
data-collapsible={props.collapsible ? "" : undefined}
|
|
325
|
+
onPointerdown={onDown}
|
|
326
|
+
onPointermove={onMove}
|
|
327
|
+
onPointerup={onUp}
|
|
328
|
+
onPointercancel={onUp}
|
|
288
329
|
>
|
|
289
330
|
{!props.toolbarOnly && (
|
|
290
331
|
<svg viewBox={`0 0 ${svgW} ${svgH}`} width={svgW} height={svgH} class="hk-minimap-svg">
|
|
@@ -369,6 +410,17 @@ export default defineComponent({
|
|
|
369
410
|
<Maximize2 size={11} />
|
|
370
411
|
</button>
|
|
371
412
|
)}
|
|
413
|
+
{props.collapsible && (
|
|
414
|
+
<button
|
|
415
|
+
class="hk-mm-zoom-btn hk-mm-collapse-btn"
|
|
416
|
+
type="button"
|
|
417
|
+
onClick={() => toggleCollapsed(true)}
|
|
418
|
+
aria-label={t("hikari::zoomToolbar.collapseMap", "Hide minimap")}
|
|
419
|
+
title={t("hikari::zoomToolbar.collapseMap", "Hide minimap")}
|
|
420
|
+
>
|
|
421
|
+
<Minimize2 size={11} />
|
|
422
|
+
</button>
|
|
423
|
+
)}
|
|
372
424
|
</div>
|
|
373
425
|
{sliderOpen.value && (
|
|
374
426
|
<div class="hk-mm-zoom-pop">
|
|
@@ -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/
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/* HkTreemapStage — the interactive treemap surface: a header with the
|
|
2
|
+
* back button + drill path, the pan/zoom cell canvas (the cells reuse
|
|
3
|
+
* HkTreemap's chrome classes verbatim — theme mortar, ellipsis labels),
|
|
4
|
+
* and the collapsible HkMinimap pinned bottom-right. */
|
|
5
|
+
|
|
6
|
+
.hk-treemap-stage {
|
|
7
|
+
position: relative;
|
|
8
|
+
display: flex;
|
|
9
|
+
flex-direction: column;
|
|
10
|
+
width: 100%;
|
|
11
|
+
height: 100%;
|
|
12
|
+
min-width: 0;
|
|
13
|
+
min-height: 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.hk-treemap-stage__head {
|
|
17
|
+
display: flex;
|
|
18
|
+
flex: 0 0 auto;
|
|
19
|
+
align-items: center;
|
|
20
|
+
gap: var(--space-6, 6px);
|
|
21
|
+
padding: var(--space-4, 4px) var(--space-8, 8px);
|
|
22
|
+
min-height: 32px;
|
|
23
|
+
border-bottom: 1px solid var(--border-faint);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/* Keeps the path centred when the back button is absent (root level). */
|
|
27
|
+
.hk-treemap-stage__head-pad {
|
|
28
|
+
width: 24px;
|
|
29
|
+
flex: 0 0 auto;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.hk-treemap-stage__path {
|
|
33
|
+
display: flex;
|
|
34
|
+
align-items: center;
|
|
35
|
+
min-width: 0;
|
|
36
|
+
gap: var(--space-4, 4px);
|
|
37
|
+
font-size: var(--text-xs, 0.75rem);
|
|
38
|
+
overflow: hidden;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.hk-treemap-stage__crumb {
|
|
42
|
+
appearance: none;
|
|
43
|
+
border: 0;
|
|
44
|
+
padding: 2px 4px;
|
|
45
|
+
background: transparent;
|
|
46
|
+
color: rgb(var(--color-muted));
|
|
47
|
+
cursor: pointer;
|
|
48
|
+
border-radius: var(--radius-xs, 3px);
|
|
49
|
+
white-space: nowrap;
|
|
50
|
+
overflow: hidden;
|
|
51
|
+
text-overflow: ellipsis;
|
|
52
|
+
max-width: 12rem;
|
|
53
|
+
|
|
54
|
+
&:hover {
|
|
55
|
+
color: rgb(var(--color-text));
|
|
56
|
+
background: rgb(var(--color-primary) / 6%);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
&[data-last] {
|
|
60
|
+
color: rgb(var(--color-text));
|
|
61
|
+
font-weight: 600;
|
|
62
|
+
cursor: default;
|
|
63
|
+
|
|
64
|
+
&:hover {
|
|
65
|
+
background: transparent;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.hk-treemap-stage__crumb-seg {
|
|
71
|
+
display: inline-flex;
|
|
72
|
+
align-items: center;
|
|
73
|
+
min-width: 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.hk-treemap-stage__crumb-sep {
|
|
77
|
+
color: rgb(var(--color-text) / 35%);
|
|
78
|
+
flex: 0 0 auto;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.hk-treemap-stage__body {
|
|
82
|
+
position: relative;
|
|
83
|
+
flex: 1 1 auto;
|
|
84
|
+
min-height: 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.hk-treemap-stage__canvas {
|
|
88
|
+
position: absolute;
|
|
89
|
+
inset: 0;
|
|
90
|
+
overflow: hidden;
|
|
91
|
+
background: rgb(var(--color-muted, 108 108 108) / 12%);
|
|
92
|
+
border-radius: var(--radius-sm, 4px);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.hk-treemap-stage__surface {
|
|
96
|
+
position: absolute;
|
|
97
|
+
inset: 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* Cells reuse .hk-treemap__cell chrome from HkTreemap.scss; the stage
|
|
101
|
+
* adds the drill affordance on group cells. */
|
|
102
|
+
.hk-treemap-stage__cell--drillable {
|
|
103
|
+
cursor: zoom-in;
|
|
104
|
+
|
|
105
|
+
&:hover {
|
|
106
|
+
outline: 2px solid rgb(var(--color-primary) / 70%);
|
|
107
|
+
outline-offset: -2px;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
&:focus-visible {
|
|
111
|
+
outline: 2px solid rgb(var(--color-primary));
|
|
112
|
+
outline-offset: -2px;
|
|
113
|
+
}
|
|
114
|
+
}
|