@celestia-island/hikari 0.55.273 → 0.55.275

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.273",
3
+ "version": "0.55.275",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -40,15 +40,41 @@
40
40
  font-variant-numeric: tabular-nums;
41
41
  }
42
42
 
43
+ // Above-bar value label (showValues) — the same 10-unit voice as the
44
+ // axis, a step stronger so the count reads first.
45
+ &__value {
46
+ fill: rgb(var(--color-text) / 0.75);
47
+ font-size: 10px;
48
+ text-anchor: middle;
49
+ font-variant-numeric: tabular-nums;
50
+ }
51
+
52
+ // Slot mark (a bar's icon — the supership Star): a nested <svg> whose
53
+ // ink follows currentColor, so lucide's stroke picks up the axis voice
54
+ // without per-icon wiring.
55
+ &__xmark {
56
+ color: rgb(var(--color-text) / 0.55);
57
+ }
58
+
43
59
  &__bar {
44
60
  fill: rgb(var(--color-primary));
45
61
  outline: none;
46
62
  cursor: pointer;
47
- transition: filter 0.16s ease;
63
+ transition: filter 0.16s ease, opacity 0.16s ease;
48
64
 
49
65
  &:hover,
50
- &:focus-visible {
66
+ &:focus-visible,
67
+ // Dim-pair head (activeIndex): keeps the hover lift while the
68
+ // pointer may sit anywhere — the host pairs bars by wiring
69
+ // activeIndex from the hover events.
70
+ &[data-hot] {
51
71
  filter: brightness(1.08) saturate(1.15);
52
72
  }
73
+
74
+ // Dim-pair follower: every other bar recedes, same ease family as
75
+ // the lift so the pair breathes together.
76
+ &[data-dim] {
77
+ opacity: 0.45;
78
+ }
53
79
  }
54
80
  }
@@ -27,6 +27,10 @@ const BARS = [
27
27
  { label: "Wed", value: 0 },
28
28
  ];
29
29
 
30
+ /** A tiny stand-in for a lucide glyph: any icon FUNCTION draws the
31
+ * 24-unit box the slot-mark wrapper maps onto its 12-unit mark. */
32
+ const StarGlyph = () => <rect x="0" y="0" width="24" height="24" />;
33
+
30
34
  describe("HkBarChart", () => {
31
35
  it("renders one labeled bar per bucket and skips zero buckets' paths", () => {
32
36
  const el = mount(<HkBarChart bars={BARS} />);
@@ -59,4 +63,126 @@ describe("HkBarChart", () => {
59
63
  "weekly playtime",
60
64
  );
61
65
  });
66
+
67
+ // ── showValues ───────────────────────────────────────────────────
68
+
69
+ it("showValues renders formatValue above each bar and skips zero buckets", () => {
70
+ const el = mount(
71
+ <HkBarChart bars={BARS} showValues formatValue={(v) => `${Math.round(v / 3600)}h`} />,
72
+ );
73
+ const values = [...el.querySelectorAll(".hk-bar-chart__value")];
74
+ // The zero bucket (Wed) carries no label — the wowsp tier histogram's
75
+ // old labelY-null behavior: an empty slot never shows a stray "0".
76
+ expect(values.map((t) => t.textContent)).toEqual(["1h", "2h"]);
77
+ // Each label's baseline sits exactly 5 units above its bar's top —
78
+ // the path's y coordinates sit at odd indices ("M x0 y L x0 y… Q x y
79
+ // x y …"), and their minimum IS the top.
80
+ const topOf = (d: string) => {
81
+ const nums = d.split(/[\sMLQAZ]+/).filter(Boolean).map(Number);
82
+ return Math.min(...nums.filter((_, i) => i % 2 === 1));
83
+ };
84
+ const tops = [...el.querySelectorAll(".hk-bar-chart__bar")].map((p) =>
85
+ topOf(p.getAttribute("d") ?? ""),
86
+ );
87
+ expect(Number(values[0].getAttribute("y"))).toBeCloseTo(tops[0] - 5, 6);
88
+ expect(Number(values[1].getAttribute("y"))).toBeCloseTo(tops[1] - 5, 6);
89
+ });
90
+
91
+ it("showValues off by default — old usage renders no value labels", () => {
92
+ const el = mount(<HkBarChart bars={BARS} />);
93
+ expect(el.querySelectorAll(".hk-bar-chart__value")).toHaveLength(0);
94
+ });
95
+
96
+ // ── hover event + activeIndex dim-pair ───────────────────────────
97
+
98
+ it("emits hover on bar mouseenter / mouseleave with focus / blur parity", () => {
99
+ const hovers: (number | null)[] = [];
100
+ const el = mount(<HkBarChart bars={BARS} onHover={(i) => hovers.push(i)} />);
101
+ const bars = [...el.querySelectorAll(".hk-bar-chart__bar")];
102
+ bars[0].dispatchEvent(new Event("mouseenter"));
103
+ bars[1].dispatchEvent(new Event("focus"));
104
+ bars[1].dispatchEvent(new Event("blur"));
105
+ bars[0].dispatchEvent(new Event("mouseleave"));
106
+ expect(hovers).toEqual([0, 1, null, null]);
107
+ });
108
+
109
+ it("activeIndex carries data-hot on the paired bar and data-dim on the others", () => {
110
+ const el = mount(<HkBarChart bars={BARS} activeIndex={0} />);
111
+ const bars = [...el.querySelectorAll(".hk-bar-chart__bar")];
112
+ expect(bars[0].getAttribute("data-hot")).toBe("true");
113
+ expect(bars[0].hasAttribute("data-dim")).toBe(false);
114
+ expect(bars[1].getAttribute("data-dim")).toBe("true");
115
+ expect(bars[1].hasAttribute("data-hot")).toBe(false);
116
+ });
117
+
118
+ it("defaults to no pair — no data-hot / data-dim anywhere", () => {
119
+ const el = mount(<HkBarChart bars={BARS} />);
120
+ expect(el.querySelector("[data-hot], [data-dim]")).toBeNull();
121
+ });
122
+
123
+ // ── icon slot mark (the supership Star) ──────────────────────────
124
+
125
+ it("renders a resolvable icon in place of the x-label text, sized and centered", () => {
126
+ const el = mount(
127
+ <HkBarChart
128
+ bars={[
129
+ { label: "10", value: 5, icon: StarGlyph },
130
+ { label: "11", value: 7 },
131
+ ]}
132
+ />,
133
+ );
134
+ const marks = [...el.querySelectorAll(".hk-bar-chart__xmark")];
135
+ expect(marks).toHaveLength(1);
136
+ // The nested-svg wrapper maps the 24-unit lucide box onto a 12-unit
137
+ // mark centered on slot 0 (centerX 182) at the x-label's optical
138
+ // center (baselineY 196 + 4).
139
+ expect(marks[0].getAttribute("viewBox")).toBe("0 0 24 24");
140
+ expect(marks[0].getAttribute("width")).toBe("12");
141
+ expect(marks[0].getAttribute("height")).toBe("12");
142
+ expect(marks[0].getAttribute("x")).toBe("176");
143
+ expect(marks[0].getAttribute("y")).toBe("200");
144
+ // The icon swapped out that slot's text; the other slot keeps its text.
145
+ expect([...el.querySelectorAll(".hk-bar-chart__x")].map((t) => t.textContent)).toEqual([
146
+ "11",
147
+ ]);
148
+ // The bar's aria string keeps the TEXT label, icon or not.
149
+ expect(el.querySelector(".hk-bar-chart__bar")?.getAttribute("aria-label")).toContain("10");
150
+ });
151
+
152
+ it("normalizes icons like HkPillToggleGroup — vnode passes through, junk falls back to text", () => {
153
+ const el = mount(
154
+ <HkBarChart
155
+ bars={[
156
+ { label: "a", value: 3, icon: <rect x="4" y="4" width="16" height="16" /> },
157
+ { label: "b", value: 4, icon: "not-an-icon" },
158
+ ]}
159
+ />,
160
+ );
161
+ // The prebuilt vnode rides the mark wrapper; the junk string does not
162
+ // blank the slot — it falls back to the text label.
163
+ expect(el.querySelectorAll(".hk-bar-chart__xmark")).toHaveLength(1);
164
+ expect(el.querySelectorAll(".hk-bar-chart__xmark rect")).toHaveLength(1);
165
+ expect([...el.querySelectorAll(".hk-bar-chart__x")].map((t) => t.textContent)).toEqual(["b"]);
166
+ });
167
+
168
+ // ── back-compat: the wowsp PlaytimeView usage surface ────────────
169
+
170
+ it("old-props usage renders exactly the old surface", () => {
171
+ const el = mount(
172
+ <HkBarChart
173
+ bars={BARS.map((b) => ({ label: b.label, value: b.value, hint: b.hint }))}
174
+ width={480}
175
+ formatValue={(v) => `${Math.round(v / 3600)}h`}
176
+ />,
177
+ );
178
+ expect(el.querySelectorAll(".hk-bar-chart__bar")).toHaveLength(2);
179
+ expect(el.querySelectorAll(".hk-bar-chart__value")).toHaveLength(0);
180
+ expect(el.querySelectorAll(".hk-bar-chart__xmark")).toHaveLength(0);
181
+ expect(el.querySelector("[data-hot], [data-dim]")).toBeNull();
182
+ expect([...el.querySelectorAll(".hk-bar-chart__x")].map((t) => t.textContent)).toEqual([
183
+ "Mon",
184
+ "Tue",
185
+ "Wed",
186
+ ]);
187
+ });
62
188
  });
@@ -1,8 +1,43 @@
1
- import { computed, defineComponent, type PropType } from "vue";
1
+ import { computed, defineComponent, h, type PropType, type VNode } from "vue";
2
2
 
3
3
  import { barLayout, type BarSlot } from "./barChart";
4
4
  import "./HkBarChart.scss";
5
5
 
6
+ /** One bar entry. */
7
+ export interface HkBarChartBar {
8
+ /** Text label under the slot — also the bar's aria string, even when
9
+ * an `icon` replaces the visible text. */
10
+ label: string;
11
+ value: number;
12
+ /** Hover/focus hint; defaults to `label: formatValue(value)`. */
13
+ hint?: string;
14
+ /** Optional slot mark rendered IN PLACE of the x-label text (the tier
15
+ * histogram's supership Star). Accepts EITHER a component (a lucide
16
+ * icon function, a defineComponent options object) or a prebuilt
17
+ * vnode — normalized exactly like HkPillToggleGroup's icon handling;
18
+ * anything else falls back to the text label (never a blank slot). */
19
+ icon?: unknown;
20
+ }
21
+
22
+ /** Normalize a bar icon to a renderable vnode — the HkPillToggleGroup
23
+ * convention verbatim: a component (function or options object) is
24
+ * instantiated with `h`; a prebuilt vnode passes through; anything
25
+ * else (string, number, null) renders nothing, and the caller falls
26
+ * back to the text label. */
27
+ function resolveIcon(icon: unknown): VNode | null {
28
+ if (icon == null) return null;
29
+ if (typeof icon === "object") return icon as VNode;
30
+ if (typeof icon === "function") return h(icon as Parameters<typeof h>[0]);
31
+ return null;
32
+ }
33
+
34
+ /** The slot-mark box (wowsp's tier-star convention): lucide glyphs draw
35
+ * in a 24×24 box, so the wrapper nested <svg> maps that box onto a
36
+ * 12-unit mark centered on the x-label's optical center — the 10-unit
37
+ * text's baseline is baselineY + 14 and its glyph box spans
38
+ * ≈ baselineY + 6 … + 14, so a mark from +4 to +16 shares its center. */
39
+ const BAR_MARK = { glyphBox: 24, size: 12, top: 4 } as const;
40
+
6
41
  /**
7
42
  * HkBarChart — one bar per bucket over a hand-drawn declarative SVG (no
8
43
  * chart library; the HkTreemap/HkCalendarHeatmap dependency-free line).
@@ -16,6 +51,15 @@ import "./HkBarChart.scss";
16
51
  * wowsp delegated-tooltip hook reads — hikari itself renders no native
17
52
  * title bubble).
18
53
  *
54
+ * The tier histogram's needs ride along as OPTIONAL props (wowsp
55
+ * ShipDistCharts): `showValues` paints formatValue(value) above each
56
+ * bar (empty slots carry no label), `hover` + `activeIndex` implement a
57
+ * dim-pair — the HOST wires activeIndex from the hover events, the bar
58
+ * at activeIndex carries `data-hot` and every other bar `data-dim` —
59
+ * and a bar's `icon` replaces its x-label text (the supership slot's
60
+ * nested-svg Star, positioned/sized here from the 24-unit lucide box).
61
+ * Defaults reproduce the old output exactly.
62
+ *
19
63
  * Dumb by design: the caller owns aggregation and passes the settled
20
64
  * buckets in. All geometry lives in ./barChart (pure, pinned by its own
21
65
  * tests).
@@ -27,7 +71,7 @@ export default defineComponent({
27
71
  props: {
28
72
  /** The bars, in axis order (oldest → newest for trend charts). */
29
73
  bars: {
30
- type: Array as PropType<{ label: string; value: number; hint?: string }[]>,
74
+ type: Array as PropType<HkBarChartBar[]>,
31
75
  required: true,
32
76
  },
33
77
  /** Frame size (the SVG viewBox; CSS scales it). */
@@ -42,10 +86,25 @@ export default defineComponent({
42
86
  ariaLabel: { type: String, default: null },
43
87
  /** Rendered when every bucket is zero / the array is empty. */
44
88
  emptyText: { type: String, default: "No data" },
89
+ /** Paint formatValue(value) as a small label ABOVE each bar. Skip
90
+ * rule: bars with value <= 0 carry NO label (their slot stays an
91
+ * empty slot) — the wowsp tier histogram's old labelY-null behavior,
92
+ * so an unplayed bucket never shows a stray "0". */
93
+ showValues: { type: Boolean, default: false },
94
+ /** The dim-pair head: when set, the bar at this index carries
95
+ * `data-hot` (kept hover lift) and every other bar carries
96
+ * `data-dim` (≈0.45 opacity). The HOST implements the pair by
97
+ * wiring this from the `hover` events; null (the default) draws no
98
+ * pair at all. */
99
+ activeIndex: { type: Number as PropType<number | null>, default: null },
45
100
  },
46
101
  emits: {
47
102
  /** A bar was clicked (its bucket index). */
48
103
  select: (index: number) => typeof index === "number",
104
+ /** The pointer entered a bar (its bucket index) or left it / the
105
+ * keyboard focus moved onto or off one (focus/blur parity — null
106
+ * when the hover ended). */
107
+ hover: (index: number | null) => typeof index === "number" || index === null,
49
108
  },
50
109
  setup(props, { emit }) {
51
110
  const layout = computed(() =>
@@ -60,7 +119,12 @@ export default defineComponent({
60
119
  props.ariaLabel ??
61
120
  props.bars.map((b) => `${b.label} ${props.formatValue(b.value)}`).join(", "),
62
121
  );
63
- const hintOf = (b: { label: string; value: number; hint?: string }, i: number): string =>
122
+ /** The dim-pair head, normalized: anything but a number (undefined,
123
+ * null, garbage) means "no pair". */
124
+ const active = computed(() =>
125
+ typeof props.activeIndex === "number" ? props.activeIndex : null,
126
+ );
127
+ const hintOf = (b: HkBarChartBar, i: number): string =>
64
128
  b.hint ?? `${b.label}: ${props.formatValue(b.value)}`;
65
129
 
66
130
  return () => {
@@ -90,23 +154,58 @@ export default defineComponent({
90
154
  </text>
91
155
  </g>
92
156
  ))}
93
- {l.bars.map((s: BarSlot, i: number) => (
94
- <g key={i}>
95
- {s.path ? (
96
- <path
97
- class="hk-bar-chart__bar"
98
- d={s.path}
99
- data-hint={hintOf(props.bars[i], i)}
100
- aria-label={hintOf(props.bars[i], i)}
101
- tabindex={0}
102
- onClick={() => emit("select", i)}
103
- />
104
- ) : null}
105
- <text class="hk-bar-chart__x" x={s.centerX} y={l.baselineY + 14}>
106
- {props.bars[i].label}
107
- </text>
108
- </g>
109
- ))}
157
+ {l.bars.map((s: BarSlot, i: number) => {
158
+ const bar = props.bars[i];
159
+ const mark = resolveIcon(bar.icon);
160
+ return (
161
+ <g key={i}>
162
+ {s.path ? (
163
+ <path
164
+ class="hk-bar-chart__bar"
165
+ d={s.path}
166
+ data-hint={hintOf(bar, i)}
167
+ aria-label={hintOf(bar, i)}
168
+ tabindex={0}
169
+ data-hot={active.value === i || undefined}
170
+ data-dim={(active.value !== null && active.value !== i) || undefined}
171
+ onClick={() => emit("select", i)}
172
+ onMouseenter={() => emit("hover", i)}
173
+ onMouseleave={() => emit("hover", null)}
174
+ onFocus={() => emit("hover", i)}
175
+ onBlur={() => emit("hover", null)}
176
+ />
177
+ ) : null}
178
+ {props.showValues && s.labelY != null ? (
179
+ <text class="hk-bar-chart__value" x={s.centerX} y={s.labelY}>
180
+ {props.formatValue(bar.value)}
181
+ </text>
182
+ ) : null}
183
+ {mark != null ? (
184
+ // The slot mark (the supership's Star): a nested <svg>
185
+ // wrapper maps the icon's 24-unit glyph box onto a
186
+ // 12-unit mark centered on the x-label's optical
187
+ // center — positioned HERE so the consumer passes a
188
+ // bare lucide component and knows no geometry. Ink
189
+ // (currentColor) lives in SCSS.
190
+ <svg
191
+ class="hk-bar-chart__xmark"
192
+ x={s.centerX - BAR_MARK.size / 2}
193
+ y={l.baselineY + BAR_MARK.top}
194
+ width={BAR_MARK.size}
195
+ height={BAR_MARK.size}
196
+ viewBox={`0 0 ${BAR_MARK.glyphBox} ${BAR_MARK.glyphBox}`}
197
+ aria-hidden="true"
198
+ >
199
+ {mark}
200
+ </svg>
201
+ ) : (
202
+ <text class="hk-bar-chart__x" x={s.centerX} y={l.baselineY + 14}>
203
+ {bar.label}
204
+ </text>
205
+ )}
206
+ </g>
207
+ );
208
+ })}
110
209
  </svg>
111
210
  );
112
211
  };
@@ -65,6 +65,10 @@ button.hk-persistent-toast {
65
65
  color: rgb(var(--color-muted));
66
66
  }
67
67
 
68
+ .hk-persistent-toast--success .hk-persistent-toast__icon {
69
+ color: rgb(var(--color-success));
70
+ }
71
+
68
72
  .hk-persistent-toast__label {
69
73
  overflow: hidden;
70
74
  text-overflow: ellipsis;
@@ -111,6 +111,15 @@ describe("HkPersistentToast inert default", () => {
111
111
  expect(container.querySelector(".hk-persistent-toast__spinner")).not.toBeNull();
112
112
  });
113
113
 
114
+ it("renders a static icon for the success tone", () => {
115
+ const { container } = mount({ props: { tone: "success", label: "Saved" } });
116
+ const c = chip(container);
117
+ expect(c.classList.contains("hk-persistent-toast--success")).toBe(true);
118
+ // Success renders a static lucide icon like the other non-loading tones.
119
+ expect(container.querySelector(".hk-persistent-toast__spinner")).toBeNull();
120
+ expect(c.querySelector("svg")).not.toBeNull();
121
+ });
122
+
114
123
  it("renders the ✕ sibling button only when dismissible", () => {
115
124
  const onDismiss = vi.fn();
116
125
  const { container } = mount({ props: { label: "x", dismissible: true }, onDismiss });
@@ -1,4 +1,4 @@
1
- import { AlertTriangle, Info, X } from "lucide-vue-next";
1
+ import { AlertTriangle, Check, Info, X } from "lucide-vue-next";
2
2
  import {
3
3
  computed,
4
4
  defineComponent,
@@ -15,7 +15,7 @@ import { useI18n } from "../i18n/context";
15
15
  import "./HkPersistentToast.scss";
16
16
  import { ensureOverlayRoot } from "../overlay/overlayRoot";
17
17
 
18
- export type PersistentToastTone = "loading" | "info" | "warning" | "error";
18
+ export type PersistentToastTone = "loading" | "info" | "warning" | "error" | "success";
19
19
 
20
20
  /** Viewport gutter the detail card clamps into (mirror of
21
21
  * --viewport-gutter's desktop value; the card is chrome-adjacent and
@@ -251,6 +251,8 @@ export default defineComponent({
251
251
  <span class="hk-persistent-toast__spinner" aria-hidden="true" />
252
252
  ) : props.tone === "error" || props.tone === "warning" ? (
253
253
  <AlertTriangle size={12} class="hk-persistent-toast__icon" aria-hidden="true" />
254
+ ) : props.tone === "success" ? (
255
+ <Check size={12} class="hk-persistent-toast__icon" aria-hidden="true" />
254
256
  ) : (
255
257
  <Info size={12} class="hk-persistent-toast__icon" aria-hidden="true" />
256
258
  )}