@celestia-island/hikari 0.40.36 → 0.41.0

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.40.36",
3
+ "version": "0.41.0",
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",
@@ -38,6 +38,52 @@ afterEach(() => {
38
38
  });
39
39
 
40
40
  describe("HkStatusBar", () => {
41
+ it("renders extraDetails rows capped at 400px with an ellipsized, title-backed value", async () => {
42
+ const container = mountBar({
43
+ version: "1.2.3",
44
+ connectionStatus: "connected",
45
+ connectionInfo: INFO,
46
+ extraDetails: [
47
+ { key: "gateway", label: "Gateway", value: "gateway.celestia.world" },
48
+ ],
49
+ });
50
+ await nextTick();
51
+ // Popover content teleports to <body> and only renders once the tag
52
+ // is hovered open (same grammar as the protocol/network rows).
53
+ const tag = container.querySelector<HTMLElement>(".s-status-bar-tag")!;
54
+ tag.dispatchEvent(new MouseEvent("mouseenter"));
55
+ await nextTick();
56
+ const panel = document.body.querySelector<HTMLElement>(".hk-popover-panel");
57
+ expect(panel, "popover opens on hover").toBeTruthy();
58
+ const row = panel!.querySelector('[data-extra-detail="gateway"]');
59
+ expect(row).not.toBeNull();
60
+ expect((row as HTMLElement).style.maxWidth).toBe("400px");
61
+ const value = panel!.querySelector("span[title]") as HTMLElement;
62
+ expect(value.textContent).toBe("gateway.celestia.world");
63
+ expect(value.title).toBe("gateway.celestia.world");
64
+ expect(value.style.textOverflow).toBe("ellipsis");
65
+ // The label column keeps a minimum width — a long value must not
66
+ // squeeze it (the row space-between-aligns instead).
67
+ const label = row!.querySelector("span[style*='min-width'], span:nth-child(2)") as HTMLElement;
68
+ expect(label.style.minWidth).toBe("72px");
69
+ expect(label.style.flexShrink).toBe("0");
70
+ });
71
+
72
+ it("renders no extraDetails rows when the prop is absent", async () => {
73
+ const container = mountBar({
74
+ version: "1.2.3",
75
+ connectionStatus: "connected",
76
+ connectionInfo: INFO,
77
+ });
78
+ await nextTick();
79
+ container
80
+ .querySelector<HTMLElement>(".s-status-bar-tag")!
81
+ .dispatchEvent(new MouseEvent("mouseenter"));
82
+ await nextTick();
83
+ const panel = document.body.querySelector(".hk-popover-panel");
84
+ expect(panel?.querySelector("[data-extra-detail]")).toBeNull();
85
+ });
86
+
41
87
  it("mounts with the traffic light and version rows inline, protocol row on hover-open", async () => {
42
88
  const container = mountBar({
43
89
  version: "1.2.3",
@@ -96,6 +96,21 @@ export const HkStatusBar = defineComponent({
96
96
  transportTier: { type: String as PropType<string>, default: undefined },
97
97
  attemptNumber: { type: Number, default: undefined },
98
98
  countdown: { type: Number, default: undefined },
99
+ /**
100
+ * Optional extra rows appended to the connection-info popover (after
101
+ * the network row): host-supplied label/value pairs with a prebuilt
102
+ * icon vnode each — the same loose-icon contract as HkAuthMethodList.
103
+ * Rows grow with their content but cap at 400px; values past the cap
104
+ * ellipsize (full text rides the native title). Lets a host surface
105
+ * stage endpoints (gateway, registry…) inside the popover without
106
+ * forking the component.
107
+ */
108
+ extraDetails: {
109
+ type: Array as PropType<
110
+ Array<{ key: string; icon?: unknown; label: string; value: string }>
111
+ >,
112
+ default: undefined,
113
+ },
99
114
  },
100
115
  setup(props) {
101
116
  const popupOpen = ref(false);
@@ -358,6 +373,13 @@ export const HkStatusBar = defineComponent({
358
373
  <span style={{ opacity: 0.5, marginRight: "auto" }}>{t("hikari::statusBar.network", "Network")}</span>
359
374
  <span>{regionDisplayName(info.region, locale, t)}{info.asn != null ? ` · AS${info.asn}` : ""}{info.isLocalhost ? " · " + t("hikari::statusBar.local", "Local") : ""}</span>
360
375
  </div>
376
+ {(props.extraDetails ?? []).map((row) => (
377
+ <div key={row.key} data-extra-detail={row.key} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "6px", maxWidth: "400px" }}>
378
+ <span style={{ opacity: 0.5, flexShrink: 0, display: "inline-flex" }}>{row.icon}</span>
379
+ <span style={{ opacity: 0.5, flexShrink: 0, minWidth: "72px", marginRight: "auto" }}>{row.label}</span>
380
+ <span title={row.value} style={{ fontFamily: `var(--font-mono, ${HIKARI_FONT_MONO})`, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{row.value}</span>
381
+ </div>
382
+ ))}
361
383
  </>
362
384
  ) : (
363
385
  <div style={{ opacity: 0.5 }}>{t("hikari::statusBar.fetching", "Fetching connection info...")}</div>
@@ -3,6 +3,17 @@
3
3
  align-items: center;
4
4
  gap: 0;
5
5
  width: 100%;
6
+
7
+ // Shared node geometry — ONE source of truth for the 24px indicator
8
+ // circle and the rail thickness. Every connector math keys off these:
9
+ // window-mode links center on the node along the inline axis, the
10
+ // vertical rail hangs centered under it, so both stay glued to the
11
+ // circles even if the node size ever changes.
12
+ --hk-timeline-node-size: 24px;
13
+ --hk-timeline-link-thickness: 2px;
14
+ --hk-timeline-link-top: calc(
15
+ (var(--hk-timeline-node-size) - var(--hk-timeline-link-thickness)) / 2
16
+ );
6
17
  }
7
18
 
8
19
  .hk-timeline[data-orientation="vertical"] {
@@ -26,12 +37,9 @@
26
37
  // Geometry contract with the stacked steps above: the node is the 24px
27
38
  // indicator circle at the top of every stack, so its center sits half a
28
39
  // node below the cell top in ALL columns — that is where link segments go.
29
- --hk-timeline-node-size: 24px;
40
+ // (Node size, thickness and link-top are the shared tokens on
41
+ // `.hk-timeline`.)
30
42
  --hk-timeline-link-clearance: 20px;
31
- --hk-timeline-link-thickness: 2px;
32
- --hk-timeline-link-top: calc(
33
- (var(--hk-timeline-node-size) - var(--hk-timeline-link-thickness)) / 2
34
- );
35
43
  }
36
44
 
37
45
  .hk-timeline[data-mode="window"] .hk-timeline-window {
@@ -142,6 +150,95 @@
142
150
  transform: scaleX(-1);
143
151
  }
144
152
 
153
+ /* ── Vertical window mode ────────────────────────────────────────────────
154
+ The height-starved sibling of the compact horizontal window: when the
155
+ host cannot fit the full column, the timeline collapses to the same
156
+ three-cell window (previous / current / next) stacked as equal thirds,
157
+ the outer two dimmed exactly like their left/right counterparts, and
158
+ vertical rail segments joining the node centers on an overlay — with
159
+ fading continuations past the top/bottom edges where hidden steps go. */
160
+ .hk-timeline[data-orientation="vertical"][data-mode="window"] {
161
+ grid-template-columns: minmax(0, 1fr);
162
+ grid-template-rows: repeat(3, minmax(0, 1fr));
163
+ align-items: stretch;
164
+
165
+ // Each cell centers its step within its third, which puts every node
166
+ // center at the SAME fractions of the host height as the horizontal
167
+ // mode puts them across the width (1/6, 1/2, 5/6) — the link math
168
+ // below is the horizontal one rotated, nothing more.
169
+ .hk-timeline-window {
170
+ display: flex;
171
+ flex-direction: column;
172
+ justify-content: center;
173
+ min-height: 0;
174
+ }
175
+
176
+ .hk-timeline-window .hk-timeline-step {
177
+ flex: 0 0 auto;
178
+ max-width: 100%;
179
+
180
+ [data-el="label"] {
181
+ overflow: hidden;
182
+ text-overflow: ellipsis;
183
+ white-space: nowrap;
184
+ }
185
+ }
186
+
187
+ // Vertical rails: centered under the node column, spanning between the
188
+ // cells' node centers (at 1/6, 1/2, 5/6 of the height) minus the same
189
+ // clearance the horizontal bonds keep from the circles. Each segment
190
+ // also re-asserts the inline geometry — the horizontal segment rules
191
+ // above carry the same attribute specificity and must not leak in.
192
+ .hk-timeline-link {
193
+ width: var(--hk-timeline-link-thickness);
194
+ height: auto;
195
+ inset-inline-start: var(--hk-timeline-link-top);
196
+ top: auto;
197
+ bottom: auto;
198
+
199
+ &[data-segment="before-current"] {
200
+ inset-inline-start: var(--hk-timeline-link-top);
201
+ width: var(--hk-timeline-link-thickness);
202
+ top: calc(16.6667% + var(--hk-timeline-link-clearance));
203
+ height: calc(33.3334% - (var(--hk-timeline-link-clearance) * 2));
204
+ }
205
+
206
+ &[data-segment="current-after"] {
207
+ inset-inline-start: var(--hk-timeline-link-top);
208
+ width: var(--hk-timeline-link-thickness);
209
+ top: calc(50% + var(--hk-timeline-link-clearance));
210
+ height: calc(33.3334% - (var(--hk-timeline-link-clearance) * 2));
211
+ }
212
+
213
+ // Continuation hints: from the container edge toward that cell's node,
214
+ // fading INTO the edge ("more steps exist beyond this side").
215
+ &[data-segment="edge-before"] {
216
+ inset-inline-start: var(--hk-timeline-link-top);
217
+ width: var(--hk-timeline-link-thickness);
218
+ top: 0;
219
+ height: calc(16.6667% - var(--hk-timeline-link-clearance));
220
+ }
221
+
222
+ &[data-segment="edge-after"] {
223
+ inset-inline-start: var(--hk-timeline-link-top);
224
+ width: var(--hk-timeline-link-thickness);
225
+ top: auto;
226
+ bottom: 0;
227
+ height: calc(16.6667% - var(--hk-timeline-link-clearance));
228
+ }
229
+
230
+ &[data-fade-dir="up"] {
231
+ background-color: transparent;
232
+ background-image: linear-gradient(to top, var(--_link-color), transparent);
233
+ }
234
+
235
+ &[data-fade-dir="down"] {
236
+ background-color: transparent;
237
+ background-image: linear-gradient(to bottom, var(--_link-color), transparent);
238
+ }
239
+ }
240
+ }
241
+
145
242
 
146
243
  .hk-timeline-step {
147
244
  display: flex;
@@ -187,7 +284,7 @@
187
284
  }
188
285
 
189
286
  [data-el="connector"] {
190
- height: 2px;
287
+ height: var(--hk-timeline-link-thickness);
191
288
  flex: 1 1 0%;
192
289
  min-width: 0.5rem;
193
290
  margin: 0 var(--hi-space-4, 4px);
@@ -198,19 +295,41 @@
198
295
  flex: 0 0 auto;
199
296
  }
200
297
 
201
- .hk-timeline[data-orientation="vertical"] .hk-timeline-step {
298
+ .hk-timeline[data-orientation="vertical"][data-mode="full"] .hk-timeline-step {
202
299
  flex-direction: row;
203
300
  flex: 0 0 auto;
204
- padding-bottom: var(--hi-space-24, 24px);
301
+
302
+ // The gap this step's rail must bridge: exactly the space between the
303
+ // bottom edge of this step's node circle and the top edge of the next
304
+ // step's node circle. Padding and rail height share the token, so the
305
+ // rail always lands flush on both circles — no floating dashes, no
306
+ // overlap, whatever the rhythm is tuned to.
307
+ --hk-timeline-vert-gap: var(--hi-space-24, 24px);
308
+ padding-bottom: var(--hk-timeline-vert-gap);
205
309
 
206
310
  [data-el="connector"] {
207
- width: 2px;
208
- height: 1rem;
311
+ width: var(--hk-timeline-link-thickness);
312
+ // The horizontal base rule leaves `min-width: 0.5rem` behind; on an
313
+ // absolutely-positioned rail min-width beats width, which once fatted
314
+ // the rail to 8px and shoved it off the node center. Neutralize the
315
+ // leaked sizing constraints explicitly.
316
+ min-width: 0;
209
317
  flex: 0 0 auto;
210
318
  position: absolute;
211
- left: 0.6875rem;
212
- top: 1.5rem;
319
+ height: var(--hk-timeline-vert-gap);
320
+ // Centered under the NODE, not the row: the circle occupies the first
321
+ // node-size square of the row, so the rail's inline offset is
322
+ // (node − thickness) / 2. Logical property mirrors it under RTL
323
+ // together with the flipped flex column.
324
+ inset-inline-start: calc(
325
+ (var(--hk-timeline-node-size) - var(--hk-timeline-link-thickness)) / 2
326
+ );
327
+ inset-inline-end: auto;
328
+ top: var(--hk-timeline-node-size);
329
+ bottom: auto;
213
330
  margin: 0;
331
+ // Rounded caps keep the rail's ends from aliasing against the circles.
332
+ border-radius: calc(var(--hk-timeline-link-thickness) / 2);
214
333
  }
215
334
  }
216
335
 
@@ -138,10 +138,48 @@ describe("HkTimeline full mode", () => {
138
138
  expect(els[3].getAttribute("aria-current")).toBeNull();
139
139
  });
140
140
 
141
- it("keeps vertical orientation in full mode", () => {
142
- const t = mountTimeline({ currentKey: "b", orientation: "vertical", collapse: "always" });
141
+ it("keeps a short vertical timeline in full mode", () => {
142
+ // A 3-step vertical timeline is its own window: nothing to collapse.
143
+ const steps = [
144
+ { key: "a", label: "Alpha" },
145
+ { key: "b", label: "Beta" },
146
+ { key: "c", label: "Gamma" },
147
+ ];
148
+ const t = mountTimeline({ currentKey: "b", orientation: "vertical", collapse: "always" }, steps);
143
149
  expect(t.container.querySelector(".hk-timeline")?.getAttribute("data-mode")).toBe("full");
144
150
  });
151
+
152
+ it("collapses a vertical timeline into the stacked window", () => {
153
+ const t = mountTimeline({ currentKey: "c", orientation: "vertical", collapse: "always" });
154
+ const root = t.container.querySelector(".hk-timeline");
155
+ expect(root?.getAttribute("data-orientation")).toBe("vertical");
156
+ expect(root?.getAttribute("data-mode")).toBe("window");
157
+
158
+ const before = t.container.querySelector('.hk-timeline-window[data-side="before"]');
159
+ const current = t.container.querySelector('.hk-timeline-window[data-side="current"]');
160
+ const after = t.container.querySelector('.hk-timeline-window[data-side="after"]');
161
+
162
+ expect(before?.querySelector('[data-el="label"]')?.textContent).toBe("Beta");
163
+ expect(current?.querySelector('[data-el="label"]')?.textContent).toBe("Gamma");
164
+ expect(after?.querySelector('[data-el="label"]')?.textContent).toBe("Delta");
165
+
166
+ // Same dimming grammar as the horizontal window: outer cells dimmed,
167
+ // the current cell at full strength.
168
+ expect(before?.hasAttribute("data-dimmed")).toBe(true);
169
+ expect(current?.hasAttribute("data-dimmed")).toBe(false);
170
+ expect(after?.hasAttribute("data-dimmed")).toBe(true);
171
+
172
+ // Vertical continuation hints fade along the block axis.
173
+ expect(
174
+ t.container.querySelector('.hk-timeline-link[data-segment="edge-before"]')?.getAttribute("data-fade-dir"),
175
+ ).toBe("up");
176
+ expect(
177
+ t.container.querySelector('.hk-timeline-link[data-segment="edge-after"]')?.getAttribute("data-fade-dir"),
178
+ ).toBe("down");
179
+
180
+ // The current step keeps its aria contract inside the stacked window.
181
+ expect(current?.querySelector(".hk-timeline-step")?.getAttribute("aria-current")).toBe("step");
182
+ });
145
183
  });
146
184
 
147
185
  describe("HkTimeline window mode", () => {
@@ -91,6 +91,23 @@ function naturalRowWidth(el: HTMLElement): number {
91
91
  return Math.ceil(width);
92
92
  }
93
93
 
94
+ /** Vertical twin of [`naturalRowWidth`]: the column's natural height when
95
+ * free to grow past the host (steps are `flex: 0 0 auto`, so an offscreen
96
+ * clone with `height: max-content` measures the unclamped stack). */
97
+ function naturalColumnHeight(el: HTMLElement): number {
98
+ const probe = el.cloneNode(true) as HTMLElement;
99
+ probe.style.position = "absolute";
100
+ probe.style.visibility = "hidden";
101
+ probe.style.pointerEvents = "none";
102
+ probe.style.height = "max-content";
103
+ const parent = el.parentElement;
104
+ if (!parent) return el.scrollHeight;
105
+ parent.appendChild(probe);
106
+ const height = probe.getBoundingClientRect().height;
107
+ probe.remove();
108
+ return Math.ceil(height);
109
+ }
110
+
94
111
  export default defineComponent({
95
112
  name: "HkTimeline",
96
113
  props: {
@@ -112,22 +129,22 @@ export default defineComponent({
112
129
  setup(props, { emit }) {
113
130
  const host = ref<HTMLElement | null>(null);
114
131
  const collapsed = ref(false);
115
- /** Full-row width captured right before collapsing; used as the
116
- * expand-again threshold (plus hysteresis) while windowed. */
117
- let fullNaturalWidth = 0;
132
+ /** Full-row width (horizontal) or full-column height (vertical)
133
+ * captured right before collapsing; used as the expand-again
134
+ * threshold (plus hysteresis) while windowed. */
135
+ let fullNaturalSize = 0;
118
136
  let observer: ResizeObserver | undefined;
119
137
 
120
138
  const currentIndex = computed(() =>
121
139
  props.steps.findIndex((s) => s.key === props.currentKey),
122
140
  );
123
141
 
124
- /** The window only makes sense for a horizontal row with more steps
125
- * than the window can show (a 3-step row is its own window). */
142
+ /** The window only makes sense for more steps than the window can show
143
+ * (a 3-step timeline is its own window). Horizontal collapse keys off
144
+ * width overflow, vertical collapse off height overflow — the stepper
145
+ * behaves the same whether the host squeezes its row or its column. */
126
146
  const windowable = computed(
127
- () =>
128
- props.orientation === "horizontal" &&
129
- props.steps.length > 3 &&
130
- currentIndex.value >= 0,
147
+ () => props.steps.length > 3 && currentIndex.value >= 0,
131
148
  );
132
149
 
133
150
  const windowed = computed(
@@ -152,23 +169,25 @@ export default defineComponent({
152
169
 
153
170
  function measure(): void {
154
171
  const el = host.value;
155
- if (!el || !windowable.value || props.collapse !== "auto") {
172
+ if (!el || props.collapse !== "auto") {
156
173
  collapsed.value = false;
157
174
  return;
158
175
  }
176
+ const vertical = props.orientation === "vertical";
177
+ const fitSize = vertical ? el.clientHeight : el.clientWidth;
159
178
  if (collapsed.value) {
160
- // Expand again only once the host comfortably fits the full row it
161
- // could not fit when we collapsed (16px hysteresis against flapping
162
- // around the exact threshold).
163
- if (fullNaturalWidth > 0 && el.clientWidth >= fullNaturalWidth + 16) {
179
+ // Expand again only once the host comfortably fits the full row or
180
+ // column it could not fit when we collapsed (16px hysteresis
181
+ // against flapping around the exact threshold).
182
+ if (fullNaturalSize > 0 && fitSize >= fullNaturalSize + 16) {
164
183
  collapsed.value = false;
165
- fullNaturalWidth = 0;
184
+ fullNaturalSize = 0;
166
185
  }
167
186
  return;
168
187
  }
169
- const natural = naturalRowWidth(el);
170
- if (natural > el.clientWidth + 1) {
171
- fullNaturalWidth = natural;
188
+ const natural = vertical ? naturalColumnHeight(el) : naturalRowWidth(el);
189
+ if (natural > fitSize + 1) {
190
+ fullNaturalSize = natural;
172
191
  collapsed.value = true;
173
192
  }
174
193
  }
@@ -197,7 +216,7 @@ export default defineComponent({
197
216
  ],
198
217
  () => {
199
218
  collapsed.value = false;
200
- fullNaturalWidth = 0;
219
+ fullNaturalSize = 0;
201
220
  void nextTick(measure);
202
221
  },
203
222
  );
@@ -288,7 +307,7 @@ export default defineComponent({
288
307
  class="hk-timeline-link"
289
308
  data-segment="edge-before"
290
309
  data-status={statusOf(w.beforeIndex)}
291
- data-fade-dir="left"
310
+ data-fade-dir={props.orientation === "vertical" ? "up" : "left"}
292
311
  />
293
312
  )}
294
313
  {w.afterIndex >= 0 && w.fadeAfter && (
@@ -296,7 +315,7 @@ export default defineComponent({
296
315
  class="hk-timeline-link"
297
316
  data-segment="edge-after"
298
317
  data-status={statusOf(w.afterIndex)}
299
- data-fade-dir="right"
318
+ data-fade-dir={props.orientation === "vertical" ? "down" : "right"}
300
319
  />
301
320
  )}
302
321
  </div>