@celestia-island/hikari 0.55.139 → 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 +1 -1
- package/src/components/HkFilterChip.scss +79 -0
- package/src/components/HkFilterChip.test.tsx +136 -0
- package/src/components/HkFilterChip.tsx +105 -0
- package/src/components/HkPillToggleGroup.scss +129 -0
- package/src/components/HkPillToggleGroup.test.tsx +177 -0
- package/src/components/HkPillToggleGroup.tsx +109 -0
- package/src/composables/useOptionStrip.test.ts +45 -0
- package/src/composables/useOptionStrip.ts +209 -0
- package/src/index.ts +3 -0
package/package.json
CHANGED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HkFilterChip chrome — the facet filter chip (trigger) and its popup
|
|
3
|
+
* body. The pill group itself is HkPillToggleGroup's. Ported from the
|
|
4
|
+
* wowsp 水表查询 filter bar's grammar: a solid-surface chip (never a
|
|
5
|
+
* bare alpha tint — a lone chip over wallpaper reads as a hole).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
.hk-filter-chip-anchor {
|
|
9
|
+
position: relative;
|
|
10
|
+
display: inline-flex;
|
|
11
|
+
align-items: center;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.hk-filter-chip {
|
|
15
|
+
display: inline-flex;
|
|
16
|
+
align-items: center;
|
|
17
|
+
gap: var(--space-6);
|
|
18
|
+
/* Vertical padding = trigger padding + one track step: the chip shares
|
|
19
|
+
* its row with segmented HkTabs-style chrome, whose track adds
|
|
20
|
+
* var(--space-2) around its triggers (the wowsp filter bar's alignment
|
|
21
|
+
* rule — a bare var(--space-4) chip lands two steps short of the row). */
|
|
22
|
+
padding: calc(var(--space-4) + var(--space-2)) var(--space-12);
|
|
23
|
+
font-family: inherit;
|
|
24
|
+
font-size: var(--text-sm);
|
|
25
|
+
font-weight: 600;
|
|
26
|
+
color: rgb(var(--color-muted));
|
|
27
|
+
white-space: nowrap;
|
|
28
|
+
cursor: pointer;
|
|
29
|
+
border: 1px solid rgb(var(--color-border) / 25%);
|
|
30
|
+
border-radius: var(--radius-sm);
|
|
31
|
+
background: rgb(var(--color-surface));
|
|
32
|
+
transition:
|
|
33
|
+
color var(--duration-short) ease,
|
|
34
|
+
background var(--duration-short) var(--ease-standard),
|
|
35
|
+
border-color var(--duration-short) var(--ease-standard);
|
|
36
|
+
|
|
37
|
+
&:hover {
|
|
38
|
+
color: rgb(var(--color-text));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
&:active {
|
|
42
|
+
transform: scale(0.96);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
&:focus-visible {
|
|
46
|
+
outline: 2px solid rgb(var(--color-primary));
|
|
47
|
+
outline-offset: 1px;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* Concrete selection — the active trigger's primary ink mixed into the
|
|
51
|
+
* solid surface (a bare alpha tint would go translucent). */
|
|
52
|
+
&--on {
|
|
53
|
+
color: rgb(var(--color-primary));
|
|
54
|
+
background: color-mix(in srgb, rgb(var(--color-primary)) 12%, rgb(var(--color-surface)));
|
|
55
|
+
border-color: rgb(var(--color-primary) / 25%);
|
|
56
|
+
|
|
57
|
+
&:hover {
|
|
58
|
+
color: rgb(var(--color-primary));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.hk-filter-chip-caret {
|
|
64
|
+
opacity: 0.6;
|
|
65
|
+
flex: 0 0 auto;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.hk-filter-chip-body {
|
|
69
|
+
display: flex;
|
|
70
|
+
flex-direction: column;
|
|
71
|
+
gap: var(--space-8, 0.5rem);
|
|
72
|
+
max-width: min(92vw, 40rem);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.hk-filter-chip-hint {
|
|
76
|
+
color: rgb(var(--color-muted));
|
|
77
|
+
font-size: var(--text-2xs);
|
|
78
|
+
line-height: 1.4;
|
|
79
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, h, ref } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkFilterChip from "./HkFilterChip";
|
|
5
|
+
|
|
6
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
7
|
+
|
|
8
|
+
function mount(node: ReturnType<typeof h>) {
|
|
9
|
+
return mountRendered(() => node);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Mount with the vnode built INSIDE the render fn, so reactive props
|
|
13
|
+
* (a host's open ref) re-render the tree when they change. */
|
|
14
|
+
function mountRendered(build: () => ReturnType<typeof h>) {
|
|
15
|
+
const container = document.createElement("div");
|
|
16
|
+
document.body.appendChild(container);
|
|
17
|
+
const app = createApp({ render: () => build() });
|
|
18
|
+
app.mount(container);
|
|
19
|
+
mounts.push({ app, container });
|
|
20
|
+
return container;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
25
|
+
app.unmount();
|
|
26
|
+
container.remove();
|
|
27
|
+
}
|
|
28
|
+
document.body.querySelectorAll(".hk-popover-scrim, .hk-popover-panel").forEach((el) => el.remove());
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const OPTIONS = [
|
|
32
|
+
{ value: "deepseek", label: "DeepSeek" },
|
|
33
|
+
{ value: "openai", label: "OpenAI" },
|
|
34
|
+
{ value: "anthropic", label: "Anthropic" },
|
|
35
|
+
{ value: "gemini", label: "Gemini" },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const chipButton = (c: HTMLElement) => c.querySelector<HTMLButtonElement>(".hk-filter-chip")!;
|
|
39
|
+
|
|
40
|
+
describe("HkFilterChip", () => {
|
|
41
|
+
it("renders the chip closed, labelled by the all-label when nothing is picked", () => {
|
|
42
|
+
const c = mount(
|
|
43
|
+
h(HkFilterChip, { label: "Providers", allLabel: "All providers", options: OPTIONS }),
|
|
44
|
+
);
|
|
45
|
+
const btn = chipButton(c);
|
|
46
|
+
expect(btn.textContent).toContain("All providers");
|
|
47
|
+
expect(btn.getAttribute("aria-haspopup")).toBe("dialog");
|
|
48
|
+
expect(btn.getAttribute("aria-expanded")).toBe("false");
|
|
49
|
+
expect(btn.className).not.toContain("hk-filter-chip--on");
|
|
50
|
+
// Nothing teleported while closed.
|
|
51
|
+
expect(document.body.querySelector(".hk-pill-group")).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("opens the pill group in a popover and reports toggle / all", async () => {
|
|
55
|
+
const open = ref(false);
|
|
56
|
+
const toggled: string[] = [];
|
|
57
|
+
const allClicks: number[] = [];
|
|
58
|
+
const c = mountRendered(
|
|
59
|
+
() =>
|
|
60
|
+
h(HkFilterChip, {
|
|
61
|
+
label: "Providers",
|
|
62
|
+
allLabel: "All providers",
|
|
63
|
+
options: OPTIONS,
|
|
64
|
+
open: open.value,
|
|
65
|
+
"onUpdate:open": (v: boolean) => (open.value = v),
|
|
66
|
+
onToggle: (v: string) => toggled.push(v),
|
|
67
|
+
onAll: () => allClicks.push(1),
|
|
68
|
+
hint: "Multi-select hint line",
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
chipButton(c).dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
72
|
+
await Promise.resolve();
|
|
73
|
+
await Promise.resolve();
|
|
74
|
+
expect(open.value).toBe(true);
|
|
75
|
+
expect(chipButton(c).getAttribute("aria-expanded")).toBe("true");
|
|
76
|
+
|
|
77
|
+
// HkPopover teleports the surface to the body — the pill group and
|
|
78
|
+
// the host-authored hint line live there, not in the anchor.
|
|
79
|
+
const group = document.body.querySelector(".hk-pill-group");
|
|
80
|
+
expect(group, "pill group mounted in the popover after the chip click").toBeTruthy();
|
|
81
|
+
expect(document.body.querySelector(".hk-filter-chip-hint")?.textContent).toContain("Multi-select hint line");
|
|
82
|
+
|
|
83
|
+
const pills = [...document.body.querySelectorAll<HTMLButtonElement>(".hk-pill-group-pill")];
|
|
84
|
+
expect(pills.length).toBe(OPTIONS.length + 1);
|
|
85
|
+
pills[2].dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
86
|
+
await Promise.resolve();
|
|
87
|
+
expect(toggled).toEqual(["openai"]);
|
|
88
|
+
|
|
89
|
+
pills[0].dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
90
|
+
await Promise.resolve();
|
|
91
|
+
expect(allClicks.length).toBe(1);
|
|
92
|
+
expect(toggled).toEqual(["openai"]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("mirrors the selection on the chip in canonical option order, capped with a +N tail", async () => {
|
|
96
|
+
// Canonical order wins over Set insertion order; the 4th pick caps
|
|
97
|
+
// the label instead of growing the chip without bound.
|
|
98
|
+
const selected = new Set(["gemini", "openai", "anthropic", "deepseek"]);
|
|
99
|
+
const c = mount(
|
|
100
|
+
h(HkFilterChip, {
|
|
101
|
+
label: "Providers",
|
|
102
|
+
allLabel: "All providers",
|
|
103
|
+
options: OPTIONS,
|
|
104
|
+
selected,
|
|
105
|
+
open: true,
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
const text = chipButton(c).querySelector(".hk-filter-chip-text")!.textContent;
|
|
109
|
+
expect(text).toBe("DeepSeek, OpenAI, Anthropic +1");
|
|
110
|
+
expect(chipButton(c).className).toContain("hk-filter-chip--on");
|
|
111
|
+
// The accessible name carries the SAME selection the eye sees — an
|
|
112
|
+
// aria-label of just the category would mask the mirror (R1 P2).
|
|
113
|
+
expect(chipButton(c).getAttribute("aria-label")).toContain("DeepSeek, OpenAI, Anthropic +1");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("re-renders when the host's selected set changes post-mount", async () => {
|
|
117
|
+
const selected = ref(new Set<string>());
|
|
118
|
+
const c = mountRendered(
|
|
119
|
+
() =>
|
|
120
|
+
h(HkFilterChip, {
|
|
121
|
+
label: "Providers",
|
|
122
|
+
allLabel: "All providers",
|
|
123
|
+
options: OPTIONS,
|
|
124
|
+
selected: selected.value,
|
|
125
|
+
open: false,
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
expect(chipButton(c).querySelector(".hk-filter-chip-text")!.textContent).toBe("All providers");
|
|
129
|
+
selected.value = new Set(["openai"]);
|
|
130
|
+
await Promise.resolve();
|
|
131
|
+
await Promise.resolve();
|
|
132
|
+
expect(chipButton(c).querySelector(".hk-filter-chip-text")!.textContent).toBe("OpenAI");
|
|
133
|
+
expect(chipButton(c).className).toContain("hk-filter-chip--on");
|
|
134
|
+
expect(chipButton(c).getAttribute("aria-label")).toContain("OpenAI");
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { computed, defineComponent, ref, type PropType } from "vue";
|
|
2
|
+
import { ChevronDown } from "lucide-vue-next";
|
|
3
|
+
|
|
4
|
+
import HkPopover, { type PopupPlacement } from "./HkPopover";
|
|
5
|
+
import HkPillToggleGroup, { type HkPillToggleGroupOption } from "./HkPillToggleGroup";
|
|
6
|
+
import "./HkFilterChip.scss";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* HkFilterChip — the FACET filter member of the filter family: a chip
|
|
10
|
+
* trigger that opens a popover (desktop) / bottom sheet (mobile, via
|
|
11
|
+
* HkPopover's sheetOnMobile) holding an HkPillToggleGroup multi-select —
|
|
12
|
+
* the 水表查询 filter-bar grammar, upstreamed from wowsp's
|
|
13
|
+
* FilterCategoryChip (2026-10-11). The sort/direction arrows and the
|
|
14
|
+
* drag-reorderable chip strip stay host-side there; this is the pure
|
|
15
|
+
* facet member.
|
|
16
|
+
*
|
|
17
|
+
* Semantics ride the thin group contract: `toggle(value)` reports one
|
|
18
|
+
* option toggled, `all()` reports the all-pill — the HOST decides
|
|
19
|
+
* whether all means clear or select-everything. The chip label mirrors
|
|
20
|
+
* the selection: the all-label when nothing is picked, otherwise the
|
|
21
|
+
* picked labels in canonical option order (capped at three plus a +N
|
|
22
|
+
* tail, so a wide facet cannot blow up the chip strip's width).
|
|
23
|
+
*/
|
|
24
|
+
export default defineComponent({
|
|
25
|
+
name: "HkFilterChip",
|
|
26
|
+
props: {
|
|
27
|
+
/** Popup title + the trigger's accessible name (the category). */
|
|
28
|
+
label: { type: String, required: true },
|
|
29
|
+
/** Chip text with nothing picked, and the all-pill's label. */
|
|
30
|
+
allLabel: { type: String, required: true },
|
|
31
|
+
/** Facet values in canonical display order (also the chip-label
|
|
32
|
+
* order — picked labels mirror it, not the Set's insertion order). */
|
|
33
|
+
options: {
|
|
34
|
+
type: Array as PropType<HkPillToggleGroupOption[]>,
|
|
35
|
+
default: () => [],
|
|
36
|
+
},
|
|
37
|
+
/** The picked values — single source of truth, never mutated here. */
|
|
38
|
+
selected: { type: Object as PropType<Set<string>>, default: () => new Set<string>() },
|
|
39
|
+
open: { type: Boolean, default: false },
|
|
40
|
+
placement: { type: String as PropType<PopupPlacement>, default: "bottom-start" },
|
|
41
|
+
/** Bottom hint line below the pill group. Host-provided on purpose:
|
|
42
|
+
* the facet's semantics (multi-select? radio? clears what?) are the
|
|
43
|
+
* host's story to tell. */
|
|
44
|
+
hint: { type: String, default: undefined },
|
|
45
|
+
},
|
|
46
|
+
emits: {
|
|
47
|
+
"update:open": (_v: boolean) => true,
|
|
48
|
+
toggle: (_value: string) => true,
|
|
49
|
+
all: () => true,
|
|
50
|
+
},
|
|
51
|
+
setup(props, { emit }) {
|
|
52
|
+
const anchorRef = ref<HTMLElement | null>(null);
|
|
53
|
+
|
|
54
|
+
/** Picked options in canonical order — the chip label's source. */
|
|
55
|
+
const picked = computed(() => props.options.filter((o) => props.selected.has(o.value)));
|
|
56
|
+
|
|
57
|
+
const chipText = computed(() => {
|
|
58
|
+
if (picked.value.length === 0) return props.allLabel;
|
|
59
|
+
const names = picked.value.map((o) => o.label);
|
|
60
|
+
const head = names.slice(0, 3).join(", ");
|
|
61
|
+
const rest = names.length - 3;
|
|
62
|
+
return rest > 0 ? `${head} +${rest}` : head;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return () => (
|
|
66
|
+
<span ref={anchorRef} class="hk-filter-chip-anchor">
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
class={["hk-filter-chip", props.selected.size > 0 && "hk-filter-chip--on"]}
|
|
70
|
+
aria-haspopup="dialog"
|
|
71
|
+
aria-expanded={props.open ? "true" : "false"}
|
|
72
|
+
aria-label={
|
|
73
|
+
picked.value.length > 0 ? `${props.label}: ${chipText.value}` : props.label
|
|
74
|
+
}
|
|
75
|
+
title={props.label}
|
|
76
|
+
onClick={() => emit("update:open", !props.open)}
|
|
77
|
+
>
|
|
78
|
+
<span class="hk-filter-chip-text">{chipText.value}</span>
|
|
79
|
+
<ChevronDown size={12} class="hk-filter-chip-caret" />
|
|
80
|
+
</button>
|
|
81
|
+
<HkPopover
|
|
82
|
+
modelValue={props.open}
|
|
83
|
+
onUpdate:modelValue={(v: boolean) => emit("update:open", v)}
|
|
84
|
+
anchorRef={anchorRef.value}
|
|
85
|
+
placement={props.placement}
|
|
86
|
+
offset={6}
|
|
87
|
+
sheetOnMobile
|
|
88
|
+
title={props.label}
|
|
89
|
+
>
|
|
90
|
+
<div class="hk-filter-chip-body">
|
|
91
|
+
<HkPillToggleGroup
|
|
92
|
+
label={props.label}
|
|
93
|
+
options={props.options}
|
|
94
|
+
selected={props.selected}
|
|
95
|
+
allLabel={props.allLabel}
|
|
96
|
+
onToggle={(v: string) => emit("toggle", v)}
|
|
97
|
+
onAll={() => emit("all")}
|
|
98
|
+
/>
|
|
99
|
+
{props.hint && <div class="hk-filter-chip-hint">{props.hint}</div>}
|
|
100
|
+
</div>
|
|
101
|
+
</HkPopover>
|
|
102
|
+
</span>
|
|
103
|
+
);
|
|
104
|
+
},
|
|
105
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HkPillToggleGroup chrome — the segmented pill track. Ported from the
|
|
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
|
|
6
|
+
* chrome around per-option buttons, the active option carrying the
|
|
7
|
+
* indicator tint directly (multi-select cannot use HkTabs — its sliding
|
|
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).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
.hk-pill-group {
|
|
16
|
+
display: flex;
|
|
17
|
+
flex-wrap: nowrap;
|
|
18
|
+
max-width: 100%;
|
|
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
|
+
}
|
|
67
|
+
padding: 2px;
|
|
68
|
+
border-radius: var(--radius-sm);
|
|
69
|
+
border: 1px solid rgb(var(--color-border) / 25%);
|
|
70
|
+
background: rgb(var(--color-surface) / 60%);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.hk-pill-group-pill {
|
|
74
|
+
display: inline-flex;
|
|
75
|
+
align-items: center;
|
|
76
|
+
gap: var(--space-6, 6px);
|
|
77
|
+
padding: var(--space-4) var(--space-12);
|
|
78
|
+
font-family: inherit;
|
|
79
|
+
font-size: var(--text-sm);
|
|
80
|
+
font-weight: 600;
|
|
81
|
+
color: rgb(var(--color-muted));
|
|
82
|
+
white-space: nowrap;
|
|
83
|
+
cursor: pointer;
|
|
84
|
+
background: transparent;
|
|
85
|
+
border: none;
|
|
86
|
+
border-radius: var(--radius-sm);
|
|
87
|
+
outline: none;
|
|
88
|
+
transition: color var(--duration-short) ease, transform var(--duration-short) ease;
|
|
89
|
+
|
|
90
|
+
&:hover {
|
|
91
|
+
color: rgb(var(--color-text));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
&:active {
|
|
95
|
+
transform: scale(0.96);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
&[data-active] {
|
|
99
|
+
color: rgb(var(--color-primary));
|
|
100
|
+
background: rgb(var(--color-primary) / 12%);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
&:focus-visible {
|
|
104
|
+
outline: 2px solid rgb(var(--color-primary));
|
|
105
|
+
outline-offset: -2px;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.hk-pill-group-icon {
|
|
110
|
+
display: inline-flex;
|
|
111
|
+
align-items: center;
|
|
112
|
+
/* An icon never widens the hit floor: replaced-element glyphs
|
|
113
|
+
* contribute nothing to a button's min-content width, so shrink
|
|
114
|
+
* pressure would collapse them before the label felt it. */
|
|
115
|
+
flex: 0 0 auto;
|
|
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
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, h } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkPillToggleGroup from "./HkPillToggleGroup";
|
|
5
|
+
|
|
6
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
7
|
+
|
|
8
|
+
function mount(node: ReturnType<typeof h>) {
|
|
9
|
+
const container = document.createElement("div");
|
|
10
|
+
document.body.appendChild(container);
|
|
11
|
+
const app = createApp({ render: () => node });
|
|
12
|
+
app.mount(container);
|
|
13
|
+
mounts.push({ app, container });
|
|
14
|
+
return container;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
19
|
+
app.unmount();
|
|
20
|
+
container.remove();
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const OPTIONS = [
|
|
25
|
+
{ value: "deepseek", label: "DeepSeek" },
|
|
26
|
+
{ value: "openai", label: "OpenAI" },
|
|
27
|
+
{ value: "anthropic", label: "Anthropic" },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
describe("HkPillToggleGroup", () => {
|
|
31
|
+
it("renders one pill per option plus the all-pill, active state from the selected set", () => {
|
|
32
|
+
const c = mount(
|
|
33
|
+
h(HkPillToggleGroup, {
|
|
34
|
+
label: "Providers",
|
|
35
|
+
options: OPTIONS,
|
|
36
|
+
selected: new Set(["openai"]),
|
|
37
|
+
allLabel: "All",
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
const pills = [...c.querySelectorAll<HTMLButtonElement>(".hk-pill-group-pill")];
|
|
41
|
+
expect(pills.map((p) => p.textContent?.trim())).toEqual(["All", "DeepSeek", "OpenAI", "Anthropic"]);
|
|
42
|
+
// Active tint rides data-active (the segmented grammar — no aria-only state).
|
|
43
|
+
expect(pills[0].hasAttribute("data-active")).toBe(false);
|
|
44
|
+
expect(pills[2].hasAttribute("data-active")).toBe(true);
|
|
45
|
+
// Every pill is a pressed-state toggle button (screen readers hear the set).
|
|
46
|
+
expect(pills[2].getAttribute("aria-pressed")).toBe("true");
|
|
47
|
+
expect(pills[1].getAttribute("aria-pressed")).toBe("false");
|
|
48
|
+
expect(c.querySelector(".hk-pill-group")!.getAttribute("role")).toBe("group");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("marks the all-pill active when nothing is picked, and hides it without allLabel", () => {
|
|
52
|
+
const withAll = mount(
|
|
53
|
+
h(HkPillToggleGroup, { label: "P", options: OPTIONS, selected: new Set<string>(), allLabel: "All" }),
|
|
54
|
+
);
|
|
55
|
+
expect(withAll.querySelectorAll(".hk-pill-group-pill")[0].hasAttribute("data-active")).toBe(true);
|
|
56
|
+
|
|
57
|
+
const bare = mount(h(HkPillToggleGroup, { label: "P", options: OPTIONS, selected: new Set<string>() }));
|
|
58
|
+
expect(bare.querySelectorAll(".hk-pill-group-pill").length).toBe(OPTIONS.length);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("reports toggle(value) and all() — the set itself is never mutated here", async () => {
|
|
62
|
+
const seen: string[] = [];
|
|
63
|
+
const allClicks: number[] = [];
|
|
64
|
+
const selected = new Set<string>(["deepseek"]);
|
|
65
|
+
const c = mount(
|
|
66
|
+
h(HkPillToggleGroup, {
|
|
67
|
+
label: "P",
|
|
68
|
+
options: OPTIONS,
|
|
69
|
+
selected,
|
|
70
|
+
allLabel: "All",
|
|
71
|
+
onToggle: (v: string) => seen.push(v),
|
|
72
|
+
onAll: () => allClicks.push(1),
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
const pills = [...c.querySelectorAll<HTMLButtonElement>(".hk-pill-group-pill")];
|
|
76
|
+
pills[2].dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
77
|
+
await Promise.resolve();
|
|
78
|
+
expect(seen).toEqual(["openai"]);
|
|
79
|
+
// The host-owned set is untouched — the component only reports.
|
|
80
|
+
expect([...selected]).toEqual(["deepseek"]);
|
|
81
|
+
|
|
82
|
+
pills[0].dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
83
|
+
await Promise.resolve();
|
|
84
|
+
expect(allClicks.length).toBe(1);
|
|
85
|
+
expect(seen).toEqual(["openai"]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("normalizes option icons: components render, junk renders nothing, nothing stringifies", async () => {
|
|
89
|
+
const { h } = await import("vue");
|
|
90
|
+
const { Zap } = await import("lucide-vue-next");
|
|
91
|
+
const c = mount(
|
|
92
|
+
h(HkPillToggleGroup, {
|
|
93
|
+
label: "P",
|
|
94
|
+
selected: new Set<string>(),
|
|
95
|
+
options: [
|
|
96
|
+
{ value: "fn", label: "Fn", icon: Zap },
|
|
97
|
+
{ value: "vnode", label: "Vn", icon: h(Zap) },
|
|
98
|
+
{ value: "junk", label: "Jk", icon: 42 },
|
|
99
|
+
],
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
await Promise.resolve();
|
|
103
|
+
const pills = [...c.querySelectorAll<HTMLButtonElement>(".hk-pill-group-pill")];
|
|
104
|
+
// A raw lucide component FUNCTION must render as its <svg> — the R1
|
|
105
|
+
// landmine: unnormalized it stringifies into source text.
|
|
106
|
+
expect(pills[0].querySelector("svg"), "function icon renders").toBeTruthy();
|
|
107
|
+
expect(pills[0].textContent).not.toContain("h(");
|
|
108
|
+
// A prebuilt vnode passes through identically.
|
|
109
|
+
expect(pills[1].querySelector("svg"), "vnode icon renders").toBeTruthy();
|
|
110
|
+
// Junk (a number) renders nothing — and never its digits.
|
|
111
|
+
expect(pills[2].querySelector(".hk-pill-group-icon")).toBeNull();
|
|
112
|
+
expect(pills[2].textContent?.trim()).toBe("Jk");
|
|
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
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { defineComponent, h, type PropType, type VNode } from "vue";
|
|
2
|
+
|
|
3
|
+
import { useOptionStrip } from "../composables/useOptionStrip";
|
|
4
|
+
import "./HkPillToggleGroup.scss";
|
|
5
|
+
|
|
6
|
+
/** One toggleable pill of the group. */
|
|
7
|
+
export interface HkPillToggleGroupOption {
|
|
8
|
+
/** Stable identity — the value emitted back through `toggle`. */
|
|
9
|
+
value: string;
|
|
10
|
+
label: string;
|
|
11
|
+
/** Optional leading glyph. Accepts EITHER a component (a lucide icon
|
|
12
|
+
* function, a defineComponent options object) or a prebuilt vnode —
|
|
13
|
+
* both normalize to a rendered element; anything else is skipped
|
|
14
|
+
* (never stringified: a raw function child renders as source text,
|
|
15
|
+
* which is exactly the landmine this normalization defuses). */
|
|
16
|
+
icon?: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Normalize an option icon to a renderable vnode. A component (function
|
|
20
|
+
* or options object) is instantiated with `h`; a prebuilt vnode passes
|
|
21
|
+
* through; anything else (string, number, null) renders nothing. */
|
|
22
|
+
function resolveIcon(icon: unknown): VNode | null {
|
|
23
|
+
if (icon == null) return null;
|
|
24
|
+
if (typeof icon === "object") return icon as VNode;
|
|
25
|
+
if (typeof icon === "function") return h(icon as Parameters<typeof h>[0]);
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* HkPillToggleGroup — the segmented pill-track multi-select: shared
|
|
31
|
+
* chrome around per-option toggle buttons, the active option carrying
|
|
32
|
+
* the indicator tint directly (multi-select cannot use HkTabs — its
|
|
33
|
+
* sliding indicator is radio semantics). Grammar ported from the wowsp
|
|
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.
|
|
41
|
+
*
|
|
42
|
+
* Deliberately THIN (the host owns the set): `toggle(value)` reports one
|
|
43
|
+
* pill toggled; the optional leading all-pill reports `all()` and the
|
|
44
|
+
* HOST decides what that means (clear the facet, or select everything —
|
|
45
|
+
* both are legitimate; see HkFilterChip and the provider model groups).
|
|
46
|
+
* `selected` is the single source of truth and is never mutated here.
|
|
47
|
+
*/
|
|
48
|
+
export default defineComponent({
|
|
49
|
+
name: "HkPillToggleGroup",
|
|
50
|
+
props: {
|
|
51
|
+
/** Accessible name of the group (role=group aria-label). */
|
|
52
|
+
label: { type: String, required: true },
|
|
53
|
+
options: {
|
|
54
|
+
type: Array as PropType<HkPillToggleGroupOption[]>,
|
|
55
|
+
default: () => [],
|
|
56
|
+
},
|
|
57
|
+
/** The picked values — single source of truth, never mutated here. */
|
|
58
|
+
selected: { type: Object as PropType<Set<string>>, default: () => new Set<string>() },
|
|
59
|
+
/** The leading all-pill's label; omit to hide the all-pill. The pill
|
|
60
|
+
* wears the active tint exactly when nothing is picked. */
|
|
61
|
+
allLabel: { type: String, default: undefined },
|
|
62
|
+
},
|
|
63
|
+
emits: {
|
|
64
|
+
toggle: (_value: string) => true,
|
|
65
|
+
all: () => true,
|
|
66
|
+
},
|
|
67
|
+
setup(props, { emit }) {
|
|
68
|
+
const { stripEl, panning, onPointerDown } = useOptionStrip();
|
|
69
|
+
|
|
70
|
+
return () => (
|
|
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
|
+
>
|
|
79
|
+
{props.allLabel != null && (
|
|
80
|
+
<button
|
|
81
|
+
type="button"
|
|
82
|
+
class="hk-pill-group-pill"
|
|
83
|
+
data-active={props.selected.size === 0 || undefined}
|
|
84
|
+
aria-pressed={props.selected.size === 0 ? "true" : "false"}
|
|
85
|
+
onClick={() => emit("all")}
|
|
86
|
+
>
|
|
87
|
+
{props.allLabel}
|
|
88
|
+
</button>
|
|
89
|
+
)}
|
|
90
|
+
{props.options.map((o) => {
|
|
91
|
+
const icon = resolveIcon(o.icon);
|
|
92
|
+
return (
|
|
93
|
+
<button
|
|
94
|
+
type="button"
|
|
95
|
+
key={o.value}
|
|
96
|
+
class="hk-pill-group-pill"
|
|
97
|
+
data-active={props.selected.has(o.value) || undefined}
|
|
98
|
+
aria-pressed={props.selected.has(o.value) ? "true" : "false"}
|
|
99
|
+
onClick={() => emit("toggle", o.value)}
|
|
100
|
+
>
|
|
101
|
+
{icon != null && <span class="hk-pill-group-icon">{icon}</span>}
|
|
102
|
+
{o.label}
|
|
103
|
+
</button>
|
|
104
|
+
);
|
|
105
|
+
})}
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
},
|
|
109
|
+
});
|
|
@@ -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
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -830,6 +830,9 @@ export { default as HkFilterBar } from "./components/HkFilterBar";
|
|
|
830
830
|
export { default as HkFilterPanel, sanitizeNumberInput } from "./components/HkFilterPanel";
|
|
831
831
|
export type { HkFilterOperator, HkFilterFieldDef, HkFilterCondition, HkFilterState } from "./components/HkFilterPanel";
|
|
832
832
|
export { default as HkFilterTrigger } from "./components/HkFilterTrigger";
|
|
833
|
+
export { default as HkFilterChip } from "./components/HkFilterChip";
|
|
834
|
+
export { default as HkPillToggleGroup } from "./components/HkPillToggleGroup";
|
|
835
|
+
export type { HkPillToggleGroupOption } from "./components/HkPillToggleGroup";
|
|
833
836
|
export { HkIconChip as HkIconChip } from "./components/HkIconChip";
|
|
834
837
|
export { HkIconChip as HIconChip } from "./components/HkIconChip";
|
|
835
838
|
export { HkCardList, HkListRow } from "./components/HkCardList";
|