@celestia-island/hikari 0.55.14 → 0.55.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.14",
3
+ "version": "0.55.16",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
package/src/DemoApp.tsx CHANGED
@@ -367,6 +367,8 @@ export default defineComponent({
367
367
  <HSearchInput modelValue={form.value.search} onUpdate:modelValue={(v: string | number) => (form.value.search = String(v))} placeholder="Search..." />
368
368
  <HNumberInput modelValue={form.value.number} onUpdate:modelValue={(v: number) => (form.value.number = v)} placeholder="Number" />
369
369
  <HInput variant="password" modelValue={form.value.password} onUpdate:modelValue={(v: string) => (form.value.password = v)} placeholder="Password (hold the eye)" />
370
+ <HInput variant="password" revealStrategy="plain" modelValue={form.value.password} onUpdate:modelValue={(v: string) => (form.value.password = v)} placeholder="Password (plain reveal)" />
371
+ <HInput variant="password" revealTrigger="toggle" modelValue={form.value.password} onUpdate:modelValue={(v: string) => (form.value.password = v)} placeholder="Password (click eye toggles)" />
370
372
  <HInput variant="password" passwordTrailing="strength" modelValue={form.value.password} onUpdate:modelValue={(v: string) => (form.value.password = v)} placeholder="Password (strength light)" />
371
373
  <HInput variant="password" passwordTrailing="none" modelValue={form.value.password} onUpdate:modelValue={(v: string) => (form.value.password = v)} placeholder="Password (no affordance)" />
372
374
  <HTextarea modelValue={form.value.textarea} onUpdate:modelValue={(v: string) => (form.value.textarea = v)} placeholder="Textarea" rows={3} />
@@ -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);