@celestia-island/hikari 0.55.140 → 0.55.141

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.141",
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",
@@ -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"
@@ -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
+ }