@celestia-island/hikari 0.55.164 → 0.55.165
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/HkFilterOperatorIcon.tsx +69 -0
- package/src/components/HkFilterPanel.scss +8 -0
- package/src/components/HkFilterPanel.styles.test.ts +6 -0
- package/src/components/HkFilterPanel.test.tsx +11 -6
- package/src/components/HkFilterPanel.tsx +19 -16
- package/src/components/HkFilterTrigger.test.tsx +1 -1
- package/src/components/HkFilterTrigger.tsx +2 -2
package/package.json
CHANGED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { defineComponent } from "vue";
|
|
2
|
+
import { Equal, EqualApproximately, EqualNot } from "lucide-vue-next";
|
|
3
|
+
|
|
4
|
+
import type { HkFilterOperator } from "./HkFilterPanel";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The operator glyph as an ICON, never a text character (user direction
|
|
8
|
+
* 2026-10-15: font stacks render U+2249 ≉ unevenly — the operator
|
|
9
|
+
* buttons are UI chrome, so they draw like every other icon in the
|
|
10
|
+
* family). Lucide ships =, ≠ and ≈; the ordered four and ≉ are
|
|
11
|
+
* hikari-authored in lucide's exact stroke grammar (24×24, stroke 2,
|
|
12
|
+
* currentColor) — ≉ is lucide's own ≈ path pair with EqualNot's slash
|
|
13
|
+
* composed in, so the set reads as one family.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// Lucide's EqualApproximately path pair (ISC, verbatim) + its EqualNot
|
|
17
|
+
// slash — composed rather than reinvented.
|
|
18
|
+
const NAPPROX_PATHS = [
|
|
19
|
+
"M5 15a6.5 6.5 0 0 1 7 0 6.5 6.5 0 0 0 7 0",
|
|
20
|
+
"M5 9a6.5 6.5 0 0 1 7 0 6.5 6.5 0 0 0 7 0",
|
|
21
|
+
"m5 19 14-14",
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const CUSTOM: Partial<Record<HkFilterOperator, string[]>> = {
|
|
25
|
+
napprox: NAPPROX_PATHS,
|
|
26
|
+
lt: ["m19 6-11 6 11 6"],
|
|
27
|
+
lte: ["m19 5-11 6 11 6", "M8 20h11"],
|
|
28
|
+
gt: ["m5 6 11 6-11 6"],
|
|
29
|
+
gte: ["m5 5 11 6-11 6", "M5 20h11"],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const LUCIDE: Partial<Record<HkFilterOperator, typeof Equal>> = {
|
|
33
|
+
eq: Equal,
|
|
34
|
+
ne: EqualNot,
|
|
35
|
+
approx: EqualApproximately,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export default defineComponent({
|
|
39
|
+
name: "HkFilterOperatorIcon",
|
|
40
|
+
props: {
|
|
41
|
+
op: { type: String as () => HkFilterOperator, required: true },
|
|
42
|
+
size: { type: Number, default: 14 },
|
|
43
|
+
},
|
|
44
|
+
setup(props) {
|
|
45
|
+
return () => {
|
|
46
|
+
const Lucide = LUCIDE[props.op];
|
|
47
|
+
if (Lucide) return <Lucide size={props.size} aria-hidden="true" />;
|
|
48
|
+
const paths = CUSTOM[props.op]!;
|
|
49
|
+
return (
|
|
50
|
+
<svg
|
|
51
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
52
|
+
width={props.size}
|
|
53
|
+
height={props.size}
|
|
54
|
+
viewBox="0 0 24 24"
|
|
55
|
+
fill="none"
|
|
56
|
+
stroke="currentColor"
|
|
57
|
+
stroke-width="2"
|
|
58
|
+
stroke-linecap="round"
|
|
59
|
+
stroke-linejoin="round"
|
|
60
|
+
aria-hidden="true"
|
|
61
|
+
>
|
|
62
|
+
{paths.map((d) => (
|
|
63
|
+
<path key={d} d={d} />
|
|
64
|
+
))}
|
|
65
|
+
</svg>
|
|
66
|
+
);
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
});
|
|
@@ -42,6 +42,14 @@
|
|
|
42
42
|
/* Minimum outer margins for every column (user direction
|
|
43
43
|
* 2026-10-12): the rows never touch the panel edge. */
|
|
44
44
|
padding: var(--space-8, 0.5rem) var(--space-12, 0.75rem);
|
|
45
|
+
/* Empty panel collapses to the add verb's own width (user direction
|
|
46
|
+
* 2026-10-15): no rows means nothing to align columns against, so the
|
|
47
|
+
* popover hugs the 添加条件 button instead of holding the full-row
|
|
48
|
+
* width. Desktop only — the phone branch below re-widens to 100%. */
|
|
49
|
+
&[data-empty] {
|
|
50
|
+
width: max-content;
|
|
51
|
+
min-width: 0;
|
|
52
|
+
}
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
/* A row is not a box — it donates its three cells to the panel grid so
|
|
@@ -73,6 +73,12 @@ describe("HkFilterPanel stylesheet contract", () => {
|
|
|
73
73
|
expect(rule).toContain("justify-self: start");
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
+
it("the empty panel collapses to the add verb's own width", () => {
|
|
77
|
+
// 2026-10-15: no rows = nothing to align columns against — the
|
|
78
|
+
// desktop popover hugs the 添加条件 button (data-empty marker).
|
|
79
|
+
expect(css).toMatch(/\.hk-filter-panel\[data-empty\]\s*\{[^}]*width:\s*max-content/);
|
|
80
|
+
});
|
|
81
|
+
|
|
76
82
|
it("the phone operator column keeps its widened cap (≥ the old 4.25rem)", () => {
|
|
77
83
|
expect(css).toContain("@media (max-width: 767px)");
|
|
78
84
|
const media = css.slice(css.indexOf("@media (max-width: 767px)"));
|
|
@@ -45,6 +45,9 @@ describe("HkFilterPanel (array state, 2026-10-15)", () => {
|
|
|
45
45
|
expect(c.querySelectorAll(".hk-filter-panel-row")).toHaveLength(0);
|
|
46
46
|
expect(c.querySelector(".hk-filter-panel-add")).toBeTruthy();
|
|
47
47
|
expect(c.querySelectorAll(".hk-filter-panel-field")).toHaveLength(0);
|
|
48
|
+
// The empty panel carries the collapse marker (width hugs the add
|
|
49
|
+
// verb on desktop — pinned in the styles contract test).
|
|
50
|
+
expect(c.querySelector(".hk-filter-panel")!.getAttribute("data-empty")).toBe("true");
|
|
48
51
|
|
|
49
52
|
const state: HkFilterState = [
|
|
50
53
|
{ key: "tool", op: "approx", value: "web" },
|
|
@@ -188,13 +191,15 @@ describe("HkFilterPanel (array state, 2026-10-15)", () => {
|
|
|
188
191
|
);
|
|
189
192
|
const rows = [...c.querySelectorAll(".hk-filter-panel-row")];
|
|
190
193
|
const opsOf = (row: Element) =>
|
|
191
|
-
[...row.querySelectorAll(".hk-filter-panel-op")].map((b) => b.
|
|
194
|
+
[...row.querySelectorAll(".hk-filter-panel-op")].map((b) => b.getAttribute("title"));
|
|
192
195
|
// Text speaks ONLY the fuzzy pair (2026-10-15: 等于其实是模糊匹配 —
|
|
193
196
|
// label it ≈, and the exclusion ≉).
|
|
194
|
-
expect(opsOf(rows[0]!)).toEqual(["
|
|
195
|
-
expect(opsOf(rows[1]!)).toEqual(["
|
|
196
|
-
|
|
197
|
-
|
|
197
|
+
expect(opsOf(rows[0]!)).toEqual(["fuzzy match", "fuzzy non-match"]);
|
|
198
|
+
expect(opsOf(rows[1]!)).toEqual(["less than", "at most", "equals", "at least", "greater than"]);
|
|
199
|
+
// Select speaks ONLY the eq/ne pair — rendered as the fuzzy pair
|
|
200
|
+
// glyphs (2026-10-15 follow-up: 等于/不等于 换成 ≈/≉).
|
|
201
|
+
expect(opsOf(rows[2]!)).toEqual(["fuzzy match", "fuzzy non-match"]);
|
|
202
|
+
expect(opsOf(rows[3]!)).toEqual(["at least", "at most"]);
|
|
198
203
|
});
|
|
199
204
|
|
|
200
205
|
it("emits a new state when an operator is picked (live, no confirm)", async () => {
|
|
@@ -207,7 +212,7 @@ describe("HkFilterPanel (array state, 2026-10-15)", () => {
|
|
|
207
212
|
}),
|
|
208
213
|
);
|
|
209
214
|
const notMatch = [...c.querySelectorAll(".hk-filter-panel-row")[0]!.querySelectorAll(".hk-filter-panel-op")]
|
|
210
|
-
.find((b) => b.
|
|
215
|
+
.find((b) => b.getAttribute("title") === "fuzzy non-match")!;
|
|
211
216
|
notMatch.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
212
217
|
await Promise.resolve();
|
|
213
218
|
expect(seen).toEqual([{ key: "tool", op: "napprox", value: "web" }]);
|
|
@@ -5,6 +5,7 @@ import HkDatePicker from "./HkDatePicker";
|
|
|
5
5
|
import HkInput from "./HkInput";
|
|
6
6
|
import HkPopover from "./HkPopover";
|
|
7
7
|
import HkPopupSelect, { type HkPopupSelectOption } from "./HkPopupSelect";
|
|
8
|
+
import HkFilterOperatorIcon from "./HkFilterOperatorIcon";
|
|
8
9
|
import HkScrollContainer from "./HkScrollContainer";
|
|
9
10
|
import { useI18n } from "../i18n/context";
|
|
10
11
|
import "./HkFilterPanel.scss";
|
|
@@ -53,16 +54,15 @@ export interface HkFilterCondition {
|
|
|
53
54
|
* field chooser. */
|
|
54
55
|
export type HkFilterState = HkFilterCondition[];
|
|
55
56
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
};
|
|
57
|
+
/** Which ICON the operator renders as: fields that speak ONLY the
|
|
58
|
+
* eq/ne pair (select) render the fuzzy pair ≈/≉ (2026-10-15 — 等于
|
|
59
|
+
* 其实是模糊匹配 applies to them too); number's exact = keeps the
|
|
60
|
+
* plain equals icon inside the ordered five. */
|
|
61
|
+
function glyphOpFor(kind: HkFilterFieldDef["kind"], op: HkFilterOperator): HkFilterOperator {
|
|
62
|
+
if (kind === "select" && op === "eq") return "approx";
|
|
63
|
+
if (kind === "select" && op === "ne") return "napprox";
|
|
64
|
+
return op;
|
|
65
|
+
}
|
|
66
66
|
|
|
67
67
|
const OPERATOR_LABEL: Record<HkFilterOperator, { key: string; fallback: string }> = {
|
|
68
68
|
eq: { key: "hikari::filterPanel.op.eq", fallback: "equals" },
|
|
@@ -309,7 +309,7 @@ export default defineComponent({
|
|
|
309
309
|
return () => {
|
|
310
310
|
const label = props.label ?? t("hikari::filterBar.trigger", "Filters");
|
|
311
311
|
return (
|
|
312
|
-
<div class="hk-filter-panel" data-testid="hk-filter-panel" data-active={activeCount.value > 0 || undefined} aria-label={label}>
|
|
312
|
+
<div class="hk-filter-panel" data-testid="hk-filter-panel" data-active={activeCount.value > 0 || undefined} data-empty={rows.value.length === 0 || undefined} aria-label={label}>
|
|
313
313
|
{rows.value.map((cond, index) => {
|
|
314
314
|
const field = fieldByKey(cond.key);
|
|
315
315
|
// An undeclared key (the page renamed a field under live
|
|
@@ -322,20 +322,23 @@ export default defineComponent({
|
|
|
322
322
|
<div key={index} class="hk-filter-panel-row" data-kind={kind}>
|
|
323
323
|
<span class="hk-filter-panel-field">{labelText}</span>
|
|
324
324
|
<div class="hk-filter-panel-ops" role="group" aria-label={`${labelText} — ${label}`}>
|
|
325
|
-
{ops.map((op) =>
|
|
325
|
+
{ops.map((op) => {
|
|
326
|
+
const glyph = glyphOpFor(kind, op);
|
|
327
|
+
return (
|
|
326
328
|
<button
|
|
327
329
|
key={op}
|
|
328
330
|
type="button"
|
|
329
331
|
class="hk-filter-panel-op"
|
|
330
332
|
aria-pressed={cond.op === op}
|
|
331
|
-
aria-label={t(OPERATOR_LABEL[
|
|
332
|
-
title={t(OPERATOR_LABEL[
|
|
333
|
+
aria-label={t(OPERATOR_LABEL[glyph].key, OPERATOR_LABEL[glyph].fallback)}
|
|
334
|
+
title={t(OPERATOR_LABEL[glyph].key, OPERATOR_LABEL[glyph].fallback)}
|
|
333
335
|
disabled={props.disabled || !field}
|
|
334
336
|
onClick={() => emitCondition(index, { op })}
|
|
335
337
|
>
|
|
336
|
-
{
|
|
338
|
+
<HkFilterOperatorIcon op={glyph} />
|
|
337
339
|
</button>
|
|
338
|
-
|
|
340
|
+
);
|
|
341
|
+
})}
|
|
339
342
|
</div>
|
|
340
343
|
<div class="hk-filter-panel-value">
|
|
341
344
|
<div class="hk-filter-panel-control">
|
|
@@ -319,7 +319,7 @@ describe("HkFilterTrigger", () => {
|
|
|
319
319
|
// stand down for it, not swallow it. (Pick the ≈ toggle; the first
|
|
320
320
|
// op button is the already-pressed =.)
|
|
321
321
|
const op = [...document.body.querySelectorAll<HTMLButtonElement>(".hk-filter-panel .hk-filter-panel-op")].find(
|
|
322
|
-
(b) => b.
|
|
322
|
+
(b) => b.getAttribute("title") === "fuzzy match",
|
|
323
323
|
)!;
|
|
324
324
|
op.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
325
325
|
await Promise.resolve();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { computed, defineComponent, ref, type PropType } from "vue";
|
|
2
|
-
import {
|
|
2
|
+
import { Filter } from "lucide-vue-next";
|
|
3
3
|
|
|
4
4
|
import HkButton from "./HkButton";
|
|
5
5
|
import HkCornerBadge from "./HkCornerBadge";
|
|
@@ -74,7 +74,7 @@ export default defineComponent({
|
|
|
74
74
|
open.value = !open.value;
|
|
75
75
|
}}
|
|
76
76
|
>
|
|
77
|
-
{{ icon: () => <
|
|
77
|
+
{{ icon: () => <Filter size={16} /> }}
|
|
78
78
|
</HkIconButton>
|
|
79
79
|
</HkCornerBadge>
|
|
80
80
|
<HkPopover
|