@celestia-island/hikari 0.55.153 → 0.55.155
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 +100 -20
- package/src/components/HkFilterPanel.styles.test.ts +82 -0
- package/src/components/HkFilterPanel.test.tsx +105 -17
- package/src/components/HkFilterPanel.tsx +102 -45
- package/src/components/HkFilterTrigger.test.tsx +4 -1
- package/src/components/HkInput.hideLabel.test.tsx +72 -0
- package/src/components/HkInput.tsx +18 -1
- package/src/components/HkPopupSelect.mobile.test.tsx +50 -0
- package/src/components/HkPopupSelect.scss +14 -0
- package/src/components/HkPopupSelect.test.tsx +84 -0
- package/src/components/HkPopupSelect.tsx +18 -3
- package/src/components/adminHeaderChrome.test.ts +65 -0
- package/src/i18n/locales/ar/components.json +2 -0
- package/src/i18n/locales/de/components.json +2 -0
- package/src/i18n/locales/en/components.json +2 -0
- package/src/i18n/locales/es/components.json +2 -0
- package/src/i18n/locales/fr/components.json +2 -0
- package/src/i18n/locales/ja/components.json +2 -0
- package/src/i18n/locales/ko/components.json +2 -0
- package/src/i18n/locales/pt/components.json +2 -0
- package/src/i18n/locales/ru/components.json +2 -0
- package/src/i18n/locales/zh-Hans/components.json +2 -0
- package/src/i18n/locales/zh-Hant/components.json +2 -0
- package/src/styles/admin-tokens.scss +29 -0
package/package.json
CHANGED
|
@@ -6,15 +6,26 @@
|
|
|
6
6
|
* narrows to roughly TWO visible symbols and scrolls horizontally
|
|
7
7
|
* instead.
|
|
8
8
|
*
|
|
9
|
-
* Row anatomy: [field label | operator symbol group | value control
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* Row anatomy: [field label | operator symbol group | value control +
|
|
10
|
+
* remove]. Rows are the page's ACTIVE conditions; the trailing add row
|
|
11
|
+
* (button parked in the operator column, user direction 2026-10-12)
|
|
12
|
+
* materializes the next latent field. Edits commit LIVE (the desktop
|
|
13
|
+
* panel has no confirm button); the mobile sheet's single confirm merely
|
|
14
|
+
* closes — the standard HkPopover sheetOnMobile split renders the same
|
|
15
|
+
* panel in both shapes.
|
|
16
|
+
*
|
|
17
|
+
* ONE GRID, ONE AXIS (user direction 2026-10-12): the panel itself is
|
|
18
|
+
* the grid and each row contributes `display: contents` cells, so the
|
|
19
|
+
* three columns track ONE set of widths across every row and the add
|
|
20
|
+
* row lines up with the operator column exactly. `align-items: center`
|
|
21
|
+
* puts every cell of every row on the same vertical axis — label,
|
|
22
|
+
* symbol group, control, and remove button all share it.
|
|
13
23
|
*/
|
|
14
24
|
|
|
15
25
|
.hk-filter-panel {
|
|
16
|
-
display:
|
|
17
|
-
|
|
26
|
+
display: grid;
|
|
27
|
+
grid-template-columns: minmax(4.5rem, auto) minmax(4.5rem, auto) minmax(6rem, 1fr);
|
|
28
|
+
align-items: center;
|
|
18
29
|
gap: var(--space-8, 0.5rem);
|
|
19
30
|
/* Bounded width: wide enough for the operator group, narrow enough to
|
|
20
31
|
* read as a header dropdown (the popover host clamps to the viewport
|
|
@@ -25,14 +36,16 @@
|
|
|
25
36
|
* a field-rich page must not grow an unbounded dropdown. */
|
|
26
37
|
max-height: min(26rem, 60vh);
|
|
27
38
|
overflow-y: auto;
|
|
28
|
-
|
|
39
|
+
/* Minimum outer margins for every column (user direction
|
|
40
|
+
* 2026-10-12): the rows never touch the panel edge. */
|
|
41
|
+
padding: var(--space-8, 0.5rem) var(--space-12, 0.75rem);
|
|
29
42
|
}
|
|
30
43
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
44
|
+
/* A row is not a box — it donates its three cells to the panel grid so
|
|
45
|
+
* column widths stay shared across all rows (and the add row). */
|
|
46
|
+
.hk-filter-panel-row,
|
|
47
|
+
.hk-filter-panel-addrow {
|
|
48
|
+
display: contents;
|
|
36
49
|
}
|
|
37
50
|
|
|
38
51
|
.hk-filter-panel-field {
|
|
@@ -93,23 +106,90 @@
|
|
|
93
106
|
cursor: not-allowed;
|
|
94
107
|
}
|
|
95
108
|
|
|
109
|
+
/* Value cell: the control takes what is left (min-width guarded, never
|
|
110
|
+
* hard-coded — it shrinks with the panel, user direction 2026-10-12);
|
|
111
|
+
* the remove verb rides at the row's end on the same axis. */
|
|
96
112
|
.hk-filter-panel-value {
|
|
113
|
+
display: flex;
|
|
114
|
+
align-items: center;
|
|
115
|
+
gap: var(--space-4, 0.25rem);
|
|
97
116
|
min-width: 0;
|
|
98
117
|
}
|
|
99
118
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
119
|
+
.hk-filter-panel-control {
|
|
120
|
+
flex: 1 1 auto;
|
|
121
|
+
min-width: 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.hk-filter-panel-remove {
|
|
125
|
+
flex: 0 0 auto;
|
|
126
|
+
width: 1.5rem;
|
|
127
|
+
height: 1.5rem;
|
|
128
|
+
display: inline-flex;
|
|
129
|
+
align-items: center;
|
|
130
|
+
justify-content: center;
|
|
131
|
+
border: none;
|
|
132
|
+
border-radius: var(--radius-sm, 6px);
|
|
133
|
+
background: transparent;
|
|
134
|
+
color: rgb(var(--color-muted, var(--color-text-secondary)));
|
|
135
|
+
cursor: pointer;
|
|
136
|
+
transition: background 0.15s ease, color 0.15s ease;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.hk-filter-panel-remove:hover {
|
|
140
|
+
background: rgb(var(--color-error) / 12%);
|
|
141
|
+
color: rgb(var(--color-error));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.hk-filter-panel-remove:disabled {
|
|
145
|
+
opacity: 0.5;
|
|
146
|
+
cursor: not-allowed;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/* The growth verb: parked in the OPERATOR COLUMN (grid-column 2), so it
|
|
150
|
+
* reads as part of the aligned axis — not a stray footer link. Ghost
|
|
151
|
+
* face; it appends the next latent field. */
|
|
152
|
+
.hk-filter-panel-add {
|
|
153
|
+
grid-column: 2 / -1;
|
|
154
|
+
justify-self: start;
|
|
155
|
+
display: inline-flex;
|
|
156
|
+
align-items: center;
|
|
157
|
+
gap: var(--space-4, 0.25rem);
|
|
158
|
+
min-height: 2rem;
|
|
159
|
+
padding: 0 var(--space-8, 0.5rem);
|
|
160
|
+
border: none;
|
|
161
|
+
border-radius: var(--radius-sm, 6px);
|
|
162
|
+
background: transparent;
|
|
163
|
+
color: rgb(var(--color-muted, var(--color-text-secondary)));
|
|
164
|
+
font-family: inherit;
|
|
165
|
+
font-size: var(--text-xs, 0.75rem);
|
|
166
|
+
line-height: 1;
|
|
167
|
+
white-space: nowrap;
|
|
168
|
+
cursor: pointer;
|
|
169
|
+
transition: background 0.15s ease, color 0.15s ease;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.hk-filter-panel-add:hover {
|
|
173
|
+
background: rgb(var(--color-primary) / 12%);
|
|
174
|
+
color: rgb(var(--color-primary));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.hk-filter-panel-add:disabled {
|
|
178
|
+
opacity: 0.5;
|
|
179
|
+
cursor: not-allowed;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/* Phones: the operator column widens a touch (user direction
|
|
183
|
+
* 2026-10-12 — the old 4.25rem cap cramped the symbol group) and the
|
|
184
|
+
* row lets the value control take the remaining width. The group itself
|
|
185
|
+
* scrolls — same interaction, smaller footprint. */
|
|
103
186
|
@media (max-width: 767px) {
|
|
104
187
|
.hk-filter-panel {
|
|
105
188
|
width: 100%;
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
.hk-filter-panel-row {
|
|
109
|
-
grid-template-columns: minmax(3.75rem, auto) minmax(3.9rem, auto) minmax(0, 1fr);
|
|
189
|
+
grid-template-columns: minmax(3.75rem, auto) minmax(4.75rem, auto) minmax(5rem, 1fr);
|
|
110
190
|
}
|
|
111
191
|
|
|
112
192
|
.hk-filter-panel-ops {
|
|
113
|
-
max-width:
|
|
193
|
+
max-width: 5.25rem;
|
|
114
194
|
}
|
|
115
195
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import { compile } from "sass";
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Compiled-stylesheet guard for HkFilterPanel — the one-axis anatomy the
|
|
10
|
+
* 2026-10-12 user direction pinned:
|
|
11
|
+
*
|
|
12
|
+
* 1. the panel is a single grid with `align-items: center` (every cell
|
|
13
|
+
* of every row on ONE vertical axis);
|
|
14
|
+
* 2. rows/add-row donate `display: contents` cells so column widths
|
|
15
|
+
* stay shared (the add button lands in the operator column);
|
|
16
|
+
* 3. real horizontal padding (minimum outer margins);
|
|
17
|
+
* 4. the value column shrinks (min-width floors, nothing hard-coded);
|
|
18
|
+
* 5. the phone operator column keeps its widened cap.
|
|
19
|
+
*
|
|
20
|
+
* Vitest stubs CSS, so the runtime suite can never catch a lost rule —
|
|
21
|
+
* this compiles the sheet directly and asserts the contract.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const componentDir = resolve(dirname(fileURLToPath(import.meta.url)));
|
|
25
|
+
|
|
26
|
+
const css = compile(resolve(componentDir, "HkFilterPanel.scss"), {
|
|
27
|
+
style: "expanded",
|
|
28
|
+
}).css;
|
|
29
|
+
|
|
30
|
+
function ruleFor(selector: string): string {
|
|
31
|
+
const start = css.indexOf(selector);
|
|
32
|
+
expect(start, `${selector} present in the compiled sheet`).toBeGreaterThanOrEqual(0);
|
|
33
|
+
const open = css.indexOf("{", start);
|
|
34
|
+
const close = css.indexOf("}", open);
|
|
35
|
+
return css.slice(start, close + 1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("HkFilterPanel stylesheet contract", () => {
|
|
39
|
+
it("compiles without warnings", () => {
|
|
40
|
+
expect(css).toContain(".hk-filter-panel");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("the panel is one grid, vertically centered on a single axis", () => {
|
|
44
|
+
const rule = ruleFor(".hk-filter-panel {");
|
|
45
|
+
expect(rule).toContain("display: grid");
|
|
46
|
+
expect(rule).toContain("align-items: center");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("rows and the add row donate contents cells to the shared grid", () => {
|
|
50
|
+
// sass keeps the selector list wrapped: `.row,` newline `.addrow {`.
|
|
51
|
+
expect(css).toMatch(/\.hk-filter-panel-row,\s*\.hk-filter-panel-addrow\s*\{[^}]*display:\s*contents/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("keeps minimum outer margins on the columns", () => {
|
|
55
|
+
const rule = ruleFor(".hk-filter-panel {");
|
|
56
|
+
// Inline padding (both edges) — not the old 0.25rem sliver.
|
|
57
|
+
expect(rule).toMatch(/padding:[^;]*var\(--space-12/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("the value column shrinks with the panel (min floors, nothing fixed)", () => {
|
|
61
|
+
const rule = ruleFor(".hk-filter-panel {");
|
|
62
|
+
expect(rule).toMatch(/minmax\(6rem,\s*1fr\)/);
|
|
63
|
+
const value = ruleFor(".hk-filter-panel-value {");
|
|
64
|
+
expect(value).toContain("min-width: 0");
|
|
65
|
+
const control = ruleFor(".hk-filter-panel-control {");
|
|
66
|
+
expect(control).toContain("min-width: 0");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("parks the add button in the operator column (grid-column 2 to the end)", () => {
|
|
70
|
+
const rule = ruleFor(".hk-filter-panel-add {");
|
|
71
|
+
// sass normalizes the track list ("2 / -1" → "2/-1").
|
|
72
|
+
expect(rule).toMatch(/grid-column:\s*2\s*\/\s*-1/);
|
|
73
|
+
expect(rule).toContain("justify-self: start");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("the phone operator column keeps its widened cap (≥ the old 4.25rem)", () => {
|
|
77
|
+
expect(css).toContain("@media (max-width: 767px)");
|
|
78
|
+
const media = css.slice(css.indexOf("@media (max-width: 767px)"));
|
|
79
|
+
expect(media).toMatch(/max-width:\s*5\.25rem/);
|
|
80
|
+
expect(media).toMatch(/minmax\(4\.75rem,\s*auto\)/);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -28,17 +28,74 @@ const FIELDS = [
|
|
|
28
28
|
{ key: "since", label: "Since", kind: "date" as const },
|
|
29
29
|
];
|
|
30
30
|
|
|
31
|
+
/** State seeding all four fields — every declared row visible. */
|
|
32
|
+
const ALL_SEEDED: HkFilterState = {
|
|
33
|
+
tool: { op: "eq", value: "" },
|
|
34
|
+
count: { op: "lt", value: "" },
|
|
35
|
+
agent: { op: "eq", value: "" },
|
|
36
|
+
since: { op: "gte", value: "" },
|
|
37
|
+
};
|
|
38
|
+
|
|
31
39
|
describe("HkFilterPanel", () => {
|
|
32
|
-
it("renders
|
|
40
|
+
it("renders rows ONLY for fields present in the state — the rest stay latent", () => {
|
|
33
41
|
const c = mount(h(HkFilterPanel, { fields: FIELDS, modelValue: {} }));
|
|
34
|
-
|
|
35
|
-
expect(
|
|
36
|
-
|
|
37
|
-
|
|
42
|
+
// Empty state: no condition rows at all, just the add row.
|
|
43
|
+
expect(c.querySelectorAll(".hk-filter-panel-row")).toHaveLength(0);
|
|
44
|
+
expect(c.querySelector(".hk-filter-panel-add")).toBeTruthy();
|
|
45
|
+
// Seeded subset: rows in declaration order, only for seeded keys.
|
|
46
|
+
const c2 = mount(
|
|
47
|
+
h(HkFilterPanel, {
|
|
48
|
+
fields: FIELDS,
|
|
49
|
+
modelValue: { agent: { op: "eq", value: "" }, tool: { op: "eq", value: "" } },
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
const labels = [...c2.querySelectorAll(".hk-filter-panel-field")].map((el) => el.textContent);
|
|
53
|
+
expect(labels).toEqual(["Tool", "Agent"]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("hides the add row once every declared field is in the state", () => {
|
|
57
|
+
const c = mount(h(HkFilterPanel, { fields: FIELDS, modelValue: ALL_SEEDED }));
|
|
58
|
+
expect(c.querySelector(".hk-filter-panel-add")).toBeNull();
|
|
59
|
+
expect(c.querySelectorAll(".hk-filter-panel-row")).toHaveLength(FIELDS.length);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("adds the next latent field with its kind's default operator and an empty value", () => {
|
|
63
|
+
let seen: HkFilterState | null = null;
|
|
64
|
+
const c = mount(
|
|
65
|
+
h(HkFilterPanel, {
|
|
66
|
+
fields: FIELDS,
|
|
67
|
+
modelValue: { tool: { op: "eq", value: "web" } },
|
|
68
|
+
"onUpdate:modelValue": (s: HkFilterState) => (seen = s),
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
c.querySelector(".hk-filter-panel-add")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
72
|
+
expect(seen).not.toBeNull();
|
|
73
|
+
// "count" is the next unused field in declaration order; its kind's
|
|
74
|
+
// first operator (lt) and an empty value.
|
|
75
|
+
expect(seen!["count"]).toEqual({ op: "lt", value: "" });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("removes a condition entirely — the key leaves the state", () => {
|
|
79
|
+
let seen: HkFilterState | null = null;
|
|
80
|
+
const c = mount(
|
|
81
|
+
h(HkFilterPanel, {
|
|
82
|
+
fields: FIELDS,
|
|
83
|
+
modelValue: { tool: { op: "eq", value: "web" }, count: { op: "lt", value: "3" } },
|
|
84
|
+
"onUpdate:modelValue": (s: HkFilterState) => (seen = s),
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
const rows = [...c.querySelectorAll(".hk-filter-panel-row")];
|
|
88
|
+
expect(rows).toHaveLength(2);
|
|
89
|
+
rows[0]!.querySelector(".hk-filter-panel-remove")!.dispatchEvent(
|
|
90
|
+
new MouseEvent("click", { bubbles: true }),
|
|
91
|
+
);
|
|
92
|
+
expect(seen).not.toBeNull();
|
|
93
|
+
expect(seen!["tool"]).toBeUndefined();
|
|
94
|
+
expect(seen!["count"]).toEqual({ op: "lt", value: "3" });
|
|
38
95
|
});
|
|
39
96
|
|
|
40
97
|
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:
|
|
98
|
+
const c = mount(h(HkFilterPanel, { fields: FIELDS, modelValue: ALL_SEEDED }));
|
|
42
99
|
const rows = [...c.querySelectorAll(".hk-filter-panel-row")];
|
|
43
100
|
const opCounts = rows.map(
|
|
44
101
|
(row) => row.querySelectorAll(".hk-filter-panel-op").length,
|
|
@@ -78,14 +135,14 @@ describe("HkFilterPanel", () => {
|
|
|
78
135
|
const c = mount(
|
|
79
136
|
h(HkFilterPanel, {
|
|
80
137
|
fields: FIELDS,
|
|
81
|
-
modelValue: {},
|
|
138
|
+
modelValue: { count: { op: "lt", value: "" } },
|
|
82
139
|
"onUpdate:modelValue": (s: HkFilterState) => (seen = s),
|
|
83
140
|
}),
|
|
84
141
|
);
|
|
85
142
|
// The number row's control is the number-variant input (type=number —
|
|
86
143
|
// the browser refuses illegal keystrokes); the model side keeps the
|
|
87
144
|
// shape guard (sanitizeNumberInput) as the belt.
|
|
88
|
-
const input = c.querySelectorAll<HTMLInputElement>(".hk-filter-panel-row input")[
|
|
145
|
+
const input = c.querySelectorAll<HTMLInputElement>(".hk-filter-panel-row input")[0]!;
|
|
89
146
|
expect(input.type).toBe("number");
|
|
90
147
|
input.value = "42.5";
|
|
91
148
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
@@ -95,22 +152,23 @@ describe("HkFilterPanel", () => {
|
|
|
95
152
|
});
|
|
96
153
|
|
|
97
154
|
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")[
|
|
155
|
+
const c = mount(h(HkFilterPanel, { fields: FIELDS, modelValue: { agent: { op: "eq", value: "" } } }));
|
|
156
|
+
const selectRow = c.querySelectorAll(".hk-filter-panel-row")[0]!;
|
|
100
157
|
expect(selectRow.querySelector(".hk-popup-select-trigger")).toBeTruthy();
|
|
101
158
|
});
|
|
102
159
|
|
|
103
160
|
it("prepends a localized empty All row to select fields without one (the clear affordance)", async () => {
|
|
104
161
|
// The select field's ONLY clear affordance is the empty value row —
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
// {value:"", label:All} row;
|
|
108
|
-
// label. (R1 mutation f2 removed
|
|
109
|
-
// this test is that missing pin.)
|
|
162
|
+
// removal drops the whole condition, but an EMPTY value still means
|
|
163
|
+
// "filters nothing" on the wire. A page without its own "all" entry
|
|
164
|
+
// must therefore always find the prepended {value:"", label:All} row;
|
|
165
|
+
// a page that ships one keeps its own label. (R1 mutation f2 removed
|
|
166
|
+
// the prepend and NOTHING went red — this test is that missing pin.)
|
|
110
167
|
const withOwnEmpty = [
|
|
111
168
|
{ key: "level", label: "Level", kind: "select" as const, options: [{ value: "", label: "Everything" }, { value: "high", label: "High" }] },
|
|
112
169
|
];
|
|
113
|
-
const
|
|
170
|
+
const seeded = { level: { op: "eq" as const, value: "" } };
|
|
171
|
+
const c = mount(h(HkFilterPanel, { fields: withOwnEmpty, modelValue: seeded }));
|
|
114
172
|
// A page shipping its own empty row keeps it as-is (no duplicate):
|
|
115
173
|
// the panel prepends only when NO option carries the empty value.
|
|
116
174
|
const ownTrigger = c.querySelector(".hk-filter-panel-row .hk-popup-select-trigger");
|
|
@@ -126,7 +184,7 @@ describe("HkFilterPanel", () => {
|
|
|
126
184
|
const withoutAll = [
|
|
127
185
|
{ key: "level", label: "Level", kind: "select" as const, options: [{ value: "high", label: "High" }] },
|
|
128
186
|
];
|
|
129
|
-
const c2 = mount(h(HkFilterPanel, { fields: withoutAll, modelValue: {} }));
|
|
187
|
+
const c2 = mount(h(HkFilterPanel, { fields: withoutAll, modelValue: { level: { op: "eq", value: "" } } }));
|
|
130
188
|
const trigger2 = c2.querySelector(".hk-filter-panel-row .hk-popup-select-trigger")!;
|
|
131
189
|
trigger2.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
132
190
|
await Promise.resolve();
|
|
@@ -143,6 +201,36 @@ describe("HkFilterPanel", () => {
|
|
|
143
201
|
expect(c2.querySelector(".hk-filter-panel")!.getAttribute("data-active")).toBeNull();
|
|
144
202
|
});
|
|
145
203
|
|
|
204
|
+
it("suppresses the control captions (the row label is the one label) but keeps them semantic", () => {
|
|
205
|
+
const c = mount(
|
|
206
|
+
h(HkFilterPanel, {
|
|
207
|
+
fields: FIELDS,
|
|
208
|
+
modelValue: { agent: { op: "eq", value: "" }, tool: { op: "eq", value: "" } },
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
// No floating caption above either control.
|
|
212
|
+
expect(c.querySelector(".hk-popup-select-label")).toBeNull();
|
|
213
|
+
expect(c.querySelector(".hk-input-label")).toBeNull();
|
|
214
|
+
// …but the field element still carries the name for AT and the
|
|
215
|
+
// mobile select sheet's breadcrumb title.
|
|
216
|
+
const selectTrigger = c.querySelector(".hk-popup-select-trigger")!;
|
|
217
|
+
expect(selectTrigger.getAttribute("aria-label")).toBe("Agent");
|
|
218
|
+
const input = c.querySelector(".hk-input-element")!;
|
|
219
|
+
expect(input.getAttribute("aria-label")).toBe("Tool");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("rides the compact control stock (sm variants) in the dense rows", () => {
|
|
223
|
+
const c = mount(
|
|
224
|
+
h(HkFilterPanel, {
|
|
225
|
+
fields: FIELDS,
|
|
226
|
+
modelValue: ALL_SEEDED,
|
|
227
|
+
}),
|
|
228
|
+
);
|
|
229
|
+
expect(c.querySelector(".hk-popup-select-trigger-sm")).toBeTruthy();
|
|
230
|
+
expect(c.querySelector(".hk-input-box-sm")).toBeTruthy();
|
|
231
|
+
expect(c.querySelector(".hk-dp-trigger-sm")).toBeTruthy();
|
|
232
|
+
});
|
|
233
|
+
|
|
146
234
|
it("sanitizes number shapes: sign leading-only, one dot, junk stripped", () => {
|
|
147
235
|
expect(sanitizeNumberInput("12a.5b9")).toBe("12.59");
|
|
148
236
|
expect(sanitizeNumberInput("-3-x.1.2")).toBe("-3.12");
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { computed, defineComponent, type PropType } from "vue";
|
|
2
|
+
import { Plus, X } from "lucide-vue-next";
|
|
2
3
|
|
|
3
4
|
import HkDatePicker from "./HkDatePicker";
|
|
4
5
|
import HkInput from "./HkInput";
|
|
@@ -30,17 +31,20 @@ export interface HkFilterFieldDef {
|
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
/** One condition: the operator and the (string) value. An EMPTY value
|
|
33
|
-
* means the field filters nothing —
|
|
34
|
-
*
|
|
35
|
-
*
|
|
34
|
+
* means the field filters nothing — pages map absent/empty onto "no
|
|
35
|
+
* filter" at the RPC boundary; the row's own remove button is the UI
|
|
36
|
+
* verb that drops the condition entirely. */
|
|
36
37
|
export interface HkFilterCondition {
|
|
37
38
|
op: HkFilterOperator;
|
|
38
39
|
value: string;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
/** Per-field condition state, keyed by the field def's key.
|
|
42
|
-
*
|
|
43
|
-
*
|
|
42
|
+
/** Per-field condition state, keyed by the field def's key. The record
|
|
43
|
+
* IS the list of visible condition rows: a declared field the record
|
|
44
|
+
* does not mention is latent — it only materializes when the user adds
|
|
45
|
+
* it from the panel's add row (user direction 2026-10-12: conditions
|
|
46
|
+
* are ADDED, not all rendered at once), and a row the user removes
|
|
47
|
+
* leaves the record. */
|
|
44
48
|
export type HkFilterState = Record<string, HkFilterCondition>;
|
|
45
49
|
|
|
46
50
|
const OPERATOR_SYMBOL: Record<HkFilterOperator, string> = {
|
|
@@ -136,9 +140,13 @@ export default defineComponent({
|
|
|
136
140
|
|
|
137
141
|
return () => {
|
|
138
142
|
const label = props.label ?? t("hikari::filterBar.trigger", "Filters");
|
|
143
|
+
// The visible rows ARE the state record's declared fields, in
|
|
144
|
+
// declaration order; the rest stay latent behind the add row.
|
|
145
|
+
const rows = props.fields.filter((f) => props.modelValue[f.key] !== undefined);
|
|
146
|
+
const addable = props.fields.filter((f) => props.modelValue[f.key] === undefined);
|
|
139
147
|
return (
|
|
140
148
|
<div class="hk-filter-panel" data-testid="hk-filter-panel" data-active={activeCount.value > 0 || undefined} aria-label={label}>
|
|
141
|
-
{
|
|
149
|
+
{rows.map((field) => {
|
|
142
150
|
const condition = conditionFor(field);
|
|
143
151
|
const ops = operatorsFor(field);
|
|
144
152
|
return (
|
|
@@ -161,48 +169,97 @@ export default defineComponent({
|
|
|
161
169
|
))}
|
|
162
170
|
</div>
|
|
163
171
|
<div class="hk-filter-panel-value">
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
172
|
+
<div class="hk-filter-panel-control">
|
|
173
|
+
{field.kind === "select" ? (
|
|
174
|
+
<HkPopupSelect
|
|
175
|
+
modelValue={condition.value}
|
|
176
|
+
onUpdate:modelValue={(v: string) => emitCondition(field, { value: v })}
|
|
177
|
+
label={field.label}
|
|
178
|
+
// The row's left cell already names the field —
|
|
179
|
+
// the control's own caption above it reads as
|
|
180
|
+
// duplication (user direction 2026-10-12), and
|
|
181
|
+
// the compact variant keeps the dense row stock.
|
|
182
|
+
hideLabel
|
|
183
|
+
size="sm"
|
|
184
|
+
disabled={props.disabled}
|
|
185
|
+
options={selectOptions(field)}
|
|
186
|
+
/>
|
|
187
|
+
) : field.kind === "date" ? (
|
|
188
|
+
// The calendar picker — never the OS native input
|
|
189
|
+
// (2026-10-11 direction); the wire format is the same
|
|
190
|
+
// ISO YYYY-MM-DD the native input spoke.
|
|
191
|
+
<HkDatePicker
|
|
192
|
+
modelValue={condition.value || null}
|
|
193
|
+
onUpdate:modelValue={(v: string | null) =>
|
|
194
|
+
emitCondition(field, { value: v ?? "" })
|
|
195
|
+
}
|
|
196
|
+
size="sm"
|
|
197
|
+
disabled={props.disabled}
|
|
198
|
+
/>
|
|
199
|
+
) : (
|
|
200
|
+
<HkInput
|
|
201
|
+
type="text"
|
|
202
|
+
variant={field.kind === "number" ? "number" : "text"}
|
|
203
|
+
modelValue={condition.value}
|
|
204
|
+
onUpdate:modelValue={(v: string) =>
|
|
205
|
+
emitCondition(field, {
|
|
206
|
+
// Belt over the browser's native number filtering:
|
|
207
|
+
// the stored model keeps a clean number SHAPE only
|
|
208
|
+
// (sign, digits, one dot), so pages can hand it to
|
|
209
|
+
// RPC params verbatim.
|
|
210
|
+
value: field.kind === "number" ? sanitizeNumberInput(v) : v,
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
label={field.label}
|
|
214
|
+
hideLabel
|
|
215
|
+
size="sm"
|
|
216
|
+
placeholder={field.placeholder ?? ""}
|
|
217
|
+
disabled={props.disabled}
|
|
218
|
+
/>
|
|
219
|
+
)}
|
|
220
|
+
</div>
|
|
221
|
+
{/* Drop the condition entirely (the row leaves the
|
|
222
|
+
* panel; the page refetches unfiltered). Clearing the
|
|
223
|
+
* VALUE still means "filters nothing" for the wire —
|
|
224
|
+
* removal is just the tidy-up verb on top. */}
|
|
225
|
+
<button
|
|
226
|
+
type="button"
|
|
227
|
+
class="hk-filter-panel-remove"
|
|
228
|
+
aria-label={t("hikari::filterPanel.removeCondition", "Remove condition")}
|
|
229
|
+
title={t("hikari::filterPanel.removeCondition", "Remove condition")}
|
|
230
|
+
disabled={props.disabled}
|
|
231
|
+
onClick={() => {
|
|
232
|
+
const next: HkFilterState = { ...props.modelValue };
|
|
233
|
+
delete next[field.key];
|
|
234
|
+
emit("update:modelValue", next);
|
|
235
|
+
}}
|
|
236
|
+
>
|
|
237
|
+
<X size={14} aria-hidden="true" />
|
|
238
|
+
</button>
|
|
202
239
|
</div>
|
|
203
240
|
</div>
|
|
204
241
|
);
|
|
205
242
|
})}
|
|
243
|
+
{addable.length > 0 && (
|
|
244
|
+
<div class="hk-filter-panel-addrow">
|
|
245
|
+
{/* The ONE growth verb, parked in the operator column so
|
|
246
|
+
* the row grid reads as one aligned axis (user direction
|
|
247
|
+
* 2026-10-12). Appends the next unused field with its
|
|
248
|
+
* kind's default operator and an empty value. */}
|
|
249
|
+
<button
|
|
250
|
+
type="button"
|
|
251
|
+
class="hk-filter-panel-add"
|
|
252
|
+
disabled={props.disabled}
|
|
253
|
+
onClick={() => {
|
|
254
|
+
const field = addable[0]!;
|
|
255
|
+
emitCondition(field, { op: operatorsFor(field)[0]!, value: "" });
|
|
256
|
+
}}
|
|
257
|
+
>
|
|
258
|
+
<Plus size={14} aria-hidden="true" />
|
|
259
|
+
<span>{t("hikari::filterPanel.addCondition", "Add condition")}</span>
|
|
260
|
+
</button>
|
|
261
|
+
</div>
|
|
262
|
+
)}
|
|
206
263
|
</div>
|
|
207
264
|
);
|
|
208
265
|
};
|
|
@@ -47,7 +47,10 @@ describe("HkFilterTrigger", () => {
|
|
|
47
47
|
const c = mount(
|
|
48
48
|
h(HkFilterTrigger, {
|
|
49
49
|
fields: FIELDS,
|
|
50
|
-
|
|
50
|
+
// Seed one condition so the panel opens with its row (an empty
|
|
51
|
+
// state renders only the add row — conditions are added, not
|
|
52
|
+
// all rendered at once).
|
|
53
|
+
modelValue: { tool: { op: "eq", value: "" } },
|
|
51
54
|
"onUpdate:modelValue": (s: Record<string, unknown>) => (seen = s),
|
|
52
55
|
}),
|
|
53
56
|
);
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, h } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkInput from "./HkInput";
|
|
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
|
+
describe("HkInput hideLabel", () => {
|
|
25
|
+
it("renders the caption by default and derives no aria-label", () => {
|
|
26
|
+
const c = mount(h(HkInput, { modelValue: "", label: "Tool" }));
|
|
27
|
+
expect(c.querySelector(".hk-input-label")?.textContent).toContain("Tool");
|
|
28
|
+
expect(c.querySelector<HTMLInputElement>(".hk-input-element")!.getAttribute("aria-label")).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("hideLabel drops the caption and names the field element instead (text)", () => {
|
|
32
|
+
const c = mount(h(HkInput, { modelValue: "", label: "Tool", hideLabel: true }));
|
|
33
|
+
expect(c.querySelector(".hk-input-label")).toBeNull();
|
|
34
|
+
expect(c.querySelector<HTMLInputElement>(".hk-input-element")!.getAttribute("aria-label")).toBe("Tool");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("hideLabel names the field element instead (textarea)", () => {
|
|
38
|
+
const c = mount(h(HkInput, { modelValue: "", type: "textarea", label: "Notes", hideLabel: true, rows: 2 }));
|
|
39
|
+
expect(c.querySelector(".hk-input-label")).toBeNull();
|
|
40
|
+
expect(c.querySelector<HTMLTextAreaElement>(".hk-input-textarea")!.getAttribute("aria-label")).toBe("Notes");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("a caller-provided aria-label wins over the derived one", () => {
|
|
44
|
+
const c = mount(
|
|
45
|
+
h(HkInput, { modelValue: "", label: "Tool", hideLabel: true, "aria-label": "Custom name" }),
|
|
46
|
+
);
|
|
47
|
+
expect(c.querySelector<HTMLInputElement>(".hk-input-element")!.getAttribute("aria-label")).toBe("Custom name");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("hideLabel names the password surface's input too", () => {
|
|
51
|
+
const c = mount(
|
|
52
|
+
h(HkInput, { modelValue: "", label: "Passphrase", hideLabel: true, variant: "password" }),
|
|
53
|
+
);
|
|
54
|
+
expect(c.querySelector(".hk-input-label")).toBeNull();
|
|
55
|
+
// The surface forwards its fallthrough attrs onto the native input.
|
|
56
|
+
const input = c.querySelector<HTMLInputElement>('input[type="password"]')!;
|
|
57
|
+
expect(input, "the password surface renders its native input").toBeTruthy();
|
|
58
|
+
expect(input.getAttribute("aria-label")).toBe("Passphrase");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("a caller aria-label reaches the password input unchanged when hideLabel is off", () => {
|
|
62
|
+
// The derived aria-label renders `undefined` when hideLabel is off —
|
|
63
|
+
// and because JSX props merge in order, that undefined sits BEFORE
|
|
64
|
+
// the attrs spread so it can never clobber the caller's name (a
|
|
65
|
+
// regression here once silenced every labelled password field).
|
|
66
|
+
const c = mount(
|
|
67
|
+
h(HkInput, { modelValue: "", label: "Passphrase", "aria-label": "Current password", variant: "password" }),
|
|
68
|
+
);
|
|
69
|
+
const input = c.querySelector<HTMLInputElement>('input[type="password"]')!;
|
|
70
|
+
expect(input.getAttribute("aria-label")).toBe("Current password");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -44,6 +44,15 @@ export default defineComponent({
|
|
|
44
44
|
rows: { type: Number, default: 3 },
|
|
45
45
|
autoGrow: { type: Boolean, default: false },
|
|
46
46
|
size: { type: String as () => "sm" | "md" | "lg", default: "md" },
|
|
47
|
+
/**
|
|
48
|
+
* Suppress the caption above the control while keeping `label`
|
|
49
|
+
* semantic: the field element gets `aria-label={label}` instead
|
|
50
|
+
* (every variant — the password surface forwards it to its input).
|
|
51
|
+
* For rows that already name the field next to the control
|
|
52
|
+
* (HkFilterPanel's [field | ops | value] grid) — a second caption
|
|
53
|
+
* above the control reads as duplication there.
|
|
54
|
+
*/
|
|
55
|
+
hideLabel: { type: Boolean, default: false },
|
|
47
56
|
/**
|
|
48
57
|
* Input variant. `password` renders the hikari password surface:
|
|
49
58
|
* the canvas dot matrix with input ripples, the centered breathing
|
|
@@ -349,7 +358,7 @@ export default defineComponent({
|
|
|
349
358
|
|
|
350
359
|
return () => (
|
|
351
360
|
<div class="hk-input-wrapper">
|
|
352
|
-
{props.label && (
|
|
361
|
+
{props.label && !props.hideLabel && (
|
|
353
362
|
<label class="hk-input-label" for={fieldId.value}>
|
|
354
363
|
{props.label}
|
|
355
364
|
{props.required && <span class="hk-input-required">*</span>}
|
|
@@ -357,6 +366,12 @@ export default defineComponent({
|
|
|
357
366
|
)}
|
|
358
367
|
{isPassword.value ? (
|
|
359
368
|
<HkPasswordSurface
|
|
369
|
+
// Derived name FIRST: an explicit caller aria-label (via the
|
|
370
|
+
// attrs spread after it) must win — mergeProps lets a later
|
|
371
|
+
// undefined clobber an earlier value, so the order here IS
|
|
372
|
+
// the caller-precedence contract (same as the input/textarea
|
|
373
|
+
// branches below).
|
|
374
|
+
aria-label={props.hideLabel ? props.label : undefined}
|
|
360
375
|
{...filteredAttrs.value}
|
|
361
376
|
modelValue={props.modelValue}
|
|
362
377
|
onUpdate:modelValue={(v: string) => emit("update:modelValue", v)}
|
|
@@ -436,6 +451,7 @@ export default defineComponent({
|
|
|
436
451
|
data-1p-ignore
|
|
437
452
|
data-lpignore="true"
|
|
438
453
|
class="hk-input-element"
|
|
454
|
+
aria-label={props.hideLabel ? props.label : undefined}
|
|
439
455
|
{...filteredAttrs.value}
|
|
440
456
|
onInput={onInput}
|
|
441
457
|
onFocus={(e) => { forwardFocus(true); emit("focus", e); }}
|
|
@@ -476,6 +492,7 @@ export default defineComponent({
|
|
|
476
492
|
"hk-input-textarea",
|
|
477
493
|
isAutoGrow.value ? "hk-input-textarea-autogrow" : "",
|
|
478
494
|
]}
|
|
495
|
+
aria-label={props.hideLabel ? props.label : undefined}
|
|
479
496
|
{...filteredAttrs.value}
|
|
480
497
|
onInput={onInput}
|
|
481
498
|
onFocus={(e) => { forwardFocus(true); emit("focus", e); }}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, h } from "vue";
|
|
3
|
+
|
|
4
|
+
// The sheet-title pin needs the mobile breakpoint (HkPopover's sheet
|
|
5
|
+
// branch); the rest of the file is desktop-neutral. Same file-level mock
|
|
6
|
+
// split as HkFilterTrigger.mobile.
|
|
7
|
+
vi.mock("../runtime/useBreakpoint", () => ({
|
|
8
|
+
useBreakpoint: () => ({ isMobile: { value: true } }),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
import HkPopupSelect from "./HkPopupSelect";
|
|
12
|
+
|
|
13
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
14
|
+
|
|
15
|
+
function mount(node: ReturnType<typeof h>) {
|
|
16
|
+
const container = document.createElement("div");
|
|
17
|
+
document.body.appendChild(container);
|
|
18
|
+
const app = createApp({ render: () => node });
|
|
19
|
+
app.mount(container);
|
|
20
|
+
mounts.push({ app, container });
|
|
21
|
+
return container;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
26
|
+
app.unmount();
|
|
27
|
+
container.remove();
|
|
28
|
+
}
|
|
29
|
+
document.body.querySelectorAll(".hk-popover-scrim, .hk-popover-panel, .hk-select-popout-host").forEach((el) => el.remove());
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const OPTIONS = [{ value: "a1", label: "Agent One" }];
|
|
33
|
+
|
|
34
|
+
describe("HkPopupSelect (mobile sheet)", () => {
|
|
35
|
+
it("hideLabel keeps the sheet's breadcrumb title on the field's name", async () => {
|
|
36
|
+
// hideLabel only suppresses the INLINE caption; the sheet header
|
|
37
|
+
// must still carry the field's i18n label — it names the blocking
|
|
38
|
+
// window layer for the popup-manager breadcrumb.
|
|
39
|
+
const c = mount(
|
|
40
|
+
h(HkPopupSelect, { modelValue: "", label: "Agent", hideLabel: true, options: OPTIONS }),
|
|
41
|
+
);
|
|
42
|
+
const trigger = c.querySelector<HTMLButtonElement>(".hk-popup-select-trigger")!;
|
|
43
|
+
trigger.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
44
|
+
await Promise.resolve();
|
|
45
|
+
await Promise.resolve();
|
|
46
|
+
const title = document.body.querySelector(".hk-popover-sheet-title");
|
|
47
|
+
expect(title, "the sheet renders a title band").toBeTruthy();
|
|
48
|
+
expect(title!.textContent).toBe("Agent");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -85,6 +85,20 @@
|
|
|
85
85
|
cursor: not-allowed;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/* Compact variant (size="sm"): the dense-row stock — filter condition
|
|
89
|
+
* rows, toolbars — where the 2.5rem form-field stock reads oversized.
|
|
90
|
+
* Mirrors HkDatePicker's .hk-dp-trigger-sm geometry. */
|
|
91
|
+
.hk-popup-select-trigger-sm {
|
|
92
|
+
min-height: 2rem;
|
|
93
|
+
padding: var(--space-4) var(--space-8);
|
|
94
|
+
padding-inline-end: var(--space-28);
|
|
95
|
+
font-size: var(--text-sm);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.hk-popup-select-trigger-sm .hk-popup-select-arrow {
|
|
99
|
+
inset-inline-end: var(--space-8);
|
|
100
|
+
}
|
|
101
|
+
|
|
88
102
|
.hk-popup-select-value {
|
|
89
103
|
flex: 1;
|
|
90
104
|
white-space: nowrap;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, h } from "vue";
|
|
3
|
+
|
|
4
|
+
// The sheet half of this file runs with a pinned mobile breakpoint (the
|
|
5
|
+
// desktop pins below share the file's module graph, and useBreakpoint
|
|
6
|
+
// cannot be re-mocked per-test) — mirror of HkFilterTrigger.mobile's
|
|
7
|
+
// split.
|
|
8
|
+
vi.mock("../runtime/useBreakpoint", () => ({
|
|
9
|
+
useBreakpoint: () => ({ isMobile: { value: false } }),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
import HkPopupSelect from "./HkPopupSelect";
|
|
13
|
+
|
|
14
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
15
|
+
|
|
16
|
+
function mount(node: ReturnType<typeof h>) {
|
|
17
|
+
const container = document.createElement("div");
|
|
18
|
+
document.body.appendChild(container);
|
|
19
|
+
const app = createApp({ render: () => node });
|
|
20
|
+
app.mount(container);
|
|
21
|
+
mounts.push({ app, container });
|
|
22
|
+
return container;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
27
|
+
app.unmount();
|
|
28
|
+
container.remove();
|
|
29
|
+
}
|
|
30
|
+
document.body.querySelectorAll(".hk-popover-scrim, .hk-popover-panel, .hk-select-popout-host").forEach((el) => el.remove());
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const OPTIONS = [
|
|
34
|
+
{ value: "a1", label: "Agent One" },
|
|
35
|
+
{ value: "a2", label: "Agent Two" },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
describe("HkPopupSelect", () => {
|
|
39
|
+
it("renders the md stock by default — no sm class, no derived aria-label", () => {
|
|
40
|
+
const c = mount(h(HkPopupSelect, { modelValue: "a1", label: "Agent", options: OPTIONS }));
|
|
41
|
+
const trigger = c.querySelector<HTMLButtonElement>(".hk-popup-select-trigger")!;
|
|
42
|
+
expect(trigger.className).toContain("hk-popup-select-trigger");
|
|
43
|
+
expect(trigger.className).not.toContain("hk-popup-select-trigger-sm");
|
|
44
|
+
// Without hideLabel the caption renders and no aria-label is derived.
|
|
45
|
+
expect(c.querySelector(".hk-popup-select-label")?.textContent).toBe("Agent");
|
|
46
|
+
expect(trigger.getAttribute("aria-label")).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("size=sm rides the compact trigger class", () => {
|
|
50
|
+
const c = mount(h(HkPopupSelect, { modelValue: "a1", size: "sm", options: OPTIONS }));
|
|
51
|
+
expect(c.querySelector(".hk-popup-select-trigger-sm")).toBeTruthy();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("hideLabel drops the caption but keeps the name on the trigger", () => {
|
|
55
|
+
const c = mount(
|
|
56
|
+
h(HkPopupSelect, { modelValue: "a1", label: "Agent", hideLabel: true, options: OPTIONS }),
|
|
57
|
+
);
|
|
58
|
+
expect(c.querySelector(".hk-popup-select-label")).toBeNull();
|
|
59
|
+
expect(c.querySelector(".hk-popup-select-trigger")!.getAttribute("aria-label")).toBe("Agent");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("a caller-provided aria-label cannot reach the trigger (fallthrough lands on the wrapper)", () => {
|
|
63
|
+
// Unlike HkInput (whose field element spreads caller attrs after the
|
|
64
|
+
// derived aria-label), this trigger is an explicit element in the
|
|
65
|
+
// render — callers rename the field through `label`, which feeds
|
|
66
|
+
// both the caption and (with hideLabel) the derived aria-label.
|
|
67
|
+
const c = mount(
|
|
68
|
+
h(HkPopupSelect, {
|
|
69
|
+
modelValue: "a1",
|
|
70
|
+
label: "Agent",
|
|
71
|
+
hideLabel: true,
|
|
72
|
+
"aria-label": "Custom name",
|
|
73
|
+
options: OPTIONS,
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
expect(c.querySelector(".hk-popup-select-trigger")!.getAttribute("aria-label")).toBe("Agent");
|
|
77
|
+
expect(c.querySelector(".hk-popup-select-wrapper")!.getAttribute("aria-label")).toBe("Custom name");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("without hideLabel no aria-label is derived, even when a label renders", () => {
|
|
81
|
+
const c = mount(h(HkPopupSelect, { modelValue: "a1", label: "Agent", options: OPTIONS }));
|
|
82
|
+
expect(c.querySelector(".hk-popup-select-trigger")!.getAttribute("aria-label")).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -36,6 +36,17 @@ export default defineComponent({
|
|
|
36
36
|
error: { type: String, default: undefined },
|
|
37
37
|
disabled: { type: Boolean, default: false },
|
|
38
38
|
required: { type: Boolean, default: false },
|
|
39
|
+
/** Control density: "md" is the form-field stock (2.5rem), "sm" the
|
|
40
|
+
* compact variant (2rem) for dense rows — filter condition rows,
|
|
41
|
+
* toolbars (HkDatePicker/HkInput carry the same split). */
|
|
42
|
+
size: { type: String as PropType<"sm" | "md">, default: "md" },
|
|
43
|
+
/** Suppress the caption above the control while keeping `label`
|
|
44
|
+
* semantic: the trigger gets `aria-label={label}` and the mobile
|
|
45
|
+
* sheet still takes `label` as its breadcrumb title. For rows that
|
|
46
|
+
* already name the field next to the control (HkFilterPanel's
|
|
47
|
+
* [field | ops | value] grid) — a second caption above the control
|
|
48
|
+
* reads as duplication there. */
|
|
49
|
+
hideLabel: { type: Boolean, default: false },
|
|
39
50
|
options: {
|
|
40
51
|
type: Array as PropType<HkPopupSelectOption[]>,
|
|
41
52
|
default: undefined,
|
|
@@ -159,7 +170,7 @@ export default defineComponent({
|
|
|
159
170
|
|
|
160
171
|
return () => (
|
|
161
172
|
<div class="hk-popup-select-wrapper">
|
|
162
|
-
{props.label ? (
|
|
173
|
+
{props.label && !props.hideLabel ? (
|
|
163
174
|
<label class="hk-popup-select-label">
|
|
164
175
|
{props.label}
|
|
165
176
|
{props.required && <span class="hk-popup-select-required">*</span>}
|
|
@@ -168,7 +179,11 @@ export default defineComponent({
|
|
|
168
179
|
<button
|
|
169
180
|
ref={triggerRef}
|
|
170
181
|
type="button"
|
|
171
|
-
class=
|
|
182
|
+
class={[
|
|
183
|
+
"hk-popup-select-trigger",
|
|
184
|
+
props.size === "sm" ? "hk-popup-select-trigger-sm" : "",
|
|
185
|
+
]}
|
|
186
|
+
aria-label={props.hideLabel ? props.label : undefined}
|
|
172
187
|
data-error={props.error || undefined}
|
|
173
188
|
data-disabled={props.disabled || undefined}
|
|
174
189
|
disabled={props.disabled}
|
|
@@ -179,7 +194,7 @@ export default defineComponent({
|
|
|
179
194
|
<span class="hk-popup-select-value">
|
|
180
195
|
{displayLabel.value || props.placeholder}
|
|
181
196
|
</span>
|
|
182
|
-
<ChevronDown size={16} class="hk-popup-select-arrow" />
|
|
197
|
+
<ChevronDown size={props.size === "sm" ? 14 : 16} class="hk-popup-select-arrow" />
|
|
183
198
|
</button>
|
|
184
199
|
<HkPopover
|
|
185
200
|
modelValue={isOpen.value}
|
|
@@ -27,6 +27,9 @@ function ruleDeclarations(source: string, selector: string): Array<[string, stri
|
|
|
27
27
|
let boundary = at - 1;
|
|
28
28
|
while (boundary >= 0 && !"}{;".includes(code[boundary])) boundary -= 1;
|
|
29
29
|
if (code.slice(boundary + 1, at).replace(/\s+/g, " ").trim() !== "") continue;
|
|
30
|
+
// Top-level rules only: a nested occurrence (the ≤767px phone band)
|
|
31
|
+
// must not satisfy the desktop pins.
|
|
32
|
+
if (braceDepthAt(code, at) !== 0) continue;
|
|
30
33
|
const body = code.slice(open + 1, code.indexOf("}", open));
|
|
31
34
|
const out: Array<[string, string]> = [];
|
|
32
35
|
for (const part of body.split(";")) {
|
|
@@ -38,10 +41,50 @@ function ruleDeclarations(source: string, selector: string): Array<[string, stri
|
|
|
38
41
|
return null;
|
|
39
42
|
}
|
|
40
43
|
|
|
44
|
+
|
|
45
|
+
/** Brace depth of `code` at character index `at` — 0 = top level, ≥1 =
|
|
46
|
+
* nested inside an at-block (e.g. the ≤767px phone band's @media). */
|
|
47
|
+
function braceDepthAt(code: string, at: number): number {
|
|
48
|
+
let depth = 0;
|
|
49
|
+
for (let i = 0; i < at; i += 1) {
|
|
50
|
+
if (code[i] === "{") depth += 1;
|
|
51
|
+
else if (code[i] === "}") depth -= 1;
|
|
52
|
+
}
|
|
53
|
+
return depth;
|
|
54
|
+
}
|
|
55
|
+
|
|
41
56
|
const adminTokens = readFileSync(join(here, "..", "styles", "admin-tokens.scss"), "utf8");
|
|
42
57
|
const iconButtonVars = readFileSync(join(here, "HkIconButtonVars.scss"), "utf8");
|
|
43
58
|
const iconButton = readFileSync(join(here, "HkIconButton.tsx"), "utf8");
|
|
44
59
|
|
|
60
|
+
/** Every rule whose prelude IS `selector` (whole-prelude match, comments
|
|
61
|
+
* stripped) — used for the phone band, where the same prelude appears
|
|
62
|
+
* both at top level (desktop) and inside the ≤767px media block. */
|
|
63
|
+
function allRuleDeclarations(
|
|
64
|
+
source: string,
|
|
65
|
+
selector: string,
|
|
66
|
+
): Array<{ nested: boolean; decls: Array<[string, string]> }> {
|
|
67
|
+
const code = source.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
68
|
+
const out: Array<{ nested: boolean; decls: Array<[string, string]> }> = [];
|
|
69
|
+
for (let at = code.indexOf(selector); at >= 0; at = code.indexOf(selector, at + 1)) {
|
|
70
|
+
const open = code.indexOf("{", at + selector.length);
|
|
71
|
+
if (open < 0) continue;
|
|
72
|
+
if (code.slice(at + selector.length, open).trim() !== "") continue;
|
|
73
|
+
let boundary = at - 1;
|
|
74
|
+
while (boundary >= 0 && !"}{;".includes(code[boundary])) boundary -= 1;
|
|
75
|
+
if (code.slice(boundary + 1, at).replace(/\s+/g, " ").trim() !== "") continue;
|
|
76
|
+
const nested = braceDepthAt(code, at) > 0;
|
|
77
|
+
const body = code.slice(open + 1, code.indexOf("}", open));
|
|
78
|
+
const decls: Array<[string, string]> = [];
|
|
79
|
+
for (const part of body.split(";")) {
|
|
80
|
+
const m = /^\s*([-a-zA-Z]+)\s*:\s*([\s\S]+?)\s*$/.exec(part);
|
|
81
|
+
if (m) decls.push([m[1], m[2].replace(/\s+/g, " ")]);
|
|
82
|
+
}
|
|
83
|
+
out.push({ nested, decls });
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
45
88
|
describe("admin header chrome geometry (the two user-requested fixes)", () => {
|
|
46
89
|
it("keeps the console title at the panel's visual distance from the avatar (16px wing gap)", () => {
|
|
47
90
|
// The frontend's workspace button sits a flex gap PLUS its own 8px
|
|
@@ -76,4 +119,26 @@ describe("admin header chrome geometry (the two user-requested fixes)", () => {
|
|
|
76
119
|
// The component accepts the step.
|
|
77
120
|
expect(iconButton).toMatch(/PropType<16 \| 24 \| 28 \| 32 \| 36 \| 40>/);
|
|
78
121
|
});
|
|
122
|
+
|
|
123
|
+
it("pins the phone band: wings keep their floors, the centre strip owns the overflow", () => {
|
|
124
|
+
// User report 2026-10-12: on phones the balanced wings (flex 1 1 0 +
|
|
125
|
+
// min-width 0) were squeezed BELOW the avatar's 28px floor by a wide
|
|
126
|
+
// centre tab strip, and the strip painted straight over the avatar.
|
|
127
|
+
// The ≤767px band must pin both wings and hand the leftover to the
|
|
128
|
+
// centre (mirroring ChatLayout's own phone band) so the strip slides
|
|
129
|
+
// inside its own box instead of over the avatar.
|
|
130
|
+
const userRules = allRuleDeclarations(adminTokens, ".s-admin-header-user");
|
|
131
|
+
const centreRules = allRuleDeclarations(adminTokens, ".s-admin-header-center");
|
|
132
|
+
const actionsRules = allRuleDeclarations(adminTokens, ".s-admin-header-actions");
|
|
133
|
+
// Exactly one NESTED occurrence (the phone band) per prelude.
|
|
134
|
+
const phone = (rules: typeof userRules) => rules.filter((r) => r.nested).map((r) => r.decls);
|
|
135
|
+
expect(phone(userRules)).toHaveLength(1);
|
|
136
|
+
expect(phone(centreRules)).toHaveLength(1);
|
|
137
|
+
expect(phone(actionsRules)).toHaveLength(1);
|
|
138
|
+
expect(phone(userRules)[0]).toContainEqual(["flex", "0 0 auto"]);
|
|
139
|
+
expect(phone(actionsRules)[0]).toContainEqual(["flex", "0 0 auto"]);
|
|
140
|
+
expect(phone(centreRules)[0]).toContainEqual(["flex", "1 1 auto"]);
|
|
141
|
+
expect(phone(centreRules)[0]).toContainEqual(["min-width", "0"]);
|
|
142
|
+
expect(phone(centreRules)[0]).toContainEqual(["overflow", "hidden"]);
|
|
143
|
+
});
|
|
79
144
|
});
|
|
@@ -200,6 +200,8 @@
|
|
|
200
200
|
"hikari::filterBar.trigger": "عوامل التصفية",
|
|
201
201
|
"hikari::filterBar.apply": "تطبيق",
|
|
202
202
|
"hikari::filterPanel.all": "الكل",
|
|
203
|
+
"hikari::filterPanel.addCondition": "إضافة شرط",
|
|
204
|
+
"hikari::filterPanel.removeCondition": "إزالة الشرط",
|
|
203
205
|
"hikari::filterBar.reset": "إعادة تعيين",
|
|
204
206
|
"hikari::filterPanel.op.eq": "يساوي",
|
|
205
207
|
"hikari::filterPanel.op.ne": "لا يساوي",
|
|
@@ -200,6 +200,8 @@
|
|
|
200
200
|
"hikari::filterBar.trigger": "Filter",
|
|
201
201
|
"hikari::filterBar.apply": "Anwenden",
|
|
202
202
|
"hikari::filterPanel.all": "Alle",
|
|
203
|
+
"hikari::filterPanel.addCondition": "Bedingung hinzufügen",
|
|
204
|
+
"hikari::filterPanel.removeCondition": "Bedingung entfernen",
|
|
203
205
|
"hikari::filterBar.reset": "Zurücksetzen",
|
|
204
206
|
"hikari::filterPanel.op.eq": "gleich",
|
|
205
207
|
"hikari::filterPanel.op.ne": "ungleich",
|
|
@@ -200,6 +200,8 @@
|
|
|
200
200
|
"hikari::filterBar.trigger": "Filters",
|
|
201
201
|
"hikari::filterBar.apply": "Apply",
|
|
202
202
|
"hikari::filterPanel.all": "All",
|
|
203
|
+
"hikari::filterPanel.addCondition": "Add condition",
|
|
204
|
+
"hikari::filterPanel.removeCondition": "Remove condition",
|
|
203
205
|
"hikari::filterBar.reset": "Reset",
|
|
204
206
|
"hikari::filterPanel.op.eq": "equals",
|
|
205
207
|
"hikari::filterPanel.op.ne": "not equals",
|
|
@@ -200,6 +200,8 @@
|
|
|
200
200
|
"hikari::filterBar.trigger": "Filtros",
|
|
201
201
|
"hikari::filterBar.apply": "Aplicar",
|
|
202
202
|
"hikari::filterPanel.all": "Todos",
|
|
203
|
+
"hikari::filterPanel.addCondition": "Añadir condición",
|
|
204
|
+
"hikari::filterPanel.removeCondition": "Eliminar condición",
|
|
203
205
|
"hikari::filterBar.reset": "Restablecer",
|
|
204
206
|
"hikari::filterPanel.op.eq": "igual a",
|
|
205
207
|
"hikari::filterPanel.op.ne": "distinto de",
|
|
@@ -200,6 +200,8 @@
|
|
|
200
200
|
"hikari::filterBar.trigger": "Filtres",
|
|
201
201
|
"hikari::filterBar.apply": "Appliquer",
|
|
202
202
|
"hikari::filterPanel.all": "Tous",
|
|
203
|
+
"hikari::filterPanel.addCondition": "Ajouter une condition",
|
|
204
|
+
"hikari::filterPanel.removeCondition": "Supprimer la condition",
|
|
203
205
|
"hikari::filterBar.reset": "Réinitialiser",
|
|
204
206
|
"hikari::filterPanel.op.eq": "égal à",
|
|
205
207
|
"hikari::filterPanel.op.ne": "différent de",
|
|
@@ -200,6 +200,8 @@
|
|
|
200
200
|
"hikari::filterBar.trigger": "絞り込み",
|
|
201
201
|
"hikari::filterBar.apply": "適用",
|
|
202
202
|
"hikari::filterPanel.all": "すべて",
|
|
203
|
+
"hikari::filterPanel.addCondition": "条件を追加",
|
|
204
|
+
"hikari::filterPanel.removeCondition": "条件を削除",
|
|
203
205
|
"hikari::filterBar.reset": "リセット",
|
|
204
206
|
"hikari::filterPanel.op.eq": "等しい",
|
|
205
207
|
"hikari::filterPanel.op.ne": "等しくない",
|
|
@@ -200,6 +200,8 @@
|
|
|
200
200
|
"hikari::filterBar.trigger": "필터",
|
|
201
201
|
"hikari::filterBar.apply": "적용",
|
|
202
202
|
"hikari::filterPanel.all": "전체",
|
|
203
|
+
"hikari::filterPanel.addCondition": "조건 추가",
|
|
204
|
+
"hikari::filterPanel.removeCondition": "조건 삭제",
|
|
203
205
|
"hikari::filterBar.reset": "초기화",
|
|
204
206
|
"hikari::filterPanel.op.eq": "같음",
|
|
205
207
|
"hikari::filterPanel.op.ne": "같지 않음",
|
|
@@ -200,6 +200,8 @@
|
|
|
200
200
|
"hikari::filterBar.trigger": "Filtros",
|
|
201
201
|
"hikari::filterBar.apply": "Aplicar",
|
|
202
202
|
"hikari::filterPanel.all": "Todos",
|
|
203
|
+
"hikari::filterPanel.addCondition": "Adicionar condição",
|
|
204
|
+
"hikari::filterPanel.removeCondition": "Remover condição",
|
|
203
205
|
"hikari::filterBar.reset": "Redefinir",
|
|
204
206
|
"hikari::filterPanel.op.eq": "igual a",
|
|
205
207
|
"hikari::filterPanel.op.ne": "diferente de",
|
|
@@ -200,6 +200,8 @@
|
|
|
200
200
|
"hikari::filterBar.trigger": "Фильтры",
|
|
201
201
|
"hikari::filterBar.apply": "Применить",
|
|
202
202
|
"hikari::filterPanel.all": "Все",
|
|
203
|
+
"hikari::filterPanel.addCondition": "Добавить условие",
|
|
204
|
+
"hikari::filterPanel.removeCondition": "Удалить условие",
|
|
203
205
|
"hikari::filterBar.reset": "Сбросить",
|
|
204
206
|
"hikari::filterPanel.op.eq": "равно",
|
|
205
207
|
"hikari::filterPanel.op.ne": "не равно",
|
|
@@ -204,6 +204,8 @@
|
|
|
204
204
|
"hikari::filterBar.trigger": "筛选",
|
|
205
205
|
"hikari::filterBar.apply": "应用",
|
|
206
206
|
"hikari::filterPanel.all": "全部",
|
|
207
|
+
"hikari::filterPanel.addCondition": "添加条件",
|
|
208
|
+
"hikari::filterPanel.removeCondition": "移除条件",
|
|
207
209
|
"hikari::filterBar.reset": "重置",
|
|
208
210
|
"hikari::filterPanel.op.eq": "等于",
|
|
209
211
|
"hikari::filterPanel.op.ne": "不等于",
|
|
@@ -204,6 +204,8 @@
|
|
|
204
204
|
"hikari::filterBar.trigger": "篩選",
|
|
205
205
|
"hikari::filterBar.apply": "套用",
|
|
206
206
|
"hikari::filterPanel.all": "全部",
|
|
207
|
+
"hikari::filterPanel.addCondition": "新增條件",
|
|
208
|
+
"hikari::filterPanel.removeCondition": "移除條件",
|
|
207
209
|
"hikari::filterBar.reset": "重設",
|
|
208
210
|
"hikari::filterPanel.op.eq": "等於",
|
|
209
211
|
"hikari::filterPanel.op.ne": "不等於",
|
|
@@ -450,6 +450,35 @@
|
|
|
450
450
|
min-width: 0;
|
|
451
451
|
}
|
|
452
452
|
|
|
453
|
+
|
|
454
|
+
/* Phone band (≤767px) — mirrors ChatLayout's own ≤767px header band
|
|
455
|
+
* (the console face of the same balanced-wing header). On phones the
|
|
456
|
+
* desktop balanced wings (`flex: 1 1 0` + `min-width: 0`) let a wide
|
|
457
|
+
* centre tab strip squeeze BOTH side wings below their content floors:
|
|
458
|
+
* the avatar wing shrank under its 28px trigger and the strip painted
|
|
459
|
+
* straight over the avatar (user report 2026-10-12, L2 firewall tabs).
|
|
460
|
+
* The frontend's answer — pin both wings, hand the leftover to the
|
|
461
|
+
* centre and let its HTabs scroller own the overflow — is reproduced
|
|
462
|
+
* here in substance: the strip keeps every pixel the avatar and the action
|
|
463
|
+
* buttons don't need, and slides INSIDE its own box (edge fades + scroll
|
|
464
|
+
* limits intact) without ever covering the avatar.
|
|
465
|
+
* Desktop (>767px) keeps the balanced-wing centring untouched. */
|
|
466
|
+
@media (max-width: 767px) {
|
|
467
|
+
.s-admin-header-user {
|
|
468
|
+
flex: 0 0 auto;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
.s-admin-header-actions {
|
|
472
|
+
flex: 0 0 auto;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
.s-admin-header-center {
|
|
476
|
+
flex: 1 1 auto;
|
|
477
|
+
min-width: 0;
|
|
478
|
+
overflow: hidden;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
453
482
|
.s-admin-header-avatar {
|
|
454
483
|
position: relative;
|
|
455
484
|
width: var(--space-28);
|