@celestia-island/hikari 0.55.87 → 0.55.89
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/HkFilterPanel.scss +115 -0
- package/src/components/HkFilterPanel.test.tsx +152 -0
- package/src/components/HkFilterPanel.tsx +198 -0
- package/src/components/HkFilterTrigger.mobile.test.tsx +55 -0
- package/src/components/HkFilterTrigger.scss +34 -0
- package/src/components/HkFilterTrigger.test.tsx +80 -0
- package/src/components/HkFilterTrigger.tsx +117 -0
- package/src/i18n/locales/ar/components.json +9 -1
- package/src/i18n/locales/de/components.json +9 -1
- package/src/i18n/locales/en/components.json +9 -1
- package/src/i18n/locales/es/components.json +9 -1
- package/src/i18n/locales/fr/components.json +9 -1
- package/src/i18n/locales/ja/components.json +9 -1
- package/src/i18n/locales/ko/components.json +9 -1
- package/src/i18n/locales/pt/components.json +9 -1
- package/src/i18n/locales/ru/components.json +9 -1
- package/src/i18n/locales/zh-Hans/components.json +9 -1
- package/src/i18n/locales/zh-Hant/components.json +9 -1
- package/src/index.ts +3 -0
package/package.json
CHANGED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HkFilterPanel — the condition-row list inside the header filter
|
|
3
|
+
* affordance. Bounded on BOTH axes by design (user direction
|
|
4
|
+
* 2026-09-29): never too tall, never too wide, but never so narrow that
|
|
5
|
+
* the operator symbol group cannot fit — on phones the operator column
|
|
6
|
+
* narrows to roughly TWO visible symbols and scrolls horizontally
|
|
7
|
+
* instead.
|
|
8
|
+
*
|
|
9
|
+
* Row anatomy: [field label | operator symbol group | value control].
|
|
10
|
+
* Edits commit LIVE (the desktop panel has no confirm button); the
|
|
11
|
+
* mobile sheet's single confirm merely closes — the standard HkPopover
|
|
12
|
+
* sheetOnMobile split renders the same panel in both shapes.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
.hk-filter-panel {
|
|
16
|
+
display: flex;
|
|
17
|
+
flex-direction: column;
|
|
18
|
+
gap: var(--space-8, 0.5rem);
|
|
19
|
+
/* Bounded width: wide enough for the operator group, narrow enough to
|
|
20
|
+
* read as a header dropdown (the popover host clamps to the viewport
|
|
21
|
+
* edge; this keeps the panel from stretching to meet it). */
|
|
22
|
+
width: clamp(21rem, 92vw, 27rem);
|
|
23
|
+
max-width: 100%;
|
|
24
|
+
/* Bounded height: ~7 rows of breathing room, then internal scroll —
|
|
25
|
+
* a field-rich page must not grow an unbounded dropdown. */
|
|
26
|
+
max-height: min(26rem, 60vh);
|
|
27
|
+
overflow-y: auto;
|
|
28
|
+
padding: var(--space-4, 0.25rem);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.hk-filter-panel-row {
|
|
32
|
+
display: grid;
|
|
33
|
+
grid-template-columns: minmax(4.5rem, auto) minmax(0, auto) minmax(0, 1fr);
|
|
34
|
+
align-items: center;
|
|
35
|
+
gap: var(--space-8, 0.5rem);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.hk-filter-panel-field {
|
|
39
|
+
font-size: var(--text-xs, 0.75rem);
|
|
40
|
+
color: rgb(var(--color-muted, var(--color-text-secondary)));
|
|
41
|
+
overflow: hidden;
|
|
42
|
+
text-overflow: ellipsis;
|
|
43
|
+
white-space: nowrap;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* Operator symbol group: one glyph per toggle button. Scrolls
|
|
47
|
+
* horizontally when the kind's operator set (or a phone viewport)
|
|
48
|
+
* outgrows the column; scroll snapping keeps each symbol landing
|
|
49
|
+
* cleanly under the thumb. */
|
|
50
|
+
.hk-filter-panel-ops {
|
|
51
|
+
display: flex;
|
|
52
|
+
align-items: center;
|
|
53
|
+
gap: var(--space-2, 0.125rem);
|
|
54
|
+
overflow-x: auto;
|
|
55
|
+
overscroll-behavior-x: contain;
|
|
56
|
+
scroll-snap-type: x proximity;
|
|
57
|
+
scrollbar-width: none;
|
|
58
|
+
padding: 1px;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.hk-filter-panel-op {
|
|
62
|
+
flex: 0 0 auto;
|
|
63
|
+
scroll-snap-align: center;
|
|
64
|
+
min-width: 1.75rem;
|
|
65
|
+
height: 1.75rem;
|
|
66
|
+
padding: 0 var(--space-6, 0.375rem);
|
|
67
|
+
display: inline-flex;
|
|
68
|
+
align-items: center;
|
|
69
|
+
justify-content: center;
|
|
70
|
+
border: none;
|
|
71
|
+
border-radius: var(--radius-sm, 6px);
|
|
72
|
+
background: transparent;
|
|
73
|
+
color: rgb(var(--color-muted, var(--color-text-secondary)));
|
|
74
|
+
font-size: var(--text-sm, 0.875rem);
|
|
75
|
+
line-height: 1;
|
|
76
|
+
cursor: pointer;
|
|
77
|
+
transition: background 0.15s ease, color 0.15s ease;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.hk-filter-panel-op:hover {
|
|
81
|
+
background: rgb(var(--color-primary) / 12%);
|
|
82
|
+
color: rgb(var(--color-text));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.hk-filter-panel-op[aria-pressed="true"] {
|
|
86
|
+
background: rgb(var(--color-primary) / 18%);
|
|
87
|
+
color: rgb(var(--color-primary));
|
|
88
|
+
font-weight: 700;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.hk-filter-panel-op:disabled {
|
|
92
|
+
opacity: 0.5;
|
|
93
|
+
cursor: not-allowed;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.hk-filter-panel-value {
|
|
97
|
+
min-width: 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* Phones: the operator column narrows hard (about two symbols visible)
|
|
101
|
+
* and the row lets the value control take the remaining width. The
|
|
102
|
+
* group itself scrolls — same interaction, smaller footprint. */
|
|
103
|
+
@media (max-width: 767px) {
|
|
104
|
+
.hk-filter-panel {
|
|
105
|
+
width: 100%;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.hk-filter-panel-row {
|
|
109
|
+
grid-template-columns: minmax(3.75rem, auto) minmax(3.9rem, auto) minmax(0, 1fr);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.hk-filter-panel-ops {
|
|
113
|
+
max-width: 4.25rem;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, h } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkFilterPanel, { sanitizeNumberInput, type HkFilterState } from "./HkFilterPanel";
|
|
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 FIELDS = [
|
|
25
|
+
{ key: "tool", label: "Tool", kind: "text" as const, placeholder: "web_automation" },
|
|
26
|
+
{ key: "count", label: "Calls", kind: "number" as const },
|
|
27
|
+
{ key: "agent", label: "Agent", kind: "select" as const, options: [{ value: "a1", label: "Agent One" }] },
|
|
28
|
+
{ key: "since", label: "Since", kind: "date" as const },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
describe("HkFilterPanel", () => {
|
|
32
|
+
it("renders one condition row per declared field", () => {
|
|
33
|
+
const c = mount(h(HkFilterPanel, { fields: FIELDS, modelValue: {} }));
|
|
34
|
+
const rows = c.querySelectorAll(".hk-filter-panel-row");
|
|
35
|
+
expect(rows).toHaveLength(FIELDS.length);
|
|
36
|
+
const labels = [...c.querySelectorAll(".hk-filter-panel-field")].map((el) => el.textContent);
|
|
37
|
+
expect(labels).toEqual(["Tool", "Calls", "Agent", "Since"]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("gives each kind its default operator set (regular 3, numeric 5, select 2, date 2)", () => {
|
|
41
|
+
const c = mount(h(HkFilterPanel, { fields: FIELDS, modelValue: {} }));
|
|
42
|
+
const rows = [...c.querySelectorAll(".hk-filter-panel-row")];
|
|
43
|
+
const opCounts = rows.map(
|
|
44
|
+
(row) => row.querySelectorAll(".hk-filter-panel-op").length,
|
|
45
|
+
);
|
|
46
|
+
// Regular = 3 (equals / not-equals / fuzzy); numeric = the ordered
|
|
47
|
+
// five with NO not-equals; select = equals / not-equals; date = the
|
|
48
|
+
// ordered pair.
|
|
49
|
+
expect(opCounts).toEqual([3, 5, 2, 2]);
|
|
50
|
+
// Symbols, not words: one glyph per button.
|
|
51
|
+
const numberOps = [...rows[1]!.querySelectorAll(".hk-filter-panel-op")].map(
|
|
52
|
+
(b) => b.textContent,
|
|
53
|
+
);
|
|
54
|
+
expect(numberOps).toEqual(["<", "≤", "=", "≥", ">"]);
|
|
55
|
+
// The kind's first operator is the pressed one for an untouched field.
|
|
56
|
+
const pressed = rows[0]!.querySelector('.hk-filter-panel-op[aria-pressed="true"]');
|
|
57
|
+
expect(pressed?.textContent).toBe("=");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("emits a new state when an operator is picked (live, no confirm)", () => {
|
|
61
|
+
let seen: HkFilterState | null = null;
|
|
62
|
+
const c = mount(
|
|
63
|
+
h(HkFilterPanel, {
|
|
64
|
+
fields: FIELDS,
|
|
65
|
+
modelValue: { tool: { op: "eq", value: "web" } },
|
|
66
|
+
"onUpdate:modelValue": (s: HkFilterState) => (seen = s),
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
const fuzzy = [...c.querySelectorAll(".hk-filter-panel-row")[0]!.querySelectorAll(".hk-filter-panel-op")]
|
|
70
|
+
.find((b) => b.textContent === "≈")!;
|
|
71
|
+
fuzzy.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
72
|
+
expect(seen).not.toBeNull();
|
|
73
|
+
expect(seen!["tool"]).toEqual({ op: "approx", value: "web" });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("renders numeric fields on the number-variant input (native legal-number filtering)", async () => {
|
|
77
|
+
let seen: HkFilterState | null = null;
|
|
78
|
+
const c = mount(
|
|
79
|
+
h(HkFilterPanel, {
|
|
80
|
+
fields: FIELDS,
|
|
81
|
+
modelValue: {},
|
|
82
|
+
"onUpdate:modelValue": (s: HkFilterState) => (seen = s),
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
// The number row's control is the number-variant input (type=number —
|
|
86
|
+
// the browser refuses illegal keystrokes); the model side keeps the
|
|
87
|
+
// shape guard (sanitizeNumberInput) as the belt.
|
|
88
|
+
const input = c.querySelectorAll<HTMLInputElement>(".hk-filter-panel-row input")[1]!;
|
|
89
|
+
expect(input.type).toBe("number");
|
|
90
|
+
input.value = "42.5";
|
|
91
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
92
|
+
await Promise.resolve();
|
|
93
|
+
expect(seen).not.toBeNull();
|
|
94
|
+
expect(seen!["count"]).toEqual({ op: "lt", value: "42.5" });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("keeps select fields on a popup select and prepends the empty clear row", () => {
|
|
98
|
+
const c = mount(h(HkFilterPanel, { fields: FIELDS, modelValue: {} }));
|
|
99
|
+
const selectRow = c.querySelectorAll(".hk-filter-panel-row")[2]!;
|
|
100
|
+
expect(selectRow.querySelector(".hk-popup-select-trigger")).toBeTruthy();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("prepends a localized empty All row to select fields without one (the clear affordance)", async () => {
|
|
104
|
+
// The select field's ONLY clear affordance is the empty value row —
|
|
105
|
+
// there is no reset verb in the header trigger. A page without its
|
|
106
|
+
// own "all" entry must therefore always find the prepended
|
|
107
|
+
// {value:"", label:All} row; a page that ships one keeps its own
|
|
108
|
+
// label. (R1 mutation f2 removed the prepend and NOTHING went red —
|
|
109
|
+
// this test is that missing pin.)
|
|
110
|
+
const withOwnEmpty = [
|
|
111
|
+
{ key: "level", label: "Level", kind: "select" as const, options: [{ value: "", label: "Everything" }, { value: "high", label: "High" }] },
|
|
112
|
+
];
|
|
113
|
+
const c = mount(h(HkFilterPanel, { fields: withOwnEmpty, modelValue: {} }));
|
|
114
|
+
// A page shipping its own empty row keeps it as-is (no duplicate):
|
|
115
|
+
// the panel prepends only when NO option carries the empty value.
|
|
116
|
+
const ownTrigger = c.querySelector(".hk-filter-panel-row .hk-popup-select-trigger");
|
|
117
|
+
expect(ownTrigger).toBeTruthy();
|
|
118
|
+
// Drive the popup open to count rows: the real HkPopupSelect renders
|
|
119
|
+
// exactly the options it was given.
|
|
120
|
+
ownTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
121
|
+
await Promise.resolve();
|
|
122
|
+
const items = [...document.body.querySelectorAll(".hk-popup-select-item")].map((el) => el.textContent?.trim());
|
|
123
|
+
expect(items).toEqual(["Everything", "High"]);
|
|
124
|
+
document.body.querySelectorAll(".hk-popup-select-item, .hk-select-popout-host").forEach((el) => el.remove());
|
|
125
|
+
|
|
126
|
+
const withoutAll = [
|
|
127
|
+
{ key: "level", label: "Level", kind: "select" as const, options: [{ value: "high", label: "High" }] },
|
|
128
|
+
];
|
|
129
|
+
const c2 = mount(h(HkFilterPanel, { fields: withoutAll, modelValue: {} }));
|
|
130
|
+
const trigger2 = c2.querySelector(".hk-filter-panel-row .hk-popup-select-trigger")!;
|
|
131
|
+
trigger2.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
132
|
+
await Promise.resolve();
|
|
133
|
+
const items2 = [...document.body.querySelectorAll(".hk-popup-select-item")].map((el) => el.textContent?.trim());
|
|
134
|
+
expect(items2[0], "the localized All row is prepended").toBe("All");
|
|
135
|
+
expect(items2).toEqual(["All", "High"]);
|
|
136
|
+
document.body.querySelectorAll(".hk-popup-select-item, .hk-select-popout-host").forEach((el) => el.remove());
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("marks the panel active when any condition carries a value", () => {
|
|
140
|
+
const c = mount(h(HkFilterPanel, { fields: FIELDS, modelValue: { count: { op: "gt", value: "3" } } }));
|
|
141
|
+
expect(c.querySelector(".hk-filter-panel")!.getAttribute("data-active")).toBe("true");
|
|
142
|
+
const c2 = mount(h(HkFilterPanel, { fields: FIELDS, modelValue: {} }));
|
|
143
|
+
expect(c2.querySelector(".hk-filter-panel")!.getAttribute("data-active")).toBeNull();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("sanitizes number shapes: sign leading-only, one dot, junk stripped", () => {
|
|
147
|
+
expect(sanitizeNumberInput("12a.5b9")).toBe("12.59");
|
|
148
|
+
expect(sanitizeNumberInput("-3-x.1.2")).toBe("-3.12");
|
|
149
|
+
expect(sanitizeNumberInput("abc")).toBe("");
|
|
150
|
+
expect(sanitizeNumberInput("1-2")).toBe("12");
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { computed, defineComponent, type PropType } from "vue";
|
|
2
|
+
|
|
3
|
+
import HkInput from "./HkInput";
|
|
4
|
+
import HkPopupSelect, { type HkPopupSelectOption } from "./HkPopupSelect";
|
|
5
|
+
import { useI18n } from "../i18n/context";
|
|
6
|
+
import "./HkFilterPanel.scss";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Filter condition operators. The symbol IS the button (one glyph, one
|
|
10
|
+
* toggle — compact by design): the regular kinds get three (equals /
|
|
11
|
+
* not-equals / fuzzy), numeric fields get the ordered five (no
|
|
12
|
+
* not-equals), select fields narrow to equals / not-equals, and date
|
|
13
|
+
* fields bound with the ordered pair.
|
|
14
|
+
*/
|
|
15
|
+
export type HkFilterOperator = "eq" | "ne" | "approx" | "lt" | "lte" | "gte" | "gt";
|
|
16
|
+
|
|
17
|
+
/** One filterable field a page declares for the panel. */
|
|
18
|
+
export interface HkFilterFieldDef {
|
|
19
|
+
/** Condition key — stable, page-owned (maps to the RPC parameter). */
|
|
20
|
+
key: string;
|
|
21
|
+
label: string;
|
|
22
|
+
kind: "text" | "number" | "select" | "date";
|
|
23
|
+
/** select kind: the offered values. A leading "—" (empty value = no
|
|
24
|
+
* filter) row is prepended when the page did not include one. */
|
|
25
|
+
options?: HkPopupSelectOption[];
|
|
26
|
+
/** Override the kind's default operator set. */
|
|
27
|
+
operators?: HkFilterOperator[];
|
|
28
|
+
placeholder?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** One condition: the operator and the (string) value. An EMPTY value
|
|
32
|
+
* means the field filters nothing — this is also how a condition is
|
|
33
|
+
* cleared (re-open the panel, empty the value; there is no separate
|
|
34
|
+
* reset verb in the header affordance). */
|
|
35
|
+
export interface HkFilterCondition {
|
|
36
|
+
op: HkFilterOperator;
|
|
37
|
+
value: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Per-field condition state, keyed by the field def's key. Fields the
|
|
41
|
+
* record does not mention render with their kind's default operator and
|
|
42
|
+
* an empty value. */
|
|
43
|
+
export type HkFilterState = Record<string, HkFilterCondition>;
|
|
44
|
+
|
|
45
|
+
const OPERATOR_SYMBOL: Record<HkFilterOperator, string> = {
|
|
46
|
+
eq: "=",
|
|
47
|
+
ne: "≠",
|
|
48
|
+
approx: "≈",
|
|
49
|
+
lt: "<",
|
|
50
|
+
lte: "≤",
|
|
51
|
+
gte: "≥",
|
|
52
|
+
gt: ">",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const OPERATOR_LABEL: Record<HkFilterOperator, { key: string; fallback: string }> = {
|
|
56
|
+
eq: { key: "hikari::filterPanel.op.eq", fallback: "equals" },
|
|
57
|
+
ne: { key: "hikari::filterPanel.op.ne", fallback: "not equals" },
|
|
58
|
+
approx: { key: "hikari::filterPanel.op.approx", fallback: "fuzzy match" },
|
|
59
|
+
lt: { key: "hikari::filterPanel.op.lt", fallback: "less than" },
|
|
60
|
+
lte: { key: "hikari::filterPanel.op.lte", fallback: "at most" },
|
|
61
|
+
gte: { key: "hikari::filterPanel.op.gte", fallback: "at least" },
|
|
62
|
+
gt: { key: "hikari::filterPanel.op.gt", fallback: "greater than" },
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const DEFAULT_OPERATORS: Record<HkFilterFieldDef["kind"], HkFilterOperator[]> = {
|
|
66
|
+
text: ["eq", "ne", "approx"],
|
|
67
|
+
number: ["lt", "lte", "eq", "gte", "gt"],
|
|
68
|
+
select: ["eq", "ne"],
|
|
69
|
+
date: ["gte", "lte"],
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Keep only a legal number shape: optional leading minus, digits, one
|
|
73
|
+
* decimal point ("只允许输入合法数字" — the input filters the keystrokes
|
|
74
|
+
* rather than clamping afterwards). The intermediate fragments "-",
|
|
75
|
+
* "." and "-." survive deliberately: rejecting them mid-keystroke would
|
|
76
|
+
* make typing a negative or a decimal impossible; no chest page ships a
|
|
77
|
+
* number field yet, and one will want Number()-validation at the wire
|
|
78
|
+
* boundary when the first does. */
|
|
79
|
+
export function sanitizeNumberInput(raw: string): string {
|
|
80
|
+
let out = raw.replace(/[^-0-9.]/g, "");
|
|
81
|
+
// One minus, and only leading.
|
|
82
|
+
const hasMinus = out.startsWith("-");
|
|
83
|
+
out = out.replace(/-/g, "");
|
|
84
|
+
if (hasMinus) out = `-${out}`;
|
|
85
|
+
// One decimal point.
|
|
86
|
+
const firstDot = out.indexOf(".");
|
|
87
|
+
if (firstDot >= 0) {
|
|
88
|
+
out = out.slice(0, firstDot + 1) + out.slice(firstDot + 1).replace(/\./g, "");
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export default defineComponent({
|
|
94
|
+
name: "HkFilterPanel",
|
|
95
|
+
props: {
|
|
96
|
+
fields: { type: Array as PropType<HkFilterFieldDef[]>, required: true },
|
|
97
|
+
modelValue: { type: Object as PropType<HkFilterState>, default: () => ({}) },
|
|
98
|
+
disabled: { type: Boolean, default: false },
|
|
99
|
+
/** Panel aria-label / heading context (defaults to "Filters"). */
|
|
100
|
+
label: { type: String, default: undefined },
|
|
101
|
+
},
|
|
102
|
+
emits: {
|
|
103
|
+
"update:modelValue": (_state: HkFilterState) => true,
|
|
104
|
+
},
|
|
105
|
+
setup(props, { emit }) {
|
|
106
|
+
const { t } = useI18n();
|
|
107
|
+
|
|
108
|
+
const operatorsFor = (field: HkFilterFieldDef): HkFilterOperator[] =>
|
|
109
|
+
field.operators ?? DEFAULT_OPERATORS[field.kind];
|
|
110
|
+
|
|
111
|
+
const conditionFor = (field: HkFilterFieldDef): HkFilterCondition =>
|
|
112
|
+
props.modelValue[field.key] ?? { op: operatorsFor(field)[0]!, value: "" };
|
|
113
|
+
|
|
114
|
+
function emitCondition(field: HkFilterFieldDef, patch: Partial<HkFilterCondition>) {
|
|
115
|
+
const next: HkFilterState = { ...props.modelValue };
|
|
116
|
+
next[field.key] = { ...conditionFor(field), ...patch };
|
|
117
|
+
emit("update:modelValue", next);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const selectOptions = (field: HkFilterFieldDef): HkPopupSelectOption[] => {
|
|
121
|
+
const options = field.options ?? [];
|
|
122
|
+
// The empty row IS the clear affordance for select fields (value ""
|
|
123
|
+
// filters nothing) — pages that ship their own "all" row keep it;
|
|
124
|
+
// otherwise a LOCALIZED all-row is prepended so an empty-seeded
|
|
125
|
+
// select reads as "no filter", not as a dash.
|
|
126
|
+
if (options.some((o) => o.value === "")) return options;
|
|
127
|
+
return [{ value: "", label: t("hikari::filterPanel.all", "All") }, ...options];
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/** Active-count signal for the trigger's dot (also exported via the
|
|
131
|
+
* panel's own render for testing). */
|
|
132
|
+
const activeCount = computed(
|
|
133
|
+
() => props.fields.filter((f) => conditionFor(f).value !== "").length,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
return () => {
|
|
137
|
+
const label = props.label ?? t("hikari::filterBar.trigger", "Filters");
|
|
138
|
+
return (
|
|
139
|
+
<div class="hk-filter-panel" data-testid="hk-filter-panel" data-active={activeCount.value > 0 || undefined} aria-label={label}>
|
|
140
|
+
{props.fields.map((field) => {
|
|
141
|
+
const condition = conditionFor(field);
|
|
142
|
+
const ops = operatorsFor(field);
|
|
143
|
+
return (
|
|
144
|
+
<div key={field.key} class="hk-filter-panel-row" data-kind={field.kind}>
|
|
145
|
+
<span class="hk-filter-panel-field">{field.label}</span>
|
|
146
|
+
<div class="hk-filter-panel-ops" role="group" aria-label={`${field.label} — ${label}`}>
|
|
147
|
+
{ops.map((op) => (
|
|
148
|
+
<button
|
|
149
|
+
key={op}
|
|
150
|
+
type="button"
|
|
151
|
+
class="hk-filter-panel-op"
|
|
152
|
+
aria-pressed={condition.op === op}
|
|
153
|
+
aria-label={t(OPERATOR_LABEL[op].key, OPERATOR_LABEL[op].fallback)}
|
|
154
|
+
title={t(OPERATOR_LABEL[op].key, OPERATOR_LABEL[op].fallback)}
|
|
155
|
+
disabled={props.disabled}
|
|
156
|
+
onClick={() => emitCondition(field, { op })}
|
|
157
|
+
>
|
|
158
|
+
{OPERATOR_SYMBOL[op]}
|
|
159
|
+
</button>
|
|
160
|
+
))}
|
|
161
|
+
</div>
|
|
162
|
+
<div class="hk-filter-panel-value">
|
|
163
|
+
{field.kind === "select" ? (
|
|
164
|
+
<HkPopupSelect
|
|
165
|
+
modelValue={condition.value}
|
|
166
|
+
onUpdate:modelValue={(v: string) => emitCondition(field, { value: v })}
|
|
167
|
+
label={field.label}
|
|
168
|
+
disabled={props.disabled}
|
|
169
|
+
options={selectOptions(field)}
|
|
170
|
+
/>
|
|
171
|
+
) : (
|
|
172
|
+
<HkInput
|
|
173
|
+
type={field.kind === "date" ? "date" : "text"}
|
|
174
|
+
variant={field.kind === "number" ? "number" : "text"}
|
|
175
|
+
modelValue={condition.value}
|
|
176
|
+
onUpdate:modelValue={(v: string) =>
|
|
177
|
+
emitCondition(field, {
|
|
178
|
+
// Belt over the browser's native number filtering:
|
|
179
|
+
// the stored model keeps a clean number SHAPE only
|
|
180
|
+
// (sign, digits, one dot), so pages can hand it to
|
|
181
|
+
// RPC params verbatim.
|
|
182
|
+
value: field.kind === "number" ? sanitizeNumberInput(v) : v,
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
label={field.label}
|
|
186
|
+
placeholder={field.placeholder ?? ""}
|
|
187
|
+
disabled={props.disabled}
|
|
188
|
+
/>
|
|
189
|
+
)}
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
})}
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
};
|
|
197
|
+
},
|
|
198
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, h } from "vue";
|
|
3
|
+
|
|
4
|
+
// The mobile half of the HkFilterTrigger contract (the main test file
|
|
5
|
+
// runs the desktop shape; useBreakpoint cannot be re-mocked per-test).
|
|
6
|
+
vi.mock("../runtime/useBreakpoint", () => ({
|
|
7
|
+
useBreakpoint: () => ({ isMobile: { value: true } }),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
import HkFilterTrigger from "./HkFilterTrigger";
|
|
11
|
+
|
|
12
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
13
|
+
|
|
14
|
+
function mount(node: ReturnType<typeof h>) {
|
|
15
|
+
const container = document.createElement("div");
|
|
16
|
+
document.body.appendChild(container);
|
|
17
|
+
const app = createApp({ render: () => node });
|
|
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 FIELDS = [{ key: "tool", label: "Tool", kind: "text" as const }];
|
|
32
|
+
|
|
33
|
+
describe("HkFilterTrigger (mobile sheet)", () => {
|
|
34
|
+
it("shows exactly ONE confirm button (no cancel) and closes on it", async () => {
|
|
35
|
+
const c = mount(h(HkFilterTrigger, { fields: FIELDS, modelValue: {} }));
|
|
36
|
+
const btn = c.querySelector<HTMLButtonElement>(".hk-filter-trigger .hk-icon-button")!;
|
|
37
|
+
btn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
38
|
+
await Promise.resolve();
|
|
39
|
+
await Promise.resolve();
|
|
40
|
+
const footers = document.body.querySelectorAll(".hk-filter-trigger-footer");
|
|
41
|
+
expect(footers.length, "the mobile confirm footer renders").toBe(1);
|
|
42
|
+
const confirms = footers[0]!.querySelectorAll("button");
|
|
43
|
+
// ONE button: the confirm-closes affordance. No cancel row — clearing
|
|
44
|
+
// a condition is re-opening the panel and emptying the value.
|
|
45
|
+
expect(confirms).toHaveLength(1);
|
|
46
|
+
// The panel is up; pressing confirm closes the sheet (the popover
|
|
47
|
+
// surfaces close through their transition — the TRIGGER's expanded
|
|
48
|
+
// state is the contract, same assertion grammar as HkFilterBar's).
|
|
49
|
+
expect(document.body.querySelector(".hk-filter-panel")).toBeTruthy();
|
|
50
|
+
confirms[0]!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
51
|
+
await Promise.resolve();
|
|
52
|
+
await Promise.resolve();
|
|
53
|
+
expect(btn.getAttribute("aria-expanded")).toBe("false");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HkFilterTrigger chrome: the header icon button, its active-filter dot
|
|
3
|
+
* and the mobile confirm footer. The panel's own bounded sizing lives in
|
|
4
|
+
* HkFilterPanel.scss.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
.hk-filter-trigger {
|
|
8
|
+
position: relative;
|
|
9
|
+
display: inline-flex;
|
|
10
|
+
align-items: center;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/* Active-filter marker: a small primary dot pinned to the button's
|
|
14
|
+
* top-right corner — visible only while at least one condition carries a
|
|
15
|
+
* value (the trigger's data-active drives it, so the empty-state panel
|
|
16
|
+
* never shows the dot). */
|
|
17
|
+
.hk-filter-trigger-dot {
|
|
18
|
+
position: absolute;
|
|
19
|
+
top: 1px;
|
|
20
|
+
right: 1px;
|
|
21
|
+
width: 6px;
|
|
22
|
+
height: 6px;
|
|
23
|
+
border-radius: 50%;
|
|
24
|
+
background: rgb(var(--color-primary));
|
|
25
|
+
pointer-events: none;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.hk-filter-trigger-footer {
|
|
29
|
+
display: flex;
|
|
30
|
+
justify-content: flex-end;
|
|
31
|
+
padding-top: var(--space-8, 0.5rem);
|
|
32
|
+
border-top: 1px solid rgb(var(--color-border) / 10%);
|
|
33
|
+
margin-top: var(--space-8, 0.5rem);
|
|
34
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, h } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkFilterTrigger from "./HkFilterTrigger";
|
|
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
|
+
document.body.querySelectorAll(".hk-popover-scrim, .hk-popover-panel").forEach((el) => el.remove());
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const FIELDS = [
|
|
26
|
+
{ key: "tool", label: "Tool", kind: "text" as const },
|
|
27
|
+
{ key: "count", label: "Calls", kind: "number" as const },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const triggerButton = (c: HTMLElement) =>
|
|
31
|
+
c.querySelector<HTMLButtonElement>(".hk-filter-trigger .hk-icon-button")!;
|
|
32
|
+
|
|
33
|
+
describe("HkFilterTrigger", () => {
|
|
34
|
+
it("renders the filter affordance as a ghost/28 icon button (the theme toggle's step)", () => {
|
|
35
|
+
const c = mount(h(HkFilterTrigger, { fields: FIELDS, modelValue: {} }));
|
|
36
|
+
const btn = triggerButton(c);
|
|
37
|
+
expect(btn).toBeTruthy();
|
|
38
|
+
expect(btn.className).toContain("hk-icon-button-28");
|
|
39
|
+
expect(btn.className).toContain("hk-icon-button-ghost");
|
|
40
|
+
expect(btn.getAttribute("aria-haspopup")).toBe("dialog");
|
|
41
|
+
// No active dot on an untouched state.
|
|
42
|
+
expect(c.querySelector(".hk-filter-trigger-dot")).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("opens the panel in a popover below the button and edits emit live", async () => {
|
|
46
|
+
let seen: Record<string, unknown> | null = null;
|
|
47
|
+
const c = mount(
|
|
48
|
+
h(HkFilterTrigger, {
|
|
49
|
+
fields: FIELDS,
|
|
50
|
+
modelValue: {},
|
|
51
|
+
"onUpdate:modelValue": (s: Record<string, unknown>) => (seen = s),
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
expect(document.body.querySelector(".hk-filter-panel")).toBeNull();
|
|
55
|
+
triggerButton(c).dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
56
|
+
await Promise.resolve();
|
|
57
|
+
await Promise.resolve();
|
|
58
|
+
// HkPopover teleports the surface to the body.
|
|
59
|
+
const panel = document.body.querySelector(".hk-filter-panel");
|
|
60
|
+
expect(panel, "panel mounted after the trigger click").toBeTruthy();
|
|
61
|
+
// Desktop: NO confirm footer (edits are live; dismissal is click-out).
|
|
62
|
+
expect(document.body.querySelector(".hk-filter-trigger-footer")).toBeNull();
|
|
63
|
+
// An edit inside the panel reaches the model live.
|
|
64
|
+
const input = panel!.querySelector<HTMLInputElement>(".hk-filter-panel-row input")!;
|
|
65
|
+
input.value = "web";
|
|
66
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
67
|
+
await Promise.resolve();
|
|
68
|
+
expect(seen).not.toBeNull();
|
|
69
|
+
expect((seen! as { tool: { value: string } }).tool.value).toBe("web");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("shows the active dot only while a condition carries a value", () => {
|
|
73
|
+
const c = mount(
|
|
74
|
+
h(HkFilterTrigger, { fields: FIELDS, modelValue: { count: { op: "gte", value: "3" } } }),
|
|
75
|
+
);
|
|
76
|
+
expect(c.querySelector(".hk-filter-trigger-dot")).toBeTruthy();
|
|
77
|
+
const empty = mount(h(HkFilterTrigger, { fields: FIELDS, modelValue: {} }));
|
|
78
|
+
expect(empty.querySelector(".hk-filter-trigger-dot")).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { computed, defineComponent, ref, type PropType } from "vue";
|
|
2
|
+
import { SlidersHorizontal } from "lucide-vue-next";
|
|
3
|
+
|
|
4
|
+
import HkButton from "./HkButton";
|
|
5
|
+
import HkFilterPanel, {
|
|
6
|
+
type HkFilterCondition,
|
|
7
|
+
type HkFilterFieldDef,
|
|
8
|
+
type HkFilterState,
|
|
9
|
+
} from "./HkFilterPanel";
|
|
10
|
+
import HkIconButton from "./HkIconButton";
|
|
11
|
+
import HkPopover, { type PopupPlacement } from "./HkPopover";
|
|
12
|
+
import "./HkFilterTrigger.scss";
|
|
13
|
+
import { useBreakpoint } from "../runtime/useBreakpoint";
|
|
14
|
+
import { useI18n } from "../i18n/context";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* HkFilterTrigger — the header filter affordance: a ghost/28 icon button
|
|
18
|
+
* (the same step as the theme toggle and the page refresh — the whole
|
|
19
|
+
* top-right cluster reads as one row of chrome) anchoring the
|
|
20
|
+
* HkFilterPanel in a popover on desktop and a bottom sheet on mobile
|
|
21
|
+
* (HkPopover sheetOnMobile).
|
|
22
|
+
*
|
|
23
|
+
* Apply semantics (user direction 2026-09-29):
|
|
24
|
+
* - desktop: NO confirm button — edits commit live and the panel is
|
|
25
|
+
* dismissed by clicking out;
|
|
26
|
+
* - mobile: ONE confirm button ("应用") that closes the sheet — there is
|
|
27
|
+
* deliberately no cancel; clearing a condition is done by re-opening
|
|
28
|
+
* the panel and emptying the value, never by a header reset verb.
|
|
29
|
+
*
|
|
30
|
+
* A small dot on the button marks a panel with at least one non-empty
|
|
31
|
+
* value, so a filtered page is recognizable with the panel closed.
|
|
32
|
+
*/
|
|
33
|
+
export default defineComponent({
|
|
34
|
+
name: "HkFilterTrigger",
|
|
35
|
+
props: {
|
|
36
|
+
fields: { type: Array as PropType<HkFilterFieldDef[]>, required: true },
|
|
37
|
+
modelValue: { type: Object as PropType<HkFilterState>, default: () => ({}) },
|
|
38
|
+
disabled: { type: Boolean, default: false },
|
|
39
|
+
/** Accessible name / sheet title. Defaults to the localized "Filters". */
|
|
40
|
+
label: { type: String, default: undefined },
|
|
41
|
+
placement: { type: String as PropType<PopupPlacement>, default: "bottom-end" },
|
|
42
|
+
},
|
|
43
|
+
emits: {
|
|
44
|
+
"update:modelValue": (_state: HkFilterState) => true,
|
|
45
|
+
},
|
|
46
|
+
setup(props, { emit }) {
|
|
47
|
+
const { t } = useI18n();
|
|
48
|
+
const { isMobile } = useBreakpoint();
|
|
49
|
+
const open = ref(false);
|
|
50
|
+
const anchorRef = ref<HTMLElement | null>(null);
|
|
51
|
+
|
|
52
|
+
const active = computed(() =>
|
|
53
|
+
Object.values(props.modelValue as Record<string, HkFilterCondition>).some(
|
|
54
|
+
(c) => c && c.value !== "",
|
|
55
|
+
),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
return () => {
|
|
59
|
+
const label = props.label ?? t("hikari::filterBar.trigger", "Filters");
|
|
60
|
+
return (
|
|
61
|
+
<span ref={anchorRef} class="hk-filter-trigger">
|
|
62
|
+
<HkIconButton
|
|
63
|
+
size={28}
|
|
64
|
+
variant="ghost"
|
|
65
|
+
aria-label={label}
|
|
66
|
+
aria-haspopup="dialog"
|
|
67
|
+
aria-expanded={open.value ? "true" : "false"}
|
|
68
|
+
disabled={props.disabled}
|
|
69
|
+
data-active={active.value || undefined}
|
|
70
|
+
onClick={() => {
|
|
71
|
+
open.value = !open.value;
|
|
72
|
+
}}
|
|
73
|
+
>
|
|
74
|
+
{{ icon: () => <SlidersHorizontal size={16} /> }}
|
|
75
|
+
</HkIconButton>
|
|
76
|
+
{active.value && <span class="hk-filter-trigger-dot" aria-hidden="true" />}
|
|
77
|
+
<HkPopover
|
|
78
|
+
modelValue={open.value}
|
|
79
|
+
onUpdate:modelValue={(v: boolean) => (open.value = v)}
|
|
80
|
+
anchorRef={anchorRef.value}
|
|
81
|
+
placement={props.placement}
|
|
82
|
+
offset={6}
|
|
83
|
+
sheetOnMobile
|
|
84
|
+
title={label}
|
|
85
|
+
>
|
|
86
|
+
<div class="hk-filter-trigger-body">
|
|
87
|
+
<HkFilterPanel
|
|
88
|
+
fields={props.fields}
|
|
89
|
+
modelValue={props.modelValue}
|
|
90
|
+
disabled={props.disabled}
|
|
91
|
+
label={label}
|
|
92
|
+
onUpdate:modelValue={(state: HkFilterState) => emit("update:modelValue", state)}
|
|
93
|
+
/>
|
|
94
|
+
{isMobile.value && (
|
|
95
|
+
<div class="hk-filter-trigger-footer">
|
|
96
|
+
{/* The sheet's ONE button: confirm = close (edits are
|
|
97
|
+
* already live). No cancel — clearing rides inside the
|
|
98
|
+
* panel (empty the value). */}
|
|
99
|
+
<HkButton
|
|
100
|
+
variant="primary"
|
|
101
|
+
size="sm"
|
|
102
|
+
disabled={props.disabled}
|
|
103
|
+
onClick={() => {
|
|
104
|
+
open.value = false;
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
{t("hikari::filterBar.apply", "Apply")}
|
|
108
|
+
</HkButton>
|
|
109
|
+
</div>
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
</HkPopover>
|
|
113
|
+
</span>
|
|
114
|
+
);
|
|
115
|
+
};
|
|
116
|
+
},
|
|
117
|
+
});
|
|
@@ -192,7 +192,15 @@
|
|
|
192
192
|
"hikari::zoomToolbar.zoomSlider": "مستوى التكبير",
|
|
193
193
|
"hikari::filterBar.trigger": "عوامل التصفية",
|
|
194
194
|
"hikari::filterBar.apply": "تطبيق",
|
|
195
|
-
"hikari::
|
|
195
|
+
"hikari::filterPanel.all": "الكل",
|
|
196
|
+
"hikari::filterBar.reset": "إعادة تعيين",
|
|
197
|
+
"hikari::filterPanel.op.eq": "يساوي",
|
|
198
|
+
"hikari::filterPanel.op.ne": "لا يساوي",
|
|
199
|
+
"hikari::filterPanel.op.approx": "مطابقة تقريبية",
|
|
200
|
+
"hikari::filterPanel.op.lt": "أقل من",
|
|
201
|
+
"hikari::filterPanel.op.lte": "كحد أقصى",
|
|
202
|
+
"hikari::filterPanel.op.gte": "على الأقل",
|
|
203
|
+
"hikari::filterPanel.op.gt": "أكبر من"
|
|
196
204
|
},
|
|
197
205
|
"hikari::affixPicker.unset": "غير محدد",
|
|
198
206
|
"hikari::affixPicker.title": "الخيارات",
|
|
@@ -192,7 +192,15 @@
|
|
|
192
192
|
"hikari::zoomToolbar.zoomSlider": "Zoom-Stufe",
|
|
193
193
|
"hikari::filterBar.trigger": "Filter",
|
|
194
194
|
"hikari::filterBar.apply": "Anwenden",
|
|
195
|
-
"hikari::
|
|
195
|
+
"hikari::filterPanel.all": "Alle",
|
|
196
|
+
"hikari::filterBar.reset": "Zurücksetzen",
|
|
197
|
+
"hikari::filterPanel.op.eq": "gleich",
|
|
198
|
+
"hikari::filterPanel.op.ne": "ungleich",
|
|
199
|
+
"hikari::filterPanel.op.approx": "enthält",
|
|
200
|
+
"hikari::filterPanel.op.lt": "kleiner als",
|
|
201
|
+
"hikari::filterPanel.op.lte": "höchstens",
|
|
202
|
+
"hikari::filterPanel.op.gte": "mindestens",
|
|
203
|
+
"hikari::filterPanel.op.gt": "größer als"
|
|
196
204
|
},
|
|
197
205
|
"hikari::affixPicker.unset": "Nicht festgelegt",
|
|
198
206
|
"hikari::affixPicker.title": "Optionen",
|
|
@@ -192,7 +192,15 @@
|
|
|
192
192
|
"hikari::zoomToolbar.zoomSlider": "Zoom level",
|
|
193
193
|
"hikari::filterBar.trigger": "Filters",
|
|
194
194
|
"hikari::filterBar.apply": "Apply",
|
|
195
|
-
"hikari::
|
|
195
|
+
"hikari::filterPanel.all": "All",
|
|
196
|
+
"hikari::filterBar.reset": "Reset",
|
|
197
|
+
"hikari::filterPanel.op.eq": "equals",
|
|
198
|
+
"hikari::filterPanel.op.ne": "not equals",
|
|
199
|
+
"hikari::filterPanel.op.approx": "fuzzy match",
|
|
200
|
+
"hikari::filterPanel.op.lt": "less than",
|
|
201
|
+
"hikari::filterPanel.op.lte": "at most",
|
|
202
|
+
"hikari::filterPanel.op.gte": "at least",
|
|
203
|
+
"hikari::filterPanel.op.gt": "greater than"
|
|
196
204
|
},
|
|
197
205
|
"hikari::affixPicker.title": "Options",
|
|
198
206
|
"hikari::affixPicker.search": "Search",
|
|
@@ -192,7 +192,15 @@
|
|
|
192
192
|
"hikari::zoomToolbar.zoomSlider": "Nivel de zoom",
|
|
193
193
|
"hikari::filterBar.trigger": "Filtros",
|
|
194
194
|
"hikari::filterBar.apply": "Aplicar",
|
|
195
|
-
"hikari::
|
|
195
|
+
"hikari::filterPanel.all": "Todos",
|
|
196
|
+
"hikari::filterBar.reset": "Restablecer",
|
|
197
|
+
"hikari::filterPanel.op.eq": "igual a",
|
|
198
|
+
"hikari::filterPanel.op.ne": "distinto de",
|
|
199
|
+
"hikari::filterPanel.op.approx": "coincidencia",
|
|
200
|
+
"hikari::filterPanel.op.lt": "menor que",
|
|
201
|
+
"hikari::filterPanel.op.lte": "como máximo",
|
|
202
|
+
"hikari::filterPanel.op.gte": "al menos",
|
|
203
|
+
"hikari::filterPanel.op.gt": "mayor que"
|
|
196
204
|
},
|
|
197
205
|
"hikari::affixPicker.unset": "Sin establecer",
|
|
198
206
|
"hikari::affixPicker.title": "Opciones",
|
|
@@ -192,7 +192,15 @@
|
|
|
192
192
|
"hikari::zoomToolbar.zoomSlider": "Niveau de zoom",
|
|
193
193
|
"hikari::filterBar.trigger": "Filtres",
|
|
194
194
|
"hikari::filterBar.apply": "Appliquer",
|
|
195
|
-
"hikari::
|
|
195
|
+
"hikari::filterPanel.all": "Tous",
|
|
196
|
+
"hikari::filterBar.reset": "Réinitialiser",
|
|
197
|
+
"hikari::filterPanel.op.eq": "égal à",
|
|
198
|
+
"hikari::filterPanel.op.ne": "différent de",
|
|
199
|
+
"hikari::filterPanel.op.approx": "correspond à",
|
|
200
|
+
"hikari::filterPanel.op.lt": "inférieur à",
|
|
201
|
+
"hikari::filterPanel.op.lte": "au plus",
|
|
202
|
+
"hikari::filterPanel.op.gte": "au moins",
|
|
203
|
+
"hikari::filterPanel.op.gt": "supérieur à"
|
|
196
204
|
},
|
|
197
205
|
"hikari::affixPicker.unset": "Non défini",
|
|
198
206
|
"hikari::affixPicker.title": "Options",
|
|
@@ -192,7 +192,15 @@
|
|
|
192
192
|
"hikari::zoomToolbar.zoomSlider": "ズーム倍率",
|
|
193
193
|
"hikari::filterBar.trigger": "絞り込み",
|
|
194
194
|
"hikari::filterBar.apply": "適用",
|
|
195
|
-
"hikari::
|
|
195
|
+
"hikari::filterPanel.all": "すべて",
|
|
196
|
+
"hikari::filterBar.reset": "リセット",
|
|
197
|
+
"hikari::filterPanel.op.eq": "等しい",
|
|
198
|
+
"hikari::filterPanel.op.ne": "等しくない",
|
|
199
|
+
"hikari::filterPanel.op.approx": "部分一致",
|
|
200
|
+
"hikari::filterPanel.op.lt": "未満",
|
|
201
|
+
"hikari::filterPanel.op.lte": "以下",
|
|
202
|
+
"hikari::filterPanel.op.gte": "以上",
|
|
203
|
+
"hikari::filterPanel.op.gt": "より大きい"
|
|
196
204
|
},
|
|
197
205
|
"hikari::affixPicker.unset": "未設定",
|
|
198
206
|
"hikari::affixPicker.title": "オプション",
|
|
@@ -192,7 +192,15 @@
|
|
|
192
192
|
"hikari::zoomToolbar.zoomSlider": "확대 수준",
|
|
193
193
|
"hikari::filterBar.trigger": "필터",
|
|
194
194
|
"hikari::filterBar.apply": "적용",
|
|
195
|
-
"hikari::
|
|
195
|
+
"hikari::filterPanel.all": "전체",
|
|
196
|
+
"hikari::filterBar.reset": "초기화",
|
|
197
|
+
"hikari::filterPanel.op.eq": "같음",
|
|
198
|
+
"hikari::filterPanel.op.ne": "같지 않음",
|
|
199
|
+
"hikari::filterPanel.op.approx": "부분 일치",
|
|
200
|
+
"hikari::filterPanel.op.lt": "미만",
|
|
201
|
+
"hikari::filterPanel.op.lte": "이하",
|
|
202
|
+
"hikari::filterPanel.op.gte": "이상",
|
|
203
|
+
"hikari::filterPanel.op.gt": "초과"
|
|
196
204
|
},
|
|
197
205
|
"hikari::affixPicker.unset": "미설정",
|
|
198
206
|
"hikari::affixPicker.title": "옵션",
|
|
@@ -192,7 +192,15 @@
|
|
|
192
192
|
"hikari::zoomToolbar.zoomSlider": "Nível de zoom",
|
|
193
193
|
"hikari::filterBar.trigger": "Filtros",
|
|
194
194
|
"hikari::filterBar.apply": "Aplicar",
|
|
195
|
-
"hikari::
|
|
195
|
+
"hikari::filterPanel.all": "Todos",
|
|
196
|
+
"hikari::filterBar.reset": "Redefinir",
|
|
197
|
+
"hikari::filterPanel.op.eq": "igual a",
|
|
198
|
+
"hikari::filterPanel.op.ne": "diferente de",
|
|
199
|
+
"hikari::filterPanel.op.approx": "correspondência",
|
|
200
|
+
"hikari::filterPanel.op.lt": "menor que",
|
|
201
|
+
"hikari::filterPanel.op.lte": "no máximo",
|
|
202
|
+
"hikari::filterPanel.op.gte": "pelo menos",
|
|
203
|
+
"hikari::filterPanel.op.gt": "maior que"
|
|
196
204
|
},
|
|
197
205
|
"hikari::affixPicker.unset": "Não definido",
|
|
198
206
|
"hikari::affixPicker.title": "Opções",
|
|
@@ -192,7 +192,15 @@
|
|
|
192
192
|
"hikari::zoomToolbar.zoomSlider": "Уровень масштаба",
|
|
193
193
|
"hikari::filterBar.trigger": "Фильтры",
|
|
194
194
|
"hikari::filterBar.apply": "Применить",
|
|
195
|
-
"hikari::
|
|
195
|
+
"hikari::filterPanel.all": "Все",
|
|
196
|
+
"hikari::filterBar.reset": "Сбросить",
|
|
197
|
+
"hikari::filterPanel.op.eq": "равно",
|
|
198
|
+
"hikari::filterPanel.op.ne": "не равно",
|
|
199
|
+
"hikari::filterPanel.op.approx": "приблизительно",
|
|
200
|
+
"hikari::filterPanel.op.lt": "меньше",
|
|
201
|
+
"hikari::filterPanel.op.lte": "не более",
|
|
202
|
+
"hikari::filterPanel.op.gte": "не менее",
|
|
203
|
+
"hikari::filterPanel.op.gt": "больше"
|
|
196
204
|
},
|
|
197
205
|
"hikari::affixPicker.unset": "Не задано",
|
|
198
206
|
"hikari::affixPicker.title": "Параметры",
|
|
@@ -196,7 +196,15 @@
|
|
|
196
196
|
"hikari::statusBar.region.TW": "中国台湾",
|
|
197
197
|
"hikari::filterBar.trigger": "筛选",
|
|
198
198
|
"hikari::filterBar.apply": "应用",
|
|
199
|
-
"hikari::
|
|
199
|
+
"hikari::filterPanel.all": "全部",
|
|
200
|
+
"hikari::filterBar.reset": "重置",
|
|
201
|
+
"hikari::filterPanel.op.eq": "等于",
|
|
202
|
+
"hikari::filterPanel.op.ne": "不等于",
|
|
203
|
+
"hikari::filterPanel.op.approx": "模糊匹配",
|
|
204
|
+
"hikari::filterPanel.op.lt": "小于",
|
|
205
|
+
"hikari::filterPanel.op.lte": "不超过",
|
|
206
|
+
"hikari::filterPanel.op.gte": "不低于",
|
|
207
|
+
"hikari::filterPanel.op.gt": "大于"
|
|
200
208
|
},
|
|
201
209
|
"hikari::affixPicker.title": "选项",
|
|
202
210
|
"hikari::affixPicker.search": "搜索",
|
|
@@ -196,7 +196,15 @@
|
|
|
196
196
|
"hikari::statusBar.region.TW": "中國台灣",
|
|
197
197
|
"hikari::filterBar.trigger": "篩選",
|
|
198
198
|
"hikari::filterBar.apply": "套用",
|
|
199
|
-
"hikari::
|
|
199
|
+
"hikari::filterPanel.all": "全部",
|
|
200
|
+
"hikari::filterBar.reset": "重設",
|
|
201
|
+
"hikari::filterPanel.op.eq": "等於",
|
|
202
|
+
"hikari::filterPanel.op.ne": "不等於",
|
|
203
|
+
"hikari::filterPanel.op.approx": "模糊匹配",
|
|
204
|
+
"hikari::filterPanel.op.lt": "小於",
|
|
205
|
+
"hikari::filterPanel.op.lte": "不超過",
|
|
206
|
+
"hikari::filterPanel.op.gte": "不低於",
|
|
207
|
+
"hikari::filterPanel.op.gt": "大於"
|
|
200
208
|
},
|
|
201
209
|
"hikari::affixPicker.title": "選項",
|
|
202
210
|
"hikari::affixPicker.search": "搜尋",
|
package/src/index.ts
CHANGED
|
@@ -756,6 +756,9 @@ export { HkStatCard as HkStatCard } from "./components/HkStatCard";
|
|
|
756
756
|
export { statToneColor, type StatTone as StatTone, type StatVariant as StatVariant } from "./components/HkStatCard";
|
|
757
757
|
export { HkStatusPill as HkStatusPill } from "./components/HkStatusPill";
|
|
758
758
|
export { default as HkFilterBar } from "./components/HkFilterBar";
|
|
759
|
+
export { default as HkFilterPanel, sanitizeNumberInput } from "./components/HkFilterPanel";
|
|
760
|
+
export type { HkFilterOperator, HkFilterFieldDef, HkFilterCondition, HkFilterState } from "./components/HkFilterPanel";
|
|
761
|
+
export { default as HkFilterTrigger } from "./components/HkFilterTrigger";
|
|
759
762
|
export { HkIconChip as HkIconChip } from "./components/HkIconChip";
|
|
760
763
|
export { HkIconChip as HIconChip } from "./components/HkIconChip";
|
|
761
764
|
export { HkCardList, HkListRow } from "./components/HkCardList";
|