@celestia-island/hikari 0.55.14 → 0.55.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -33,6 +33,72 @@
|
|
|
33
33
|
--hk-icon-group-item-size: 3.25rem;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/* ── Visual variants (orthogonal to the semantic mode) ──────────────────
|
|
37
|
+
*
|
|
38
|
+
* "track" (the default above) is the bordered inset strip. The two
|
|
39
|
+
* frameless variants drop the outer chrome — a frame is a host choice,
|
|
40
|
+
* not part of a group's meaning (2026-09-17 user direction):
|
|
41
|
+
* - "plain": independent buttons, still spaced, each tinting itself;
|
|
42
|
+
* - "slider": gapless packed row + one thumb pill that slides to the
|
|
43
|
+
* active item (single-selection only; the position is the active
|
|
44
|
+
* index × the item-size variable, so retuning the size keeps the
|
|
45
|
+
* thumb aligned with no JS measurement).
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
.hk-icon-group-plain {
|
|
49
|
+
padding: 0;
|
|
50
|
+
border: none;
|
|
51
|
+
background: transparent;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.hk-icon-group-slider {
|
|
55
|
+
/* A hair of inset so the thumb's rounded corners never clip — nothing
|
|
56
|
+
* like the track's full frame. */
|
|
57
|
+
--hk-icon-group-inset: 0.125rem;
|
|
58
|
+
|
|
59
|
+
position: relative;
|
|
60
|
+
padding: var(--hk-icon-group-inset);
|
|
61
|
+
border: none;
|
|
62
|
+
background: transparent;
|
|
63
|
+
gap: 0;
|
|
64
|
+
flex-wrap: nowrap;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.hk-icon-group-slider .hk-icon-group-item {
|
|
68
|
+
position: relative;
|
|
69
|
+
/* Items ride above the thumb so their hit targets and tooltips stay
|
|
70
|
+
* first-class while the pill glides behind them. */
|
|
71
|
+
z-index: 1;
|
|
72
|
+
/* Tooltip-less items are DIRECT flex children here — without this a
|
|
73
|
+
* width-constrained host could compress them while the thumb keeps
|
|
74
|
+
* the full item-size width (misalignment). Wrapped items are already
|
|
75
|
+
* flex:none via .hk-tooltip-wrapper. */
|
|
76
|
+
flex: none;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* The active item in slider mode carries no tint of its own — the thumb
|
|
80
|
+
* IS its highlight (keeps the sliding illusion seamless). Scoped to
|
|
81
|
+
* single mode: in "multiple" + slider the thumb is suppressed and the
|
|
82
|
+
* per-item tint is the ONLY active indication left. */
|
|
83
|
+
.hk-icon-group-single.hk-icon-group-slider .hk-icon-group-item[data-active] {
|
|
84
|
+
background: transparent;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.hk-icon-group-slider-thumb {
|
|
88
|
+
position: absolute;
|
|
89
|
+
top: var(--hk-icon-group-inset);
|
|
90
|
+
left: var(--hk-icon-group-inset);
|
|
91
|
+
width: var(--hk-icon-group-item-size);
|
|
92
|
+
height: var(--hk-icon-group-item-size);
|
|
93
|
+
border-radius: var(--radius-sm, 4px);
|
|
94
|
+
background: rgb(var(--color-primary, 90 140 255) / 14%);
|
|
95
|
+
transform: translateX(
|
|
96
|
+
calc(var(--hk-icon-group-active-index, 0) * var(--hk-icon-group-item-size))
|
|
97
|
+
);
|
|
98
|
+
transition: transform var(--duration, 0.18s) ease;
|
|
99
|
+
pointer-events: none;
|
|
100
|
+
}
|
|
101
|
+
|
|
36
102
|
/* The HkTooltip wrapper span is the flex child between items — let it
|
|
37
103
|
shrink to the button so the track gap (not the wrapper) spaces them. */
|
|
38
104
|
.hk-icon-group > .hk-tooltip-wrapper {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
1
3
|
import { afterEach, describe, expect, it } from "vitest";
|
|
2
4
|
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
3
5
|
|
|
@@ -179,4 +181,125 @@ describe("HkIconButtonGroup", () => {
|
|
|
179
181
|
expect(group.getAttribute("aria-label")).toBe("验证方式");
|
|
180
182
|
expect(group.getAttribute("data-test")).toBe("probe");
|
|
181
183
|
});
|
|
184
|
+
|
|
185
|
+
describe("visual variants", () => {
|
|
186
|
+
it("defaults to the track treatment", () => {
|
|
187
|
+
const c = mountComp(() => h(HkIconButtonGroup, { options }));
|
|
188
|
+
const group = c.querySelector<HTMLElement>(".hk-icon-group")!;
|
|
189
|
+
expect(group.classList.contains("hk-icon-group-track")).toBe(true);
|
|
190
|
+
expect(c.querySelector(".hk-icon-group-slider-thumb")).toBeNull();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("plain drops the frame class without touching semantics", async () => {
|
|
194
|
+
const model = ref<string | null>("totp");
|
|
195
|
+
const c = mountComp(() =>
|
|
196
|
+
h(HkIconButtonGroup, {
|
|
197
|
+
options,
|
|
198
|
+
mode: "single",
|
|
199
|
+
variant: "plain",
|
|
200
|
+
modelValue: model.value,
|
|
201
|
+
"onUpdate:modelValue": (v: string | string[]) => {
|
|
202
|
+
model.value = v as string;
|
|
203
|
+
},
|
|
204
|
+
}),
|
|
205
|
+
);
|
|
206
|
+
const group = c.querySelector<HTMLElement>(".hk-icon-group")!;
|
|
207
|
+
expect(group.classList.contains("hk-icon-group-plain")).toBe(true);
|
|
208
|
+
expect(group.classList.contains("hk-icon-group-track")).toBe(false);
|
|
209
|
+
// Selection semantics unchanged.
|
|
210
|
+
const buttons = [...c.querySelectorAll<HTMLButtonElement>(".hk-icon-group-item")];
|
|
211
|
+
expect(buttons[0]!.dataset.active).toBe("true");
|
|
212
|
+
buttons[1]!.click();
|
|
213
|
+
await nextTick();
|
|
214
|
+
expect(model.value).toBe("passkey");
|
|
215
|
+
expect(c.querySelector(".hk-icon-group-slider-thumb")).toBeNull();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("slider renders a thumb that slides to the active index", async () => {
|
|
219
|
+
const model = ref<string | null>("totp");
|
|
220
|
+
const c = mountComp(() =>
|
|
221
|
+
h(HkIconButtonGroup, {
|
|
222
|
+
options,
|
|
223
|
+
mode: "single",
|
|
224
|
+
variant: "slider",
|
|
225
|
+
modelValue: model.value,
|
|
226
|
+
"onUpdate:modelValue": (v: string | string[]) => {
|
|
227
|
+
model.value = v as string;
|
|
228
|
+
},
|
|
229
|
+
}),
|
|
230
|
+
);
|
|
231
|
+
const group = c.querySelector<HTMLElement>(".hk-icon-group")!;
|
|
232
|
+
expect(group.classList.contains("hk-icon-group-slider")).toBe(true);
|
|
233
|
+
const thumb = c.querySelector<HTMLElement>(".hk-icon-group-slider-thumb")!;
|
|
234
|
+
expect(thumb.getAttribute("aria-hidden")).toBe("true");
|
|
235
|
+
// Position = active index × item size, expressed through the CSS
|
|
236
|
+
// variable so a retuned size stays aligned.
|
|
237
|
+
expect(thumb.getAttribute("style")).toContain("--hk-icon-group-active-index: 0");
|
|
238
|
+
|
|
239
|
+
const buttons = [...c.querySelectorAll<HTMLButtonElement>(".hk-icon-group-item")];
|
|
240
|
+
buttons[1]!.click();
|
|
241
|
+
await nextTick();
|
|
242
|
+
const thumbAfter = c.querySelector<HTMLElement>(".hk-icon-group-slider-thumb")!;
|
|
243
|
+
expect(thumbAfter.getAttribute("style")).toContain("--hk-icon-group-active-index: 1");
|
|
244
|
+
expect(buttons[1]!.dataset.active).toBe("true");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("slider suppresses the thumb without a single active key", () => {
|
|
248
|
+
const noSelection = mountComp(() =>
|
|
249
|
+
h(HkIconButtonGroup, { options, mode: "single", variant: "slider", modelValue: null }),
|
|
250
|
+
);
|
|
251
|
+
expect(noSelection.querySelector(".hk-icon-group-slider-thumb")).toBeNull();
|
|
252
|
+
|
|
253
|
+
const multiple = mountComp(() =>
|
|
254
|
+
h(HkIconButtonGroup, {
|
|
255
|
+
options,
|
|
256
|
+
mode: "multiple",
|
|
257
|
+
variant: "slider",
|
|
258
|
+
modelValue: ["totp"],
|
|
259
|
+
}),
|
|
260
|
+
);
|
|
261
|
+
// "multiple" tints each item instead — one thumb cannot hop
|
|
262
|
+
// between simultaneous selections.
|
|
263
|
+
expect(multiple.querySelector(".hk-icon-group-slider-thumb")).toBeNull();
|
|
264
|
+
const items = [...multiple.querySelectorAll<HTMLElement>(".hk-icon-group-item")];
|
|
265
|
+
expect(items[0]!.dataset.active).toBe("true");
|
|
266
|
+
|
|
267
|
+
const buttons = mountComp(() =>
|
|
268
|
+
h(HkIconButtonGroup, { options, mode: "buttons", variant: "slider" }),
|
|
269
|
+
);
|
|
270
|
+
expect(buttons.querySelector(".hk-icon-group-slider-thumb")).toBeNull();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("keeps the per-item active tint in multiple+slider (SCSS contract)", () => {
|
|
274
|
+
// jsdom cannot compute styles, so pin the selector itself: the
|
|
275
|
+
// "active item paints transparent, the thumb is its highlight"
|
|
276
|
+
// rule must be scoped to single mode — in multiple+slider the
|
|
277
|
+
// thumb is suppressed and the per-item tint is the only active
|
|
278
|
+
// indication left (R1 finding: an unscoped rule wiped it).
|
|
279
|
+
// vitest transforms this file — import.meta.url is not a file:
|
|
280
|
+
// URL here; resolve from the package root (the runner's cwd).
|
|
281
|
+
const scss = readFileSync(
|
|
282
|
+
join(process.cwd(), "src/components/HkIconButtonGroup.scss"),
|
|
283
|
+
"utf-8",
|
|
284
|
+
);
|
|
285
|
+
expect(scss).toContain(
|
|
286
|
+
".hk-icon-group-single.hk-icon-group-slider .hk-icon-group-item[data-active]",
|
|
287
|
+
);
|
|
288
|
+
// Anchored to line start: the scoped selector CONTAINS the
|
|
289
|
+
// unscoped substring, so only a BOL match proves the rule lost
|
|
290
|
+
// its single-mode scoping.
|
|
291
|
+
expect(scss).not.toMatch(
|
|
292
|
+
/^\.hk-icon-group-slider \.hk-icon-group-item\[data-active\]/m,
|
|
293
|
+
);
|
|
294
|
+
// Same contract, sibling guard (R2 M6): direct-child items must
|
|
295
|
+
// not flex-shrink under a width-constrained host — the thumb keeps
|
|
296
|
+
// the full item-size width and would silently misalign behind
|
|
297
|
+
// compressed items.
|
|
298
|
+
const itemBlock = scss.match(
|
|
299
|
+
/\.hk-icon-group-slider \.hk-icon-group-item \{[^}]*\}/,
|
|
300
|
+
);
|
|
301
|
+
expect(itemBlock).toBeTruthy();
|
|
302
|
+
expect(itemBlock![0]).toContain("flex: none");
|
|
303
|
+
});
|
|
304
|
+
});
|
|
182
305
|
});
|
|
@@ -48,6 +48,19 @@ export interface HkIconButtonGroupOption {
|
|
|
48
48
|
* (text = the option's label unless overridden), so the hover reveal is
|
|
49
49
|
* built in rather than left to every consumer.
|
|
50
50
|
*
|
|
51
|
+
* Visual variants (`variant`, orthogonal to the semantic `mode` — the
|
|
52
|
+
* 2026-09-17 user direction: a frame is not part of a group's meaning,
|
|
53
|
+
* and hosts must be able to tune it):
|
|
54
|
+
* - "track" (default): the bordered inset strip — the HkTabs grammar
|
|
55
|
+
* this component shipped with;
|
|
56
|
+
* - "plain": no outer frame at all — independent icon buttons packed
|
|
57
|
+
* shoulder-to-shoulder, each tinting its own active state;
|
|
58
|
+
* - "slider": frameless and gapless with a sliding thumb pill behind
|
|
59
|
+
* the active item (single-selection mode; "multiple" falls back to
|
|
60
|
+
* per-item tints). The thumb rides a CSS-variable index
|
|
61
|
+
* (`--hk-icon-group-active-index`) times the item-size variable, so
|
|
62
|
+
* it stays correct when hosts retune the size.
|
|
63
|
+
*
|
|
51
64
|
* Known a11y limitation (documented 2026-09-13): single mode uses a
|
|
52
65
|
* radiogroup role with every item tabbable, not the full WAI-APG roving
|
|
53
66
|
* tabindex. All-items-tabbable keeps keyboard reachability identical
|
|
@@ -63,6 +76,8 @@ export default defineComponent({
|
|
|
63
76
|
* active — required/ignored by "buttons" mode. */
|
|
64
77
|
modelValue: { type: [String, Array] as PropType<string | string[] | null>, default: null },
|
|
65
78
|
mode: { type: String as PropType<"buttons" | "single" | "multiple">, default: "buttons" },
|
|
79
|
+
/** Visual treatment of the strip — see the component doc. */
|
|
80
|
+
variant: { type: String as PropType<"track" | "plain" | "slider">, default: "track" },
|
|
66
81
|
size: { type: String as PropType<"sm" | "md" | "lg">, default: "md" },
|
|
67
82
|
disabled: { type: Boolean, default: false },
|
|
68
83
|
tooltipPlacement: { type: String as PropType<TooltipPlacement>, default: "top" },
|
|
@@ -79,11 +94,36 @@ export default defineComponent({
|
|
|
79
94
|
const cls = computed(() => [
|
|
80
95
|
"hk-icon-group",
|
|
81
96
|
`hk-icon-group-${props.mode}`,
|
|
97
|
+
`hk-icon-group-${props.variant}`,
|
|
82
98
|
`hk-icon-group-${props.size}`,
|
|
83
99
|
]);
|
|
84
100
|
|
|
85
101
|
const groupRole = computed(() => (props.mode === "single" ? "radiogroup" : "group"));
|
|
86
102
|
|
|
103
|
+
/** Index of the single active option, -1 when none — drives the
|
|
104
|
+
* slider thumb's CSS-variable position. */
|
|
105
|
+
const activeIndex = computed(() =>
|
|
106
|
+
props.options.findIndex((o) => isActive(o.key)),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
/** The slider thumb exists only for a single-selection strip with an
|
|
110
|
+
* active key: "multiple" tints each item instead (one thumb cannot
|
|
111
|
+
* hop between simultaneous selections), "buttons" has no selection
|
|
112
|
+
* at all. */
|
|
113
|
+
const showThumb = computed(
|
|
114
|
+
() =>
|
|
115
|
+
props.variant === "slider" &&
|
|
116
|
+
props.mode === "single" &&
|
|
117
|
+
activeIndex.value >= 0,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const thumbStyle = computed(() => ({
|
|
121
|
+
// The transition lives in the stylesheet; the position is pure
|
|
122
|
+
// arithmetic on the active index so a retuned item-size variable
|
|
123
|
+
// keeps the thumb aligned without JS measurement.
|
|
124
|
+
"--hk-icon-group-active-index": String(Math.max(activeIndex.value, 0)),
|
|
125
|
+
}));
|
|
126
|
+
|
|
87
127
|
function isActive(key: string): boolean {
|
|
88
128
|
if (props.mode === "multiple") {
|
|
89
129
|
return Array.isArray(props.modelValue) && props.modelValue.includes(key);
|
|
@@ -111,6 +151,13 @@ export default defineComponent({
|
|
|
111
151
|
|
|
112
152
|
return () => (
|
|
113
153
|
<div class={cls.value} role={groupRole.value} {...attrs}>
|
|
154
|
+
{showThumb.value && (
|
|
155
|
+
<span
|
|
156
|
+
class="hk-icon-group-slider-thumb"
|
|
157
|
+
aria-hidden="true"
|
|
158
|
+
style={thumbStyle.value}
|
|
159
|
+
/>
|
|
160
|
+
)}
|
|
114
161
|
{props.options.map((option) => {
|
|
115
162
|
const tooltip = option.tooltip !== undefined ? option.tooltip : option.label;
|
|
116
163
|
const active = isActive(option.key);
|