@celestia-island/hikari 0.55.140 → 0.55.142

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.140",
3
+ "version": "0.55.142",
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",
@@ -126,8 +126,9 @@ export const HkConnectionStatus = defineComponent({
126
126
  compact={props.compact}
127
127
  >
128
128
  {/* Host-appended popover actions (e.g. a manual page
129
- refresh) ride the same actions row as the built-in
130
- retry button. */}
129
+ refresh) ride the status row's right edge — the only
130
+ recovery button in the popover since the built-in
131
+ labelled retry retired (2026-10-11). */}
131
132
  {{ actions: () => slots.actions?.() }}
132
133
  </HkStatusBar>
133
134
  {bs && (
@@ -1,15 +1,69 @@
1
1
  /**
2
2
  * HkPillToggleGroup chrome — the segmented pill track. Ported from the
3
- * wowsp 水表查询 filter bar's option group, token for token: shared
3
+ * wowsp 水表查询 filter bar's option group (strip chrome and fade
4
+ * masks token-for-token; the pills keep their pointer cursor — the
5
+ * strip root carries the grab affordance): shared
4
6
  * chrome around per-option buttons, the active option carrying the
5
7
  * indicator tint directly (multi-select cannot use HkTabs — its sliding
6
- * indicator is radio semantics).
8
+ * indicator is radio semantics). The track is the ONE-LINE PANNABLE
9
+ * STRIP: overflow hides under edge fades (data-h-overflow masks, the
10
+ * HkScrollContainer contract) and pans by wheel / mouse drag / native
11
+ * touch (the machinery lives in useOptionStrip); phones relax back into
12
+ * a wrapping group (media query below — the sheet carries full width).
7
13
  */
8
14
 
9
15
  .hk-pill-group {
10
16
  display: flex;
11
- flex-wrap: wrap;
17
+ flex-wrap: nowrap;
18
+ max-width: 100%;
12
19
  gap: var(--space-4, 4px);
20
+ overflow-x: auto;
21
+ overflow-y: hidden;
22
+ touch-action: pan-x;
23
+ scrollbar-width: none;
24
+ overscroll-behavior-x: contain;
25
+
26
+ &::-webkit-scrollbar {
27
+ display: none;
28
+ }
29
+
30
+ --hk-scroll-fade-size: var(--space-24);
31
+
32
+ &[data-h-overflow="end"] {
33
+ -webkit-mask-image: linear-gradient(to left, transparent 0, #000 var(--hk-scroll-fade-size));
34
+ mask-image: linear-gradient(to left, transparent 0, #000 var(--hk-scroll-fade-size));
35
+ }
36
+
37
+ &[data-h-overflow="start"] {
38
+ -webkit-mask-image: linear-gradient(to right, transparent 0, #000 var(--hk-scroll-fade-size));
39
+ mask-image: linear-gradient(to right, transparent 0, #000 var(--hk-scroll-fade-size));
40
+ }
41
+
42
+ &[data-h-overflow="both"] {
43
+ -webkit-mask-image: linear-gradient(
44
+ to right,
45
+ transparent 0,
46
+ #000 var(--hk-scroll-fade-size),
47
+ #000 calc(100% - var(--hk-scroll-fade-size)),
48
+ transparent 100%
49
+ );
50
+ mask-image: linear-gradient(
51
+ to right,
52
+ transparent 0,
53
+ #000 var(--hk-scroll-fade-size),
54
+ #000 calc(100% - var(--hk-scroll-fade-size)),
55
+ transparent 100%
56
+ );
57
+ }
58
+
59
+ /* The whole strip drags; clicks still toggle — the 5px pan threshold
60
+ * keeps plain clicks click-shaped. */
61
+ cursor: grab;
62
+
63
+ &[data-panning] {
64
+ cursor: grabbing;
65
+ user-select: none;
66
+ }
13
67
  padding: 2px;
14
68
  border-radius: var(--radius-sm);
15
69
  border: 1px solid rgb(var(--color-border) / 25%);
@@ -60,3 +114,16 @@
60
114
  * pressure would collapse them before the label felt it. */
61
115
  flex: 0 0 auto;
62
116
  }
117
+
118
+ /* Phones: the sheet carries the full width, nothing pans there — the
119
+ * strip relaxes back into the wrapping group (the wowsp filter bar's
120
+ * rule; the fade masks and the grab affordance name interactions the
121
+ * track no longer has). */
122
+ @media (max-width: 767px) {
123
+ .hk-pill-group {
124
+ flex-wrap: wrap;
125
+ overflow: visible;
126
+ touch-action: auto;
127
+ cursor: default;
128
+ }
129
+ }
@@ -111,4 +111,67 @@ describe("HkPillToggleGroup", () => {
111
111
  expect(pills[2].querySelector(".hk-pill-group-icon")).toBeNull();
112
112
  expect(pills[2].textContent?.trim()).toBe("Jk");
113
113
  });
114
+
115
+ it("strip wiring: a drag pans and swallows its trailing click — a plain click still toggles", async () => {
116
+ const toggled: string[] = [];
117
+ const c = mount(
118
+ h(HkPillToggleGroup, {
119
+ label: "P",
120
+ options: OPTIONS,
121
+ selected: new Set<string>(),
122
+ onToggle: (v: string) => toggled.push(v),
123
+ }),
124
+ );
125
+ const group = c.querySelector(".hk-pill-group")!;
126
+ const pill = c.querySelectorAll<HTMLButtonElement>(".hk-pill-group-pill")[0];
127
+
128
+ // Press on a pill, stray past the 5px threshold, release: the strip
129
+ // flags the pan and the trailing click is swallowed exactly once.
130
+ pill.dispatchEvent(
131
+ new PointerEvent("pointerdown", { bubbles: true, pointerType: "mouse", button: 0, clientX: 100, clientY: 50 }),
132
+ );
133
+ window.dispatchEvent(
134
+ new PointerEvent("pointermove", { pointerType: "mouse", clientX: 120, clientY: 52 }),
135
+ );
136
+ await Promise.resolve();
137
+ expect(group.hasAttribute("data-panning")).toBe(true);
138
+ window.dispatchEvent(new PointerEvent("pointerup", { pointerType: "mouse" }));
139
+ await Promise.resolve();
140
+ expect(group.hasAttribute("data-panning")).toBe(false);
141
+ pill.dispatchEvent(new MouseEvent("click", { bubbles: true }));
142
+ await Promise.resolve();
143
+ expect(toggled, "the pan's trailing click must not toggle").toEqual([]);
144
+
145
+ // The NEXT plain click toggles normally — the swallow is one-shot.
146
+ pill.dispatchEvent(new MouseEvent("click", { bubbles: true }));
147
+ await Promise.resolve();
148
+ expect(toggled).toEqual([OPTIONS[0].value]);
149
+ });
150
+
151
+ it("strip wiring: a sub-threshold press never pans and its click toggles", async () => {
152
+ const toggled: string[] = [];
153
+ const c = mount(
154
+ h(HkPillToggleGroup, {
155
+ label: "P",
156
+ options: OPTIONS,
157
+ selected: new Set<string>(),
158
+ onToggle: (v: string) => toggled.push(v),
159
+ }),
160
+ );
161
+ const group = c.querySelector(".hk-pill-group")!;
162
+ const pill = c.querySelectorAll<HTMLButtonElement>(".hk-pill-group-pill")[1];
163
+
164
+ pill.dispatchEvent(
165
+ new PointerEvent("pointerdown", { bubbles: true, pointerType: "mouse", button: 0, clientX: 100, clientY: 50 }),
166
+ );
167
+ window.dispatchEvent(
168
+ new PointerEvent("pointermove", { pointerType: "mouse", clientX: 103, clientY: 51 }),
169
+ );
170
+ await Promise.resolve();
171
+ expect(group.hasAttribute("data-panning"), "3px stays a click").toBe(false);
172
+ window.dispatchEvent(new PointerEvent("pointerup", { pointerType: "mouse" }));
173
+ pill.dispatchEvent(new MouseEvent("click", { bubbles: true }));
174
+ await Promise.resolve();
175
+ expect(toggled).toEqual([OPTIONS[1].value]);
176
+ });
114
177
  });
@@ -1,5 +1,6 @@
1
1
  import { defineComponent, h, type PropType, type VNode } from "vue";
2
2
 
3
+ import { useOptionStrip } from "../composables/useOptionStrip";
3
4
  import "./HkPillToggleGroup.scss";
4
5
 
5
6
  /** One toggleable pill of the group. */
@@ -30,9 +31,13 @@ function resolveIcon(icon: unknown): VNode | null {
30
31
  * chrome around per-option toggle buttons, the active option carrying
31
32
  * the indicator tint directly (multi-select cannot use HkTabs — its
32
33
  * sliding indicator is radio semantics). Grammar ported from the wowsp
33
- * 水表查询 filter bar's option strip (2026-10-11), wrapping instead of
34
- * panning: hikari ships page-sized groups, and hosts with strip-scale
35
- * sets host their own panning track.
34
+ * 水表查询 filter bar's option strip (2026-10-11); the track is the
35
+ * ONE-LINE PANNABLE STRIP (same-day follow-up direction): overflow
36
+ * hides under edge fades and pans by wheel / mouse drag / native touch,
37
+ * exactly the 水表 popups' behavior — a wrapping track always measured
38
+ * ONE long line inside HkPopover's max-content panel and ran off-screen
39
+ * (wowsp #667). Phones relax back into a wrapping group (the sheet
40
+ * carries full width; nothing pans there) via the SCSS media query.
36
41
  *
37
42
  * Deliberately THIN (the host owns the set): `toggle(value)` reports one
38
43
  * pill toggled; the optional leading all-pill reports `all()` and the
@@ -60,8 +65,17 @@ export default defineComponent({
60
65
  all: () => true,
61
66
  },
62
67
  setup(props, { emit }) {
68
+ const { stripEl, panning, onPointerDown } = useOptionStrip();
69
+
63
70
  return () => (
64
- <div class="hk-pill-group" role="group" aria-label={props.label}>
71
+ <div
72
+ ref={stripEl}
73
+ class="hk-pill-group"
74
+ role="group"
75
+ aria-label={props.label}
76
+ data-panning={panning.value || undefined}
77
+ onPointerdown={onPointerDown}
78
+ >
65
79
  {props.allLabel != null && (
66
80
  <button
67
81
  type="button"
@@ -422,7 +422,7 @@ describe("HkStatusBar", () => {
422
422
  }
423
423
  });
424
424
 
425
- it("shows a working reconnect button in the popover while disconnected (hint text retired)", async () => {
425
+ it("retires the built-in reconnect button; the hint text carries the retry", async () => {
426
426
  const onRetry = vi.fn();
427
427
  const container = mountBar({
428
428
  version: "1.2.3",
@@ -436,20 +436,62 @@ describe("HkStatusBar", () => {
436
436
  .dispatchEvent(new MouseEvent("mouseenter"));
437
437
  await nextTick();
438
438
  const panel = document.body.querySelector<HTMLElement>(".hk-popover-panel")!;
439
- // The popover body teleports to <body>: the old italic "click to
440
- // retry" hint pointed at an element whose clicks never reached the
441
- // tag handler. The actions row carries a REAL button instead, and
442
- // the misleading hint is gone whenever a retry is wired.
443
- const row = panel.querySelector<HTMLElement>("[data-status-actions]")!;
444
- expect(row, "actions row renders").toBeTruthy();
445
- const button = row.querySelector<HTMLButtonElement>("button")!;
446
- expect(button.textContent).toContain("Reconnect now");
447
- // 2026-10-02 user direction: small buttons with a leading glyph.
448
- expect(button.className).toContain("hk-btn-xs");
449
- expect(button.querySelector(".hk-btn-icon svg"), "leading glyph renders").toBeTruthy();
450
- expect(panel.textContent).not.toContain("Click to retry");
451
- button.click();
452
- expect(onRetry, "popover button click retries").toHaveBeenCalledTimes(1);
439
+ // 2026-10-11 user direction: the popover must not stack a built-in
440
+ // "Reconnect now" beside the host's own refresh — the light itself
441
+ // retries on click/tap, so the popover keeps ONE recovery action
442
+ // (the host's, via the slot).
443
+ expect(panel.querySelector("[data-status-actions]")).toBeNull();
444
+ expect(panel.textContent).not.toContain("Reconnect now");
445
+ // The fallback hint is back for hosts without a slot action — but it
446
+ // is a REAL control now: the original hint was retired because its
447
+ // clicks went nowhere, so the text carries the retry itself.
448
+ const hint = panel.querySelector<HTMLElement>("[role='button']")!;
449
+ expect(hint.textContent).toContain("Click to retry");
450
+ hint.click();
451
+ expect(onRetry, "hint click retries").toHaveBeenCalledTimes(1);
452
+ // Keyboard parity: the hint is a role=button control, so Enter (and
453
+ // Space) carry the same retry.
454
+ hint.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
455
+ expect(onRetry, "Enter on the hint retries").toHaveBeenCalledTimes(2);
456
+ hint.dispatchEvent(new KeyboardEvent("keydown", { key: " ", bubbles: true }));
457
+ expect(onRetry, "Space on the hint retries").toHaveBeenCalledTimes(2 + 1);
458
+ hint.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
459
+ expect(onRetry, "other keys are inert").toHaveBeenCalledTimes(3);
460
+ });
461
+
462
+ it("hosts the actions cluster on the status row's right side, not a bottom hairline row", async () => {
463
+ const container = document.createElement("div");
464
+ document.body.appendChild(container);
465
+ const app = createApp({
466
+ render: () =>
467
+ h(HkStatusBar, {
468
+ version: "1.2.3",
469
+ connectionStatus: "disconnected",
470
+ connectionInfo: INFO,
471
+ onRetry: () => {},
472
+ }, {
473
+ actions: () => h("button", { class: "host-refresh" }, "Refresh page"),
474
+ }),
475
+ });
476
+ app.mount(container);
477
+ mounts.push({ app, container });
478
+ await nextTick();
479
+ container
480
+ .querySelector<HTMLElement>(".s-status-bar-tag")!
481
+ .dispatchEvent(new MouseEvent("mouseenter"));
482
+ await nextTick();
483
+ const panel = document.body.querySelector<HTMLElement>(".hk-popover-panel")!;
484
+ const clusters = panel.querySelectorAll("[data-status-actions]");
485
+ expect(clusters.length, "exactly one actions cluster").toBe(1);
486
+ // The cluster lives INSIDE the row that carries the status text —
487
+ // status left, actions right, no extra vertical row.
488
+ const statusRow = clusters[0]!.closest("div")!;
489
+ expect(statusRow.textContent).toContain("Disconnected");
490
+ // The old bottom row announced itself with a hairline top border;
491
+ // nothing in the panel may carry it anymore.
492
+ const bordered = [...panel.querySelectorAll<HTMLElement>("*[style*='border']")]
493
+ .filter((el) => el.style.borderTop.includes("solid"));
494
+ expect(bordered, "no hairline actions row remains").toEqual([]);
453
495
  });
454
496
 
455
497
  it("keeps the fallback hint when no onRetry is wired", async () => {
@@ -517,27 +559,38 @@ describe("HkStatusBar", () => {
517
559
  expect(panel!.querySelector("[data-status-actions]")).toBeNull();
518
560
  });
519
561
 
520
- it("keeps the actions row available while connection info is still fetching", async () => {
521
- const onRetry = vi.fn();
522
- const container = mountBar({
523
- version: "1.2.3",
524
- connectionStatus: "disconnected",
525
- connectionInfo: null,
526
- onRetry,
562
+ it("keeps host actions available while connection info is still fetching", async () => {
563
+ const onRefresh = vi.fn();
564
+ const container = document.createElement("div");
565
+ document.body.appendChild(container);
566
+ const app = createApp({
567
+ render: () =>
568
+ h(HkStatusBar, {
569
+ version: "1.2.3",
570
+ connectionStatus: "disconnected",
571
+ connectionInfo: null,
572
+ }, {
573
+ actions: () => h("button", { class: "host-refresh", onClick: onRefresh }, "Refresh page"),
574
+ }),
527
575
  });
576
+ app.mount(container);
577
+ mounts.push({ app, container });
528
578
  await nextTick();
529
579
  container
530
580
  .querySelector<HTMLElement>(".s-status-bar-tag")!
531
581
  .dispatchEvent(new MouseEvent("mouseenter"));
532
582
  await nextTick();
533
583
  const panel = document.body.querySelector<HTMLElement>(".hk-popover-panel")!;
534
- // The row must live OUTSIDE the info/fetching ternary: an outage that
535
- // drops connectionInfo to null is exactly when the retry button is
536
- // most needed.
537
- const button = panel.querySelector<HTMLButtonElement>("[data-status-actions] button");
538
- expect(button, "retry button renders without info").toBeTruthy();
539
- button!.click();
540
- expect(onRetry).toHaveBeenCalledTimes(1);
584
+ // The cluster must live OUTSIDE the info/fetching ternary: an outage
585
+ // that drops connectionInfo to null is exactly when the recovery
586
+ // action is most needed. It rides the fetching line's right edge.
587
+ const cluster = panel.querySelector<HTMLElement>("[data-status-actions]");
588
+ expect(cluster, "actions cluster renders without info").toBeTruthy();
589
+ // Same line as the fetching text: label left, actions right.
590
+ expect(cluster!.closest("div")!.textContent).toContain("Fetching connection info");
591
+ const button = cluster!.querySelector<HTMLButtonElement>("button")!;
592
+ button.click();
593
+ expect(onRefresh).toHaveBeenCalledTimes(1);
541
594
  });
542
595
 
543
596
  it("no actions row when the slot renders nothing (wrapper forwards an empty slot)", async () => {
@@ -5,7 +5,6 @@ import { useI18n } from "../i18n/context";
5
5
  import { HIKARI_FONT_MONO } from "../theme/fontContext";
6
6
 
7
7
  import HPopover from "./HkPopover";
8
- import HkButton from "./HkButton";
9
8
  import type { HkConnectionInfo } from "./HkConnectionInfo";
10
9
  import { HkCountdownDigit } from "./HkCountdownDigit";
11
10
  import { HkPlaceholderMarquee } from "./HkPlaceholderMarquee";
@@ -382,22 +381,24 @@ export const HkStatusBar = defineComponent({
382
381
  : t("hikari::statusBar.disconnected", "Disconnected");
383
382
 
384
383
  const connecting = mode === "reconnecting" || mode === "connecting";
385
- // Actions row: the popover body teleports to <body>, so a click on
386
- // the "click to reconnect" hint text inside it never reached the
387
- // tag's own click handler — the popover now carries REAL buttons.
388
- // The built-in one retriggers the reconnect for any non-green
389
- // light; hosts append their own (e.g. a full manual refresh)
390
- // through the `actions` slot.
391
- const showRetryButton = mode !== "connected" && typeof props.onRetry === "function";
392
- // Gate the row on RENDERED content, not on the slot function's
384
+ // Recovery actions (2026-10-11 user direction): the old bottom
385
+ // hairline row stacked a built-in "Reconnect now" button beside the
386
+ // host's own refresh icon — on an outage the popover read as TWO
387
+ // buttons doing one job. The built-in labelled button is RETIRED:
388
+ // the traffic light itself already retries on click/tap, and hosts
389
+ // append their single recovery action (e.g. a manual page refresh)
390
+ // through the `actions` slot. The cluster now rides the FIRST
391
+ // popover row's right side — status text left, actions right — so
392
+ // no extra row of vertical space is spent on it.
393
+ // Gate the cluster on RENDERED content, not on the slot function's
393
394
  // existence: HkConnectionStatus forwards `actions` unconditionally,
394
- // so a function-existence gate would paint an empty hairline row in
395
- // the connected state for every host that passed no #actions.
395
+ // so a function-existence gate would paint an empty cluster in the
396
+ // connected state for every host that passed no #actions.
396
397
  const actionVnodes = slots.actions?.();
397
398
  const hasHostActions = Array.isArray(actionVnodes)
398
399
  ? actionVnodes.length > 0
399
400
  : Boolean(actionVnodes);
400
- const hasActionsRow = showRetryButton || hasHostActions;
401
+ const hasActionsRow = hasHostActions;
401
402
 
402
403
  const pv = hkMergeVersionDisplay(props.version, props.panelBuildHash);
403
404
  const ev = props.engineVersion;
@@ -494,6 +495,21 @@ export const HkStatusBar = defineComponent({
494
495
  {latency} ms
495
496
  </span>
496
497
  )}
498
+ {hasActionsRow && (
499
+ <span
500
+ data-status-actions
501
+ style={{
502
+ display: "inline-flex",
503
+ alignItems: "center",
504
+ gap: "8px",
505
+ // No latency chip to push against: the cluster
506
+ // still lands on the row's right edge.
507
+ marginLeft: latency !== null ? undefined : "auto",
508
+ }}
509
+ >
510
+ {actionVnodes}
511
+ </span>
512
+ )}
497
513
  </div>
498
514
  {connecting && attempt > 0 && (
499
515
  <div style={{ display: "flex", alignItems: "center", gap: "4px", color: "rgb(var(--color-warning))", fontSize: "0.6875rem", marginBottom: "4px" }}>
@@ -509,8 +525,21 @@ export const HkStatusBar = defineComponent({
509
525
  )}
510
526
  </div>
511
527
  )}
512
- {mode === "disconnected" && !showRetryButton && (
513
- <div style={{ fontStyle: "italic", fontSize: "0.6875rem", marginBottom: "4px", opacity: 0.7 }}>
528
+ {mode === "disconnected" && !hasActionsRow && (
529
+ // Fallback for hosts without a slot action — and a REAL
530
+ // control this time: the original hint was retired
531
+ // because its clicks went nowhere (the popover body
532
+ // teleports to <body>), so the text now carries the
533
+ // retry itself, echo guard included.
534
+ <div
535
+ role="button"
536
+ tabindex={0}
537
+ onClick={onRetryButtonClick}
538
+ onKeydown={(e: KeyboardEvent) => {
539
+ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onRetryButtonClick(); }
540
+ }}
541
+ style={{ fontStyle: "italic", fontSize: "0.6875rem", marginBottom: "4px", opacity: 0.7, cursor: "pointer" }}
542
+ >
514
543
  {t("hikari::statusBar.clickReconnect", "Click to retry")}
515
544
  </div>
516
545
  )}
@@ -565,31 +594,16 @@ export const HkStatusBar = defineComponent({
565
594
  ))}
566
595
  </>
567
596
  ) : (
568
- <div style={{ opacity: 0.5 }}>{t("hikari::statusBar.fetching", "Fetching connection info...")}</div>
569
- )}
570
- {hasActionsRow && (
571
- <div
572
- data-status-actions
573
- style={{
574
- display: "flex",
575
- alignItems: "center",
576
- gap: "8px",
577
- marginTop: "8px",
578
- paddingTop: "8px",
579
- borderTop: "1px solid var(--border-faint, rgb(var(--color-border) / 10%))",
580
- }}
581
- >
582
- {showRetryButton && (
583
- <HkButton
584
- size="xs"
585
- variant="secondary"
586
- icon="RotateCw"
587
- onClick={onRetryButtonClick}
588
- >
589
- {t("hikari::statusBar.retryNow", "Reconnect now")}
590
- </HkButton>
597
+ <div style={{ display: "flex", alignItems: "center", gap: "6px", marginBottom: "6px" }}>
598
+ <span style={{ opacity: 0.5 }}>{t("hikari::statusBar.fetching", "Fetching connection info...")}</span>
599
+ {hasActionsRow && (
600
+ // An outage that drops connectionInfo to null is exactly
601
+ // when the recovery action is most needed — the cluster
602
+ // rides the fetching line's right edge.
603
+ <span data-status-actions style={{ display: "inline-flex", alignItems: "center", gap: "8px", marginLeft: "auto" }}>
604
+ {actionVnodes}
605
+ </span>
591
606
  )}
592
- {hasHostActions ? actionVnodes : null}
593
607
  </div>
594
608
  )}
595
609
  </div>
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ WHEEL_LINE_PX,
5
+ overflowSides,
6
+ panEngaged,
7
+ stripWheelDelta,
8
+ } from "./useOptionStrip";
9
+
10
+ describe("stripWheelDelta", () => {
11
+ it("lets a real horizontal wheel delta win over the vertical one", () => {
12
+ expect(stripWheelDelta(0, -120, 40)).toBe(-120);
13
+ });
14
+ it("translates a plain vertical notch onto the horizontal axis", () => {
15
+ expect(stripWheelDelta(0, 0, 80)).toBe(80);
16
+ expect(stripWheelDelta(0, 0, -80)).toBe(-80);
17
+ });
18
+ it("normalizes Firefox line mode to pixels per notch", () => {
19
+ expect(stripWheelDelta(1, 0, 3)).toBe(3 * WHEEL_LINE_PX);
20
+ expect(stripWheelDelta(1, -2, 0)).toBe(-2 * WHEEL_LINE_PX);
21
+ });
22
+ });
23
+
24
+ describe("panEngaged", () => {
25
+ it("engages at the threshold on EITHER axis", () => {
26
+ expect(panEngaged(5, 0, 5)).toBe(true);
27
+ expect(panEngaged(0, -5, 5)).toBe(true);
28
+ expect(panEngaged(4, 4, 5)).toBe(false);
29
+ });
30
+ });
31
+
32
+ describe("overflowSides", () => {
33
+ function fake(scrollWidth: number, clientWidth: number, scrollLeft: number): HTMLElement {
34
+ return { scrollWidth, clientWidth, scrollLeft } as unknown as HTMLElement;
35
+ }
36
+ it("reports none when the strip fits", () => {
37
+ expect(overflowSides(fake(100, 120, 0))).toBe("none");
38
+ });
39
+ it("reports the edges that still hide content (sub-pixel resting counts)", () => {
40
+ expect(overflowSides(fake(200, 100, 0))).toBe("end");
41
+ expect(overflowSides(fake(200, 100, 100))).toBe("start");
42
+ expect(overflowSides(fake(200, 100, 40))).toBe("both");
43
+ expect(overflowSides(fake(200, 100, 0.5))).toBe("end");
44
+ });
45
+ });
@@ -0,0 +1,209 @@
1
+ /**
2
+ * useOptionStrip — the one-line pannable option track behind
3
+ * HkPillToggleGroup's strip mode. Upstreamed from wowsp's optionStrip
4
+ * (the 水表 filter bar's nation popup and every FilterCategoryChip
5
+ * popup, 2026-10-11 direction: the pill groups must pan with edge
6
+ * fades, not wrap). One module owns:
7
+ *
8
+ * - the wheel→horizontal pan rule (a real horizontal delta wins; a
9
+ * vertical notch is translated onto the strip; Firefox line mode is
10
+ * normalized — mirrors HkScrollContainer's wheel rule);
11
+ * - the mouse drag-pan (pointer capture + the 5px click/pan threshold;
12
+ * the click a pan leaves behind is swallowed exactly once, so panning
13
+ * never toggles an option);
14
+ * - the overflow-side sensing behind the edge fades: the strip mirrors
15
+ * HkScrollContainer's `data-h-overflow` contract (which inline edges
16
+ * still hide content), and the host SCSS turns the value into the
17
+ * matching mask-image gradients.
18
+ *
19
+ * The track element itself stays owned by the host component (its
20
+ * classes and sheet-mode overrides live there); the composable only
21
+ * wires listeners while the element is mounted.
22
+ */
23
+ import { onBeforeUnmount, ref, watch, type Ref } from "vue";
24
+
25
+ /** Firefox line-height wheel mode (deltaMode 1): one notch reports ~3
26
+ * lines, not pixels — normalized here so a notch pans a wheel-like
27
+ * distance on every engine (40px/line, the classic WebKit line height).
28
+ * Page mode (deltaMode 2, effectively extinct) passes through raw. */
29
+ export const WHEEL_LINE_PX = 40;
30
+
31
+ /** Horizontal pan distance for a wheel gesture over the strip: a real
32
+ * horizontal wheel/trackpad swipe (deltaX — shift+wheel reports as
33
+ * deltaX natively too) wins; a plain vertical wheel notch is translated
34
+ * onto the horizontal axis instead. */
35
+ export function stripWheelDelta(deltaMode: number, deltaX: number, deltaY: number): number {
36
+ const raw = deltaX !== 0 ? deltaX : deltaY;
37
+ return deltaMode === 1 ? raw * WHEEL_LINE_PX : raw;
38
+ }
39
+
40
+ /** Press→pan decision for the strip drag: once the pointer strays
41
+ * ≥ threshold px on EITHER axis the press turns into a pan; below it the
42
+ * gesture stays a click (and clicks must keep toggling options). */
43
+ export function panEngaged(dx: number, dy: number, threshold: number): boolean {
44
+ return Math.abs(dx) >= threshold || Math.abs(dy) >= threshold;
45
+ }
46
+
47
+ /** Which inline edges of a strip still hide content — the value mirrored
48
+ * onto the element as `data-h-overflow` (HkScrollContainer's contract;
49
+ * the SCSS maps each value to its edge-fade mask). Sub-pixel scroll
50
+ * offsets count as resting at the edge. RTL flips the visual sides; the
51
+ * family ships LTR only. */
52
+ export type OverflowSides = "none" | "start" | "end" | "both";
53
+
54
+ export function overflowSides(el: HTMLElement): OverflowSides {
55
+ const max = el.scrollWidth - el.clientWidth;
56
+ if (max <= 0) return "none";
57
+ const EDGE_PX = 1;
58
+ const atStart = el.scrollLeft <= EDGE_PX;
59
+ const atEnd = max - el.scrollLeft <= EDGE_PX;
60
+ if (atStart && atEnd) return "none";
61
+ return atStart ? "end" : atEnd ? "start" : "both";
62
+ }
63
+
64
+ /** Wire one strip element with the pan + fade machinery. The host binds
65
+ * `stripEl` (template ref), mirrors `panning` into `data-panning`, and
66
+ * attaches `onPointerDown`; the non-passive wheel listener, the
67
+ * capture-phase click swallow and the overflow sensing ride the ref
68
+ * transitions here (a JSX onWheel prop is passive in some paths and
69
+ * could not preventDefault, and JSX has no capture-phase click prop). */
70
+ export function useOptionStrip() {
71
+ const stripEl: Ref<HTMLElement | null> = ref(null);
72
+ const panning = ref(false);
73
+ const PAN_THRESHOLD = 5;
74
+ let pressX = 0;
75
+ let pressY = 0;
76
+ let lastX = 0;
77
+ let armed = false;
78
+ /** Set when a gesture turned into a pan: the trailing click (and only
79
+ * that one) must not toggle an option. */
80
+ let dragged = false;
81
+ let resizeObserver: ResizeObserver | null = null;
82
+ let mutationObserver: MutationObserver | null = null;
83
+
84
+ /** Re-read the overflow sides and mirror them onto the element. Fires
85
+ * on scroll, on element resizes and on content mutations (options
86
+ * re-rendering can change the track's scroll width). */
87
+ function sense() {
88
+ const el = stripEl.value;
89
+ if (!el) return;
90
+ const sides = overflowSides(el);
91
+ if (sides === "none") delete el.dataset.hOverflow;
92
+ else el.dataset.hOverflow = sides;
93
+ }
94
+
95
+ function onWheel(e: WheelEvent) {
96
+ const el = stripEl.value;
97
+ if (!el || el.scrollWidth <= el.clientWidth) return;
98
+ const delta = stripWheelDelta(e.deltaMode, e.deltaX, e.deltaY);
99
+ if (delta === 0) return;
100
+ const prev = el.scrollLeft;
101
+ el.scrollLeft += delta;
102
+ // A saturated edge lets the wheel keep scrolling the page behind.
103
+ if (el.scrollLeft !== prev) e.preventDefault();
104
+ }
105
+
106
+ function onPointerDown(e: PointerEvent) {
107
+ if (e.pointerType !== "mouse" || e.button !== 0) return;
108
+ // A pan released off-strip leaves no click behind; clear the stale
109
+ // swallow flag so this press's own click always lands.
110
+ dragged = false;
111
+ pressX = lastX = e.clientX;
112
+ pressY = e.clientY;
113
+ armed = false;
114
+ window.addEventListener("pointermove", onPointerMove);
115
+ window.addEventListener("pointerup", onPointerUp, { once: true });
116
+ window.addEventListener("pointercancel", onPointerCancel, { once: true });
117
+ }
118
+
119
+ function onPointerMove(e: PointerEvent) {
120
+ if (!armed) {
121
+ if (!panEngaged(e.clientX - pressX, e.clientY - pressY, PAN_THRESHOLD)) return;
122
+ // Engage: capture the pointer so the pan keeps tracking off-panel,
123
+ // flag the strip (grabbing cursor + selection off) and arm the
124
+ // one-shot click swallow — with capture held, the trailing click
125
+ // retargets to the strip anyway, never onto an option.
126
+ armed = true;
127
+ panning.value = true;
128
+ dragged = true;
129
+ stripEl.value?.setPointerCapture(e.pointerId);
130
+ }
131
+ e.preventDefault();
132
+ const el = stripEl.value;
133
+ if (!el) return;
134
+ el.scrollLeft -= e.clientX - lastX;
135
+ lastX = e.clientX;
136
+ }
137
+
138
+ function teardownPress() {
139
+ armed = false;
140
+ panning.value = false;
141
+ window.removeEventListener("pointermove", onPointerMove);
142
+ }
143
+
144
+ function onPointerUp() {
145
+ teardownPress();
146
+ }
147
+
148
+ function onPointerCancel() {
149
+ // A cancelled gesture never leaves a click — no swallow to hold.
150
+ dragged = false;
151
+ teardownPress();
152
+ }
153
+
154
+ /** The click a completed pan leaves behind must never toggle an
155
+ * option: swallowed exactly once, capture phase (ahead of every
156
+ * option handler). Plain clicks pass untouched. */
157
+ function onClickCapture(e: MouseEvent) {
158
+ if (!dragged) return;
159
+ dragged = false;
160
+ e.stopPropagation();
161
+ }
162
+
163
+ /** A host popup that closed before the pan's trailing click (window
164
+ * blur mid-drag — pointerup lost) must not carry the swallow flag
165
+ * into the reopened panel. */
166
+ function resetDragged() {
167
+ dragged = false;
168
+ }
169
+
170
+ watch(stripEl, (el, prev) => {
171
+ prev?.removeEventListener("wheel", onWheel);
172
+ prev?.removeEventListener("click", onClickCapture, true);
173
+ prev?.removeEventListener("scroll", sense);
174
+ resizeObserver?.disconnect();
175
+ resizeObserver = null;
176
+ mutationObserver?.disconnect();
177
+ mutationObserver = null;
178
+ if (el) {
179
+ el.addEventListener("wheel", onWheel, { passive: false });
180
+ el.addEventListener("click", onClickCapture, true);
181
+ el.addEventListener("scroll", sense, { passive: true });
182
+ if (typeof ResizeObserver !== "undefined") {
183
+ resizeObserver = new ResizeObserver(sense);
184
+ resizeObserver.observe(el);
185
+ }
186
+ if (typeof MutationObserver !== "undefined") {
187
+ mutationObserver = new MutationObserver(sense);
188
+ mutationObserver.observe(el, { childList: true, subtree: true });
189
+ }
190
+ sense();
191
+ }
192
+ });
193
+ onBeforeUnmount(() => {
194
+ teardownPress();
195
+ // Full listener teardown: a component unmounting mid-pan must not
196
+ // leave listeners behind on the strip element or the window's
197
+ // once-listeners unheld.
198
+ const el = stripEl.value;
199
+ el?.removeEventListener("wheel", onWheel);
200
+ el?.removeEventListener("click", onClickCapture, true);
201
+ el?.removeEventListener("scroll", sense);
202
+ window.removeEventListener("pointerup", onPointerUp);
203
+ window.removeEventListener("pointercancel", onPointerCancel);
204
+ resizeObserver?.disconnect();
205
+ mutationObserver?.disconnect();
206
+ });
207
+
208
+ return { stripEl, panning, onPointerDown, resetDragged };
209
+ }
@@ -151,7 +151,6 @@
151
151
  "hikari::statusBar.backend.unknown": "غير معروف",
152
152
  "hikari::statusBar.backend.warn": "متدهور",
153
153
  "hikari::statusBar.clickReconnect": "انقر لإعادة المحاولة",
154
- "hikari::statusBar.retryNow": "أعد الاتصال الآن",
155
154
  "hikari::statusBar.connected": "متصل",
156
155
  "hikari::statusBar.connecting": "جارٍ الاتصال...",
157
156
  "hikari::statusBar.disconnected": "غير متصل",
@@ -151,7 +151,6 @@
151
151
  "hikari::statusBar.backend.unknown": "Unbekannt",
152
152
  "hikari::statusBar.backend.warn": "Beeinträchtigt",
153
153
  "hikari::statusBar.clickReconnect": "Zum Wiederholen klicken",
154
- "hikari::statusBar.retryNow": "Jetzt neu verbinden",
155
154
  "hikari::statusBar.connected": "Verbunden",
156
155
  "hikari::statusBar.connecting": "Verbinde...",
157
156
  "hikari::statusBar.disconnected": "Getrennt",
@@ -151,7 +151,6 @@
151
151
  "hikari::statusBar.backend.unknown": "Unknown",
152
152
  "hikari::statusBar.backend.warn": "Degraded",
153
153
  "hikari::statusBar.clickReconnect": "Click to retry",
154
- "hikari::statusBar.retryNow": "Reconnect now",
155
154
  "hikari::statusBar.connected": "Connected",
156
155
  "hikari::statusBar.connecting": "Connecting...",
157
156
  "hikari::statusBar.disconnected": "Disconnected",
@@ -151,7 +151,6 @@
151
151
  "hikari::statusBar.backend.unknown": "Desconocido",
152
152
  "hikari::statusBar.backend.warn": "Degradado",
153
153
  "hikari::statusBar.clickReconnect": "Haga clic para reintentar",
154
- "hikari::statusBar.retryNow": "Reconectar ahora",
155
154
  "hikari::statusBar.connected": "Conectado",
156
155
  "hikari::statusBar.connecting": "Conectando...",
157
156
  "hikari::statusBar.disconnected": "Desconectado",
@@ -151,7 +151,6 @@
151
151
  "hikari::statusBar.backend.unknown": "Inconnu",
152
152
  "hikari::statusBar.backend.warn": "Dégradé",
153
153
  "hikari::statusBar.clickReconnect": "Cliquez pour réessayer",
154
- "hikari::statusBar.retryNow": "Reconnecter maintenant",
155
154
  "hikari::statusBar.connected": "Connecté",
156
155
  "hikari::statusBar.connecting": "Connexion...",
157
156
  "hikari::statusBar.disconnected": "Déconnecté",
@@ -151,7 +151,6 @@
151
151
  "hikari::statusBar.backend.unknown": "不明",
152
152
  "hikari::statusBar.backend.warn": "低下",
153
153
  "hikari::statusBar.clickReconnect": "クリックして再接続",
154
- "hikari::statusBar.retryNow": "今すぐ再接続",
155
154
  "hikari::statusBar.connected": "接続済み",
156
155
  "hikari::statusBar.connecting": "接続中...",
157
156
  "hikari::statusBar.disconnected": "切断",
@@ -151,7 +151,6 @@
151
151
  "hikari::statusBar.backend.unknown": "알 수 없음",
152
152
  "hikari::statusBar.backend.warn": "저하",
153
153
  "hikari::statusBar.clickReconnect": "클릭하여 재연결",
154
- "hikari::statusBar.retryNow": "지금 재연결",
155
154
  "hikari::statusBar.connected": "연결됨",
156
155
  "hikari::statusBar.connecting": "연결 중...",
157
156
  "hikari::statusBar.disconnected": "연결 끊김",
@@ -151,7 +151,6 @@
151
151
  "hikari::statusBar.backend.unknown": "Desconhecido",
152
152
  "hikari::statusBar.backend.warn": "Degradado",
153
153
  "hikari::statusBar.clickReconnect": "Clique para tentar novamente",
154
- "hikari::statusBar.retryNow": "Reconectar agora",
155
154
  "hikari::statusBar.connected": "Conectado",
156
155
  "hikari::statusBar.connecting": "Conectando...",
157
156
  "hikari::statusBar.disconnected": "Desconectado",
@@ -151,7 +151,6 @@
151
151
  "hikari::statusBar.backend.unknown": "Неизвестно",
152
152
  "hikari::statusBar.backend.warn": "Деградация",
153
153
  "hikari::statusBar.clickReconnect": "Нажмите, чтобы повторить",
154
- "hikari::statusBar.retryNow": "Переподключиться сейчас",
155
154
  "hikari::statusBar.connected": "Подключено",
156
155
  "hikari::statusBar.connecting": "Подключение...",
157
156
  "hikari::statusBar.disconnected": "Отключено",
@@ -151,7 +151,6 @@
151
151
  "hikari::statusBar.backend.unknown": "未知",
152
152
  "hikari::statusBar.backend.warn": "降级",
153
153
  "hikari::statusBar.clickReconnect": "点击以尝试重连",
154
- "hikari::statusBar.retryNow": "立即重连",
155
154
  "hikari::statusBar.connected": "已连接",
156
155
  "hikari::statusBar.connecting": "连接中...",
157
156
  "hikari::statusBar.disconnected": "断开",
@@ -151,7 +151,6 @@
151
151
  "hikari::statusBar.backend.unknown": "未知",
152
152
  "hikari::statusBar.backend.warn": "降級",
153
153
  "hikari::statusBar.clickReconnect": "點擊以嘗試重連",
154
- "hikari::statusBar.retryNow": "立即重連",
155
154
  "hikari::statusBar.connected": "已連線",
156
155
  "hikari::statusBar.connecting": "連線中...",
157
156
  "hikari::statusBar.disconnected": "斷開",