@celestia-island/hikari 0.55.206 → 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/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/HkThemeToggle.test.ts +8 -3
- package/src/components/HkThemeToggle.tsx +3 -1
- package/src/composables/useChromeActionSize.test.ts +65 -0
- package/src/composables/useChromeActionSize.ts +25 -0
- package/src/index.ts +3 -0
package/package.json
CHANGED
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
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
|
+
});
|
|
@@ -113,13 +113,18 @@ describe("HkThemeToggle trigger", () => {
|
|
|
113
113
|
// single button (user direction 2026-09-30) must be chrome-identical
|
|
114
114
|
// to the functional HkIconButtons hosts place beside it — the ghost
|
|
115
115
|
// variant's shared rest/hover/press paint, not a bespoke block. The
|
|
116
|
-
//
|
|
117
|
-
//
|
|
116
|
+
// size step is the chrome action ladder (2026-10-24 ruling via
|
|
117
|
+
// useChromeActionSize): ghost/32 wide, ghost/28 narrow — the rung is
|
|
118
|
+
// viewport-driven, so the test pins "the ladder's class", never one
|
|
119
|
+
// hard-coded step.
|
|
118
120
|
const triggers = container.querySelectorAll(".s-theme-toggle-btn");
|
|
119
121
|
expect(triggers).toHaveLength(1);
|
|
120
122
|
const trigger = triggers[0] as HTMLElement;
|
|
121
123
|
expect(trigger.classList.contains("hk-icon-button")).toBe(true);
|
|
122
|
-
expect(
|
|
124
|
+
expect(
|
|
125
|
+
trigger.classList.contains("hk-icon-button-28")
|
|
126
|
+
|| trigger.classList.contains("hk-icon-button-32"),
|
|
127
|
+
).toBe(true);
|
|
123
128
|
expect(trigger.classList.contains("hk-icon-button-ghost")).toBe(true);
|
|
124
129
|
// The retired main/arrow data hooks must never come back.
|
|
125
130
|
expect(trigger.hasAttribute("data-variant")).toBe(false);
|
|
@@ -6,6 +6,7 @@ import HkIconButton from "./HkIconButton";
|
|
|
6
6
|
import { HkThemePanelContent } from "./HkThemePanelContent";
|
|
7
7
|
import type { HCustomTheme } from "./HkColorSchemeDialog";
|
|
8
8
|
import "./HkThemeToggle.scss";
|
|
9
|
+
import { useChromeActionSize } from "../composables/useChromeActionSize";
|
|
9
10
|
|
|
10
11
|
export { type ThemeItemScope } from "./HkThemePanelContent";
|
|
11
12
|
|
|
@@ -80,6 +81,7 @@ export const HkThemeToggle = defineComponent({
|
|
|
80
81
|
},
|
|
81
82
|
setup(props, { emit, slots, expose }) {
|
|
82
83
|
const { t } = useI18n();
|
|
84
|
+
const chromeSize = useChromeActionSize();
|
|
83
85
|
const menuOpen = ref(false);
|
|
84
86
|
const triggerRef = ref<HTMLElement | null>(null);
|
|
85
87
|
|
|
@@ -104,7 +106,7 @@ export const HkThemeToggle = defineComponent({
|
|
|
104
106
|
* its own. */}
|
|
105
107
|
<HkIconButton
|
|
106
108
|
class="s-theme-toggle-btn"
|
|
107
|
-
size={
|
|
109
|
+
size={chromeSize.value}
|
|
108
110
|
variant="ghost"
|
|
109
111
|
aria-haspopup="menu"
|
|
110
112
|
aria-expanded={menuOpen.value ? "true" : "false"}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, h } from "vue";
|
|
3
|
+
|
|
4
|
+
import { useChromeActionSize } from "./useChromeActionSize";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The ladder's VALUES are viewport-dependent (useBreakpoint reads the
|
|
8
|
+
* real window width, and a jsdom default falls outside the wide rung),
|
|
9
|
+
* so this suite pins the CONTRACT — one source, both rungs reachable,
|
|
10
|
+
* never anything else — rather than a hard-coded mapping.
|
|
11
|
+
*/
|
|
12
|
+
describe("useChromeActionSize", () => {
|
|
13
|
+
it("returns a computed ref resolving to one of the two ladder rungs", () => {
|
|
14
|
+
const container = document.createElement("div");
|
|
15
|
+
document.body.appendChild(container);
|
|
16
|
+
const app = createApp({
|
|
17
|
+
setup() {
|
|
18
|
+
const size = useChromeActionSize();
|
|
19
|
+
// Render the resolved value into the DOM (setupState proxies are
|
|
20
|
+
// unwrapped — rendering is the honest read path).
|
|
21
|
+
return () => h("span", { "data-size": String(size.value) });
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
app.mount(container);
|
|
25
|
+
const span = container.querySelector("span")!;
|
|
26
|
+
expect(["28", "32"]).toContain(span.dataset.size!);
|
|
27
|
+
app.unmount();
|
|
28
|
+
container.remove();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("maps the viewport onto the ladder rungs (wide→32, narrow→28) and follows a flip", async () => {
|
|
32
|
+
// R3 S2 pin: nothing else pins WHICH rung a width maps to — a
|
|
33
|
+
// regression to a frozen constant passed the whole suite. Drive the
|
|
34
|
+
// REAL useBreakpoint (it listens to resize; no mock split needed).
|
|
35
|
+
const container = document.createElement("div");
|
|
36
|
+
document.body.appendChild(container);
|
|
37
|
+
const app = createApp({
|
|
38
|
+
setup() {
|
|
39
|
+
const size = useChromeActionSize();
|
|
40
|
+
return () => h("span", { "data-size": String(size.value) });
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
app.mount(container);
|
|
44
|
+
const read = () => container.querySelector("span")!.dataset.size!;
|
|
45
|
+
|
|
46
|
+
window.innerWidth = 1280;
|
|
47
|
+
window.dispatchEvent(new Event("resize"));
|
|
48
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
49
|
+
expect(read()).toBe("32");
|
|
50
|
+
|
|
51
|
+
window.innerWidth = 800;
|
|
52
|
+
window.dispatchEvent(new Event("resize"));
|
|
53
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
54
|
+
expect(read()).toBe("28");
|
|
55
|
+
|
|
56
|
+
// And back: the flip is followed, not frozen at first read.
|
|
57
|
+
window.innerWidth = 1280;
|
|
58
|
+
window.dispatchEvent(new Event("resize"));
|
|
59
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
60
|
+
expect(read()).toBe("32");
|
|
61
|
+
|
|
62
|
+
app.unmount();
|
|
63
|
+
container.remove();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { computed, type ComputedRef } from "vue";
|
|
2
|
+
|
|
3
|
+
import { useBreakpoint } from "../runtime/useBreakpoint";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The top-right chrome action size ladder (user ruling 2026-10-24):
|
|
7
|
+
* wide screens (≥1024) wear the generous ghost/32 cluster; narrower
|
|
8
|
+
* viewports tighten to ghost/28 so the row fits. ONE source for every
|
|
9
|
+
* header-chrome icon button — the global search and settings buttons,
|
|
10
|
+
* the page filter trigger, the refresh — so the cluster reads as one
|
|
11
|
+
* row of identical chrome on every face.
|
|
12
|
+
*
|
|
13
|
+
* This is the single authority; hand-rolled `isDesktop ? 32 : 28`
|
|
14
|
+
* copies in consumers drifted exactly the way this helper exists to
|
|
15
|
+
* prevent (2026-10-27: the filter triggers stayed at the old flat 28
|
|
16
|
+
* while the header moved to the ladder, and the funnel read as a
|
|
17
|
+
* different button family from its own neighbors).
|
|
18
|
+
*/
|
|
19
|
+
export function useChromeActionSize(): ComputedRef<28 | 32> {
|
|
20
|
+
const { isDesktop } = useBreakpoint();
|
|
21
|
+
// `?.` — partial breakpoint surfaces exist (test mocks, embedders that
|
|
22
|
+
// pass only what they use); an absent isDesktop reads as the NARROW
|
|
23
|
+
// rung, the safe-fitting choice for an unknown viewport.
|
|
24
|
+
return computed(() => (isDesktop?.value ? 32 : 28));
|
|
25
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -511,6 +511,7 @@ export {
|
|
|
511
511
|
export { usePageTitle, useRouteTitle } from "./composables/usePageTitle";
|
|
512
512
|
export { provideActionBar, useActionBar } from "./composables/useActionBar";
|
|
513
513
|
export type { ActionBarRenderer } from "./composables/useActionBar";
|
|
514
|
+
export { useChromeActionSize } from "./composables/useChromeActionSize";
|
|
514
515
|
export { leadingZeroBits, sha256, solvePow, solvePowSync, verifyPow } from "./utils/pow";
|
|
515
516
|
export type { PowChallenge, PowSolution } from "./utils/pow";
|
|
516
517
|
export { HkLocalePicker as HLocalePicker } from "./components/HkLocalePicker";
|
|
@@ -670,6 +671,8 @@ export { default as HkPopupSelect } from "./components/HkPopupSelect";
|
|
|
670
671
|
export { default as HkProgressBar } from "./components/HkProgressBar";
|
|
671
672
|
export { default as HkProgressDialog } from "./components/HkProgressDialog";
|
|
672
673
|
export { default as HkRadio } from "./components/HkRadio";
|
|
674
|
+
export { default as HkSegmentedRadio } from "./components/HkSegmentedRadio";
|
|
675
|
+
export type { HkSegmentedRadioOption } from "./components/HkSegmentedRadio";
|
|
673
676
|
export { default as HkScrollContainer } from "./components/HkScrollContainer";
|
|
674
677
|
export { default as HkSearchInput } from "./components/HkSearchInput";
|
|
675
678
|
export { default as HkSplash } from "./components/HkSplash";
|