@celestia-island/hikari 0.31.2 → 0.32.1
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/HkAdminHeader.tsx +5 -5
- package/src/components/HkAuthMethodList.scss +14 -6
- package/src/components/HkAuthMethodList.test.tsx +11 -10
- package/src/components/HkAuthMethodList.tsx +60 -9
- package/src/components/HkBreadcrumb.scss +20 -35
- package/src/components/HkMenu.scss +29 -96
- package/src/components/HkMenu.test.ts +5 -3
- package/src/components/HkMenu.tsx +11 -5
- package/src/components/HkMenuActionItem.scss +10 -48
- package/src/components/HkMenuActionItem.tsx +11 -8
- package/src/components/HkNavItem.scss +19 -12
- package/src/components/HkPopover.scss +7 -27
- package/src/components/HkPopupSelect.scss +11 -29
- package/src/components/HkSelect.scss +16 -31
- package/src/components/HkThemeToggle.scss +25 -33
- package/src/components/HkThemeToggle.tsx +4 -2
- package/src/components/HkToolBlock.scss +8 -3
- package/src/components/_menu-item.scss +180 -0
- package/src/styles/admin-tokens.scss +14 -30
package/package.json
CHANGED
|
@@ -168,7 +168,7 @@ export const HkAdminHeader = defineComponent({
|
|
|
168
168
|
class="s-popup-menu-item"
|
|
169
169
|
onClick={() => props.onForceSignOut?.()}
|
|
170
170
|
>
|
|
171
|
-
<LogOut size={14}
|
|
171
|
+
<span class="hk-menu-item-icon"><LogOut size={14} /></span>
|
|
172
172
|
{props.forceSignOutLabel ?? t("hikari::adminHeader.forceSignOut", "Sign out")}
|
|
173
173
|
</button>
|
|
174
174
|
</div>
|
|
@@ -202,7 +202,7 @@ export const HkAdminHeader = defineComponent({
|
|
|
202
202
|
avatarModalOpen.value = true;
|
|
203
203
|
}}
|
|
204
204
|
>
|
|
205
|
-
<Camera size={14}
|
|
205
|
+
<span class="hk-menu-item-icon"><Camera size={14} /></span>
|
|
206
206
|
{props.avatarMenuLabel ?? t("hikari::adminHeader.avatar", "Avatar")}
|
|
207
207
|
</button>
|
|
208
208
|
{/* The language trigger owns the locale anchor: the ref sits
|
|
@@ -216,7 +216,7 @@ export const HkAdminHeader = defineComponent({
|
|
|
216
216
|
class="s-popup-menu-item"
|
|
217
217
|
onClick={() => (localeMenuOpen.value = !localeMenuOpen.value)}
|
|
218
218
|
>
|
|
219
|
-
<Languages size={14}
|
|
219
|
+
<span class="hk-menu-item-icon"><Languages size={14} /></span>
|
|
220
220
|
{props.localeMenuLabel ?? t("hikari::adminHeader.language", "Language")}
|
|
221
221
|
</button>
|
|
222
222
|
{slots["locale-picker"]?.({
|
|
@@ -240,7 +240,7 @@ export const HkAdminHeader = defineComponent({
|
|
|
240
240
|
emit("goToFrontend");
|
|
241
241
|
}}
|
|
242
242
|
>
|
|
243
|
-
<ExternalLink size={14}
|
|
243
|
+
<span class="hk-menu-item-icon"><ExternalLink size={14} /></span>
|
|
244
244
|
{props.goToFrontendLabel}
|
|
245
245
|
</button>
|
|
246
246
|
) : null}
|
|
@@ -251,7 +251,7 @@ export const HkAdminHeader = defineComponent({
|
|
|
251
251
|
emit("logout");
|
|
252
252
|
}}
|
|
253
253
|
>
|
|
254
|
-
<LogOut size={14}
|
|
254
|
+
<span class="hk-menu-item-icon"><LogOut size={14} /></span>
|
|
255
255
|
{props.logoutLabel ?? t("hikari::adminHeader.logout", "Logout")}
|
|
256
256
|
</button>
|
|
257
257
|
</div>
|
|
@@ -35,10 +35,12 @@
|
|
|
35
35
|
centered hk-btn-block. HkButton has a fragment root, so inbound classes
|
|
36
36
|
don't fall through — scope everything off the `.s-auth-methods`
|
|
37
37
|
container (descendant match: the component wrapper is display:contents).
|
|
38
|
-
The label column width is MEASURED by the component (widest label text
|
|
39
|
-
published as `--auth-methods-label-width` on its
|
|
40
|
-
the rows); the
|
|
41
|
-
|
|
38
|
+
The label column width is MEASURED by the component (widest label text
|
|
39
|
+
+ 1px headroom, published as `--auth-methods-label-width` on its
|
|
40
|
+
wrapper, inherited into the rows); the `max-content` fallback only
|
|
41
|
+
covers the pre-measure / un-measurable frame, and apps may still
|
|
42
|
+
override the var explicitly. Labels are centered within the shared
|
|
43
|
+
column so every row's text sits on the same vertical center line. */
|
|
42
44
|
.s-auth-methods .hk-btn .s-auth-methods-icon {
|
|
43
45
|
width: 20px;
|
|
44
46
|
flex: none;
|
|
@@ -48,9 +50,15 @@
|
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
.s-auth-methods .hk-btn .s-auth-methods-label {
|
|
51
|
-
width: var(--auth-methods-label-width,
|
|
53
|
+
width: var(--auth-methods-label-width, max-content);
|
|
54
|
+
/* Hardened floor: even if the published column were ever stale or too
|
|
55
|
+
narrow (engine DPR / zoom mismatch), the label can never be clipped —
|
|
56
|
+
the row just widens to its own text. When the measurement is correct
|
|
57
|
+
the var already exceeds every label's natural width, so the floor is
|
|
58
|
+
inert and alignment is preserved. */
|
|
59
|
+
min-width: max-content;
|
|
52
60
|
flex: none;
|
|
53
|
-
text-align:
|
|
61
|
+
text-align: center;
|
|
54
62
|
white-space: nowrap;
|
|
55
63
|
overflow: hidden;
|
|
56
64
|
text-overflow: ellipsis;
|
|
@@ -64,35 +64,36 @@ describe("HkAuthMethodList", () => {
|
|
|
64
64
|
expect(picked).toBe("linuxdo");
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
-
it("publishes the widest label text as the label-column custom property", async () => {
|
|
67
|
+
it("publishes the widest label text plus a 1px headroom as the label-column custom property", async () => {
|
|
68
68
|
const c = mount(h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, { methods })));
|
|
69
69
|
await nextTick();
|
|
70
70
|
const wrapper = c.querySelector<HTMLElement>(".s-auth-methods-list")!;
|
|
71
71
|
expect(wrapper).toBeTruthy();
|
|
72
|
-
// Layout engines in test DOM report zero text metrics; stub
|
|
73
|
-
// on the label spans
|
|
72
|
+
// Layout engines in test DOM report zero text metrics; stub a fractional
|
|
73
|
+
// getBoundingClientRect width on the label spans so the widest-wins pair
|
|
74
|
+
// (LinuxDo wider) exercises the ceil+1 headroom instead of the integer
|
|
75
|
+
// scrollWidth that would previously round a fraction and clip the widest.
|
|
74
76
|
const spans = [...wrapper.querySelectorAll<HTMLElement>(".s-auth-methods-label")];
|
|
75
77
|
expect(spans.length).toBe(2);
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
// 61.4 ceil→62 (+1→63) and a shorter 58.0: the published column must
|
|
79
|
+
// exceed BOTH so the widest label can never sit on the clip boundary.
|
|
80
|
+
Object.defineProperty(spans[0]!, "getBoundingClientRect", { configurable: true, value: () => ({ width: 58 }) });
|
|
81
|
+
Object.defineProperty(spans[1]!, "getBoundingClientRect", { configurable: true, value: () => ({ width: 61.4 }) });
|
|
79
82
|
await nextTick();
|
|
80
|
-
const { app } = mounts[mounts.length - 1]!;
|
|
81
83
|
// Re-run measurement via a re-mount-less route: the component exposes
|
|
82
84
|
// measure() — reach it through the rendered component instance.
|
|
83
85
|
const instance = (wrapper as unknown as { __vueParentComponent?: { exposed?: Record<string, unknown> } })
|
|
84
86
|
.__vueParentComponent?.exposed;
|
|
85
87
|
expect(typeof instance?.measure).toBe("function");
|
|
86
88
|
(instance!.measure as () => void)();
|
|
87
|
-
expect(wrapper.style.getPropertyValue("--auth-methods-label-width")).toBe("
|
|
88
|
-
void app;
|
|
89
|
+
expect(wrapper.style.getPropertyValue("--auth-methods-label-width")).toBe("63px");
|
|
89
90
|
});
|
|
90
91
|
|
|
91
92
|
it("leaves the column fallback intact when no text metrics are available", async () => {
|
|
92
93
|
const c = mount(h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, { methods })));
|
|
93
94
|
await nextTick();
|
|
94
95
|
const wrapper = c.querySelector<HTMLElement>(".s-auth-methods-list")!;
|
|
95
|
-
// Zero
|
|
96
|
+
// Zero text metrics (no layout) must not publish a 0px or a 1px column.
|
|
96
97
|
expect(wrapper.style.getPropertyValue("--auth-methods-label-width")).toBe("");
|
|
97
98
|
});
|
|
98
99
|
});
|
|
@@ -12,11 +12,16 @@ import "./HkAuthMethodList.scss";
|
|
|
12
12
|
* then the same width, the row can stay centered like any HkButton
|
|
13
13
|
* without the columns drifting.
|
|
14
14
|
*
|
|
15
|
-
* The label column is auto-sized: after mount (and whenever the label set
|
|
16
|
-
*
|
|
17
|
-
* text and publishes it as
|
|
18
|
-
* so the aligned text region
|
|
19
|
-
*
|
|
15
|
+
* The label column is auto-sized: after mount (and whenever the label set,
|
|
16
|
+
* the document fonts, the browser zoom, or the container size change) the
|
|
17
|
+
* component measures the widest label text and publishes it as
|
|
18
|
+
* `--auth-methods-label-width` on its wrapper, so the aligned text region
|
|
19
|
+
* hugs the longest label instead of a blind hardcoded width. The measured
|
|
20
|
+
* fraction is rounded UP with a 1px margin — the column is always strictly
|
|
21
|
+
* wider than the longest label, so the text can never overflow into an
|
|
22
|
+
* ellipsis. Labels are centered within that shared column, keeping every
|
|
23
|
+
* row's text on the same vertical center line. Consumers can still
|
|
24
|
+
* override the var explicitly.
|
|
20
25
|
*
|
|
21
26
|
* The wrapper is `display: contents`: the component keeps a DOM node for
|
|
22
27
|
* measurement and CSS-var inheritance while the buttons remain direct
|
|
@@ -53,12 +58,25 @@ export default defineComponent({
|
|
|
53
58
|
setup(props, { emit, expose }) {
|
|
54
59
|
const listRef = ref<HTMLDivElement | null>(null);
|
|
55
60
|
|
|
61
|
+
let resizeObserver: ResizeObserver | undefined;
|
|
62
|
+
const onResize = () => void nextTick(measure);
|
|
63
|
+
const onFontsReady = () => measure();
|
|
64
|
+
|
|
56
65
|
/** Measure the widest label text and publish it as the label-column
|
|
57
66
|
* width custom property. Each span is momentarily set to
|
|
58
67
|
* `max-content` while reading: a plain `scrollWidth` on the fixed
|
|
59
68
|
* column would report `max(textWidth, columnWidth)` and the column
|
|
60
69
|
* could never shrink below its fallback. All reads happen inside one
|
|
61
|
-
* synchronous task, so no intermediate paint is observable.
|
|
70
|
+
* synchronous task, so no intermediate paint is observable.
|
|
71
|
+
*
|
|
72
|
+
* Precision: the widest width is read as a fractional
|
|
73
|
+
* `getBoundingClientRect()` (not the integer `scrollWidth`) and
|
|
74
|
+
* rounded UP with a 1px margin before publishing. An integer read can
|
|
75
|
+
* round the longest label DOWN (some engines floor it), pinning its
|
|
76
|
+
* column exactly on the glyph boundary — then any sub-pixel
|
|
77
|
+
* font-metric difference (zoom, DPR, engine) makes that one label
|
|
78
|
+
* overflow and render an ellipsis while the shorter rows stay fine.
|
|
79
|
+
* The headroom keeps the longest label strictly inside its column. */
|
|
62
80
|
function measure() {
|
|
63
81
|
const root = listRef.value;
|
|
64
82
|
if (!root) return;
|
|
@@ -66,22 +84,55 @@ export default defineComponent({
|
|
|
66
84
|
for (const label of root.querySelectorAll<HTMLElement>(".s-auth-methods-label")) {
|
|
67
85
|
const previous = label.style.width;
|
|
68
86
|
label.style.width = "max-content";
|
|
69
|
-
widest = Math.max(widest, label.
|
|
87
|
+
widest = Math.max(widest, label.getBoundingClientRect().width);
|
|
70
88
|
label.style.width = previous;
|
|
71
89
|
}
|
|
72
|
-
|
|
90
|
+
// Guard: only publish when real text metrics were observed, so a
|
|
91
|
+
// hidden / zero-size frame keeps the CSS `max-content` fallback
|
|
92
|
+
// instead of collapsing the column to a clipping 1px.
|
|
93
|
+
if (widest > 0) {
|
|
94
|
+
root.style.setProperty("--auth-methods-label-width", `${Math.ceil(widest) + 1}px`);
|
|
95
|
+
}
|
|
96
|
+
// Keep observing every rendered row so a layout change (the panel
|
|
97
|
+
// becoming visible, a container width change) re-measures. Rows are
|
|
98
|
+
// full-width `.hk-btn-block`, so publishing the column width never
|
|
99
|
+
// resizes them (no feedback loop into the observer).
|
|
100
|
+
if (resizeObserver) {
|
|
101
|
+
for (const button of root.querySelectorAll<HTMLElement>(".hk-btn")) {
|
|
102
|
+
resizeObserver.observe(button);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
73
105
|
}
|
|
74
106
|
|
|
75
107
|
onMounted(() => {
|
|
76
108
|
void nextTick(measure);
|
|
77
109
|
// Webfonts change text metrics after first paint; re-measure when
|
|
78
110
|
// they finish loading (no-op in environments without document.fonts).
|
|
79
|
-
if (typeof document !== "undefined" && document.fonts
|
|
111
|
+
if (typeof document !== "undefined" && document.fonts) {
|
|
80
112
|
void document.fonts.ready.then(measure).catch(() => {});
|
|
113
|
+
document.fonts.addEventListener?.("loadingdone", onFontsReady);
|
|
114
|
+
}
|
|
115
|
+
// Browser zoom and container-width changes reflow the text; without a
|
|
116
|
+
// re-measure the published column goes stale and the longest label
|
|
117
|
+
// overflows into the ellipsis. `resize` covers page zoom; the
|
|
118
|
+
// ResizeObserver covers the panel appearing / the container resizing.
|
|
119
|
+
if (typeof window !== "undefined") {
|
|
120
|
+
window.addEventListener("resize", onResize);
|
|
121
|
+
if (!resizeObserver && typeof ResizeObserver !== "undefined") {
|
|
122
|
+
resizeObserver = new ResizeObserver(onResize);
|
|
123
|
+
}
|
|
81
124
|
}
|
|
82
125
|
});
|
|
83
126
|
onBeforeUnmount(() => {
|
|
84
127
|
listRef.value?.style.removeProperty("--auth-methods-label-width");
|
|
128
|
+
resizeObserver?.disconnect();
|
|
129
|
+
resizeObserver = undefined;
|
|
130
|
+
if (typeof window !== "undefined") {
|
|
131
|
+
window.removeEventListener("resize", onResize);
|
|
132
|
+
}
|
|
133
|
+
if (typeof document !== "undefined" && document.fonts) {
|
|
134
|
+
document.fonts.removeEventListener?.("loadingdone", onFontsReady);
|
|
135
|
+
}
|
|
85
136
|
});
|
|
86
137
|
watch(
|
|
87
138
|
() => props.methods.map((method) => method.label).join("\u0000"),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
@use "./menu-item" as mi;
|
|
1
2
|
// Use theme variables with namespace to avoid conflicts
|
|
2
3
|
@use "./hikari-vars" as vars;
|
|
3
4
|
|
|
@@ -295,44 +296,28 @@
|
|
|
295
296
|
pointer-events: auto;
|
|
296
297
|
}
|
|
297
298
|
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
// Spacing
|
|
306
|
-
padding: 0.5rem 0.75rem;
|
|
307
|
-
margin: 0.125rem 0;
|
|
308
|
-
|
|
309
|
-
// Typography
|
|
310
|
-
font-size: vars.$hikari-font-size-sm;
|
|
311
|
-
color: var(--hi-color-text-primary);
|
|
312
|
-
text-decoration: none;
|
|
313
|
-
white-space: nowrap;
|
|
314
|
-
|
|
315
|
-
// Appearance
|
|
316
|
-
border-radius: vars.$hikari-radius-fui-sm;
|
|
317
|
-
|
|
318
|
-
// Cursor
|
|
319
|
-
cursor: pointer;
|
|
299
|
+
// NOTE: the dropdown ITEM rule lives at TOP LEVEL below, not nested
|
|
300
|
+
// here — @mixin item declares its desktop tokens on the row element,
|
|
301
|
+
// and a deeply nested selector would out-rank the sheet-context token
|
|
302
|
+
// re-declaration in _menu-item.scss (see the comment there).
|
|
303
|
+
}
|
|
304
|
+
}
|
|
320
305
|
|
|
321
|
-
|
|
322
|
-
|
|
306
|
+
// Menu items — unified menu-item spec (./_menu-item.scss): same
|
|
307
|
+
// metrics/states as every other row family. The old hover (full
|
|
308
|
+
// primary fill + double glow) was the library's only remaining
|
|
309
|
+
// bespoke row hover and is retired with the unification. Kept at
|
|
310
|
+
// (0,1,0) so the sheet binding can win inside sheet contexts.
|
|
311
|
+
.hk-breadcrumb-dropdown-item {
|
|
312
|
+
@include mi.item;
|
|
323
313
|
|
|
324
|
-
|
|
314
|
+
// Spacing
|
|
315
|
+
margin: 0.125rem 0;
|
|
325
316
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
box-shadow:
|
|
331
|
-
0 0 12px var(--hi-primary),
|
|
332
|
-
inset 0 0 8px var(--hi-primary);
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
}
|
|
317
|
+
// Typography
|
|
318
|
+
color: var(--hi-color-text-primary);
|
|
319
|
+
text-decoration: none;
|
|
320
|
+
white-space: nowrap;
|
|
336
321
|
}
|
|
337
322
|
|
|
338
323
|
// ------
|
|
@@ -3,49 +3,17 @@
|
|
|
3
3
|
* surface): desktop anchored popouts, mobile bottom sheets with
|
|
4
4
|
* scrim/grabber/title, popup-manager z-stacking, back-guard history.
|
|
5
5
|
* The panel/sheet geometry lives in HkSelect.scss — this file only
|
|
6
|
-
* owns what HkMenu itself brings to that surface: the row grammar
|
|
7
|
-
* the
|
|
6
|
+
* owns what HkMenu itself brings to that surface: the row grammar
|
|
7
|
+
* (the unified menu-item spec — see ./_menu-item.scss), the leading
|
|
8
|
+
* check column, and the sidebar variant's inline nav. */
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
.hk-menu-row {
|
|
11
|
-
display: flex;
|
|
12
|
-
align-items: center;
|
|
13
|
-
justify-content: flex-start;
|
|
14
|
-
gap: 8px;
|
|
15
|
-
width: 100%;
|
|
16
|
-
padding: 7px 10px;
|
|
17
|
-
border: 0;
|
|
18
|
-
border-radius: var(--radius-sm, 4px);
|
|
19
|
-
background: transparent;
|
|
20
|
-
color: rgb(var(--color-text));
|
|
21
|
-
font-size: var(--text-sm);
|
|
22
|
-
line-height: 1.35;
|
|
23
|
-
text-align: left;
|
|
24
|
-
cursor: pointer;
|
|
25
|
-
appearance: none;
|
|
26
|
-
transition: background 0.12s ease;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
.hk-menu-row:hover {
|
|
30
|
-
background: rgb(var(--color-primary) / 10%);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
.hk-menu-row[data-active] {
|
|
34
|
-
background: rgb(var(--color-primary) / 14%);
|
|
35
|
-
font-weight: 600;
|
|
36
|
-
}
|
|
10
|
+
@use "./menu-item" as mi;
|
|
37
11
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
.hk-menu-row[data-danger] {
|
|
44
|
-
color: rgb(var(--color-danger, 240 70 80));
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
.hk-menu-row[data-danger]:hover {
|
|
48
|
-
background: rgb(var(--color-danger, 240 70 80) / 12%);
|
|
12
|
+
/* rows are shared by popout levels and mobile sheet layers; metrics
|
|
13
|
+
* and states come from the unified spec — inside a sheet the shared
|
|
14
|
+
* panel context swaps the tokens to the 44px touch geometry. */
|
|
15
|
+
.hk-menu-row {
|
|
16
|
+
@include mi.item;
|
|
49
17
|
}
|
|
50
18
|
|
|
51
19
|
.hk-menu-flag {
|
|
@@ -55,22 +23,25 @@
|
|
|
55
23
|
}
|
|
56
24
|
|
|
57
25
|
.hk-menu-label {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
26
|
+
@include mi.label;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* Leading icon cell — every row icon renders inside the spec's fixed
|
|
30
|
+
* square, so mixed lucide sizes read as one column and the icon box
|
|
31
|
+
* (not the raw svg) centers against the label. */
|
|
32
|
+
.hk-menu-item-icon {
|
|
33
|
+
@include mi.icon;
|
|
63
34
|
}
|
|
64
35
|
|
|
65
36
|
/* Leading check column (selectMode). The cell is reserved on EVERY row
|
|
66
37
|
* of a level while the column is on, so labels stay aligned; only
|
|
67
|
-
* checked === true shows the mark
|
|
38
|
+
* checked === true shows the mark — an SVG check inside the same fixed
|
|
39
|
+
* cell grammar as leading icons (the old text "✓" carried its own
|
|
40
|
+
* baseline rhythm and sat off-center against svg icons). */
|
|
68
41
|
.hk-menu-check {
|
|
69
|
-
|
|
70
|
-
|
|
42
|
+
@include mi.icon;
|
|
43
|
+
|
|
71
44
|
color: rgb(var(--color-primary));
|
|
72
|
-
font-weight: 700;
|
|
73
|
-
text-align: center;
|
|
74
45
|
}
|
|
75
46
|
|
|
76
47
|
.hk-menu-more {
|
|
@@ -88,14 +59,6 @@
|
|
|
88
59
|
min-width: 0;
|
|
89
60
|
}
|
|
90
61
|
|
|
91
|
-
/* Touch-sized rows inside the shared mobile sheet surface (the same
|
|
92
|
-
* grammar HkSelect applies to its own sheet options). */
|
|
93
|
-
.hk-select-sheet-panel .hk-menu-level .hk-menu-row {
|
|
94
|
-
min-height: 44px;
|
|
95
|
-
padding: 12px 10px;
|
|
96
|
-
font-size: var(--text-base);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
62
|
@media (prefers-reduced-motion: reduce) {
|
|
100
63
|
.hk-menu-row {
|
|
101
64
|
transition: none;
|
|
@@ -104,10 +67,10 @@
|
|
|
104
67
|
|
|
105
68
|
/* ── sidebar variant ────────────────────────────────────────────── */
|
|
106
69
|
/* Inline vertical nav list — always rendered, no popover machinery.
|
|
107
|
-
* Rows share the
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
70
|
+
* Rows share the unified menu-item spec's grammar (metrics + states),
|
|
71
|
+
* with nav-appropriate rhythm: a consistent 4px gap between rows so
|
|
72
|
+
* rounded hover pills never touch, deeper levels indenting under
|
|
73
|
+
* their group. */
|
|
111
74
|
|
|
112
75
|
.hk-menu-sidebar {
|
|
113
76
|
display: flex;
|
|
@@ -117,21 +80,7 @@
|
|
|
117
80
|
}
|
|
118
81
|
|
|
119
82
|
.hk-menu-sidebar-row {
|
|
120
|
-
|
|
121
|
-
align-items: center;
|
|
122
|
-
gap: var(--space-8, 8px);
|
|
123
|
-
width: 100%;
|
|
124
|
-
padding: var(--space-8, 8px) var(--space-12, 12px);
|
|
125
|
-
border: 0;
|
|
126
|
-
border-radius: var(--radius-sm, 4px);
|
|
127
|
-
background: transparent;
|
|
128
|
-
color: rgb(var(--color-text) / 82%);
|
|
129
|
-
font-size: var(--text-sm);
|
|
130
|
-
line-height: 1.4;
|
|
131
|
-
text-align: left;
|
|
132
|
-
cursor: pointer;
|
|
133
|
-
appearance: none;
|
|
134
|
-
transition: background 0.12s ease, color 0.12s ease;
|
|
83
|
+
@include mi.item;
|
|
135
84
|
|
|
136
85
|
.hk-menu-label {
|
|
137
86
|
flex: 1;
|
|
@@ -142,24 +91,8 @@
|
|
|
142
91
|
}
|
|
143
92
|
}
|
|
144
93
|
|
|
145
|
-
.hk-menu-sidebar-row
|
|
146
|
-
|
|
147
|
-
color: rgb(var(--color-text));
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
.hk-menu-sidebar-row[data-active] {
|
|
151
|
-
background: rgb(var(--color-primary) / 14%);
|
|
152
|
-
color: rgb(var(--color-primary));
|
|
153
|
-
font-weight: 600;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
.hk-menu-sidebar-row[data-disabled] {
|
|
157
|
-
opacity: 0.45;
|
|
158
|
-
cursor: not-allowed;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
.hk-menu-sidebar-row[data-danger] {
|
|
162
|
-
color: rgb(var(--color-danger, 240 70 80));
|
|
94
|
+
.hk-menu-sidebar-row .hk-menu-item-icon {
|
|
95
|
+
@include mi.icon;
|
|
163
96
|
}
|
|
164
97
|
|
|
165
98
|
.hk-menu-sidebar-group {
|
|
@@ -352,9 +352,11 @@ describe("HkMenu check column (selectMode)", () => {
|
|
|
352
352
|
mountMenu(ref(true), checkedItems);
|
|
353
353
|
const cells = checkCells();
|
|
354
354
|
expect(cells).toHaveLength(3);
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
expect(cells[
|
|
355
|
+
// The mark is an SVG check inside the fixed icon cell (same grammar
|
|
356
|
+
// as leading row icons) — not a text glyph.
|
|
357
|
+
expect(cells[0].querySelector("svg")).not.toBeNull();
|
|
358
|
+
expect(cells[1].querySelector("svg")).toBeNull();
|
|
359
|
+
expect(cells[2].querySelector("svg")).toBeNull();
|
|
358
360
|
});
|
|
359
361
|
|
|
360
362
|
it("auto shows no column for plain action menus", () => {
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
type VNode,
|
|
11
11
|
} from "vue";
|
|
12
12
|
|
|
13
|
-
import { ChevronRight } from "lucide-vue-next";
|
|
13
|
+
import { Check, ChevronRight } from "lucide-vue-next";
|
|
14
14
|
|
|
15
15
|
import { useBreakpoint } from "../runtime/useBreakpoint";
|
|
16
16
|
import HkSelectPanel, { type SelectPanelPlacement } from "./HkSelectPanel";
|
|
@@ -504,11 +504,13 @@ export default defineComponent({
|
|
|
504
504
|
>
|
|
505
505
|
{reserved && (
|
|
506
506
|
<span class="hk-menu-check" data-on={item.checked || undefined} aria-hidden="true">
|
|
507
|
-
{item.checked ?
|
|
507
|
+
{item.checked ? <Check size={12} /> : null}
|
|
508
508
|
</span>
|
|
509
509
|
)}
|
|
510
510
|
{item.flag && <span class="hk-menu-flag">{item.flag}</span>}
|
|
511
|
-
{item.icon &&
|
|
511
|
+
{item.icon && (
|
|
512
|
+
<span class="hk-menu-item-icon">{h(item.icon, { size: 16 })}</span>
|
|
513
|
+
)}
|
|
512
514
|
<span class="hk-menu-label">{item.label}</span>
|
|
513
515
|
{hasKids && <ChevronRight size={14} class="hk-menu-more" />}
|
|
514
516
|
</button>
|
|
@@ -708,7 +710,9 @@ export default defineComponent({
|
|
|
708
710
|
emit("select", item.key, item);
|
|
709
711
|
}}
|
|
710
712
|
>
|
|
711
|
-
{item.icon &&
|
|
713
|
+
{item.icon && (
|
|
714
|
+
<span class="hk-menu-item-icon">{h(item.icon, { size: 16 })}</span>
|
|
715
|
+
)}
|
|
712
716
|
{item.flag && <span class="hk-menu-flag">{item.flag}</span>}
|
|
713
717
|
<span class="hk-menu-label">{item.label}</span>
|
|
714
718
|
{item.badge && <span class="hk-menu-sidebar-badge">{item.badge}</span>}
|
|
@@ -734,7 +738,9 @@ export default defineComponent({
|
|
|
734
738
|
toggleGroup(item.key);
|
|
735
739
|
}}
|
|
736
740
|
>
|
|
737
|
-
{item.icon &&
|
|
741
|
+
{item.icon && (
|
|
742
|
+
<span class="hk-menu-item-icon">{h(item.icon, { size: 16 })}</span>
|
|
743
|
+
)}
|
|
738
744
|
<span class="hk-menu-label">{item.label}</span>
|
|
739
745
|
{item.badge && <span class="hk-menu-sidebar-badge">{item.badge}</span>}
|
|
740
746
|
<ChevronRight size={14} class="hk-menu-more" />
|
|
@@ -1,58 +1,20 @@
|
|
|
1
1
|
/* ── Standard menu action row (HkMenuActionItem) ────────────────
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* as one list with
|
|
2
|
+
* Speaks the unified menu-item spec (see ./_menu-item.scss) — same
|
|
3
|
+
* metrics, icon cell, states and focus ring as every other row, so a
|
|
4
|
+
* menu panel of these rows reads as one list with HkMenu rows and
|
|
5
|
+
* select options around it. Danger rides the spec's [data-danger]
|
|
6
|
+
* state (the tsx keeps the legacy `--danger` class for compat). */
|
|
5
7
|
|
|
6
|
-
|
|
7
|
-
display: flex;
|
|
8
|
-
align-items: center;
|
|
9
|
-
gap: var(--space-8, 8px);
|
|
10
|
-
width: 100%;
|
|
11
|
-
padding: var(--space-8, 8px) var(--space-12, 12px);
|
|
12
|
-
border: 0;
|
|
13
|
-
border-radius: var(--radius-sm, 4px);
|
|
14
|
-
background: transparent;
|
|
15
|
-
color: rgb(var(--color-text) / 82%);
|
|
16
|
-
font-size: var(--text-sm);
|
|
17
|
-
font-weight: 500;
|
|
18
|
-
font-family: inherit;
|
|
19
|
-
line-height: 1.4;
|
|
20
|
-
text-align: left;
|
|
21
|
-
cursor: pointer;
|
|
22
|
-
appearance: none;
|
|
23
|
-
transition: background 0.12s ease, color 0.12s ease;
|
|
24
|
-
|
|
25
|
-
&:hover:not(:disabled) {
|
|
26
|
-
background: var(--c-primary-subtle, rgb(var(--color-primary) / 8%));
|
|
27
|
-
color: rgb(var(--color-text));
|
|
28
|
-
}
|
|
8
|
+
@use "./menu-item" as mi;
|
|
29
9
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
cursor: not-allowed;
|
|
33
|
-
}
|
|
10
|
+
.hk-menu-action-item {
|
|
11
|
+
@include mi.item;
|
|
34
12
|
}
|
|
35
13
|
|
|
36
14
|
.hk-menu-action-item__icon {
|
|
37
|
-
|
|
38
|
-
align-items: center;
|
|
39
|
-
justify-content: center;
|
|
40
|
-
flex-shrink: 0;
|
|
15
|
+
@include mi.icon;
|
|
41
16
|
}
|
|
42
17
|
|
|
43
18
|
.hk-menu-action-item__label {
|
|
44
|
-
|
|
45
|
-
min-width: 0;
|
|
46
|
-
overflow: hidden;
|
|
47
|
-
text-overflow: ellipsis;
|
|
48
|
-
white-space: nowrap;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
.hk-menu-action-item--danger {
|
|
52
|
-
color: rgb(var(--color-error, 200 50 50) / 90%);
|
|
53
|
-
|
|
54
|
-
&:hover:not(:disabled) {
|
|
55
|
-
background: rgb(var(--color-error, 200 50 50) / 10%);
|
|
56
|
-
color: rgb(var(--color-error, 200 50 50));
|
|
57
|
-
}
|
|
19
|
+
@include mi.label;
|
|
58
20
|
}
|
|
@@ -2,18 +2,20 @@ import { defineComponent } from "vue";
|
|
|
2
2
|
import "./HkMenuActionItem.scss";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Standard menu row: icon + label button
|
|
6
|
-
*
|
|
7
|
-
* as a bare button, so it works inside HkMenuPanel, a popover,
|
|
8
|
-
* other menu host; attrs (including a template `ref`) fall
|
|
9
|
-
* the button element.
|
|
5
|
+
* Standard menu row: icon + label button speaking the unified menu-item
|
|
6
|
+
* spec (metrics/icon cell/states — see _menu-item.scss). Pluggable —
|
|
7
|
+
* renders as a bare button, so it works inside HkMenuPanel, a popover,
|
|
8
|
+
* or any other menu host; attrs (including a template `ref`) fall
|
|
9
|
+
* through to the button element.
|
|
10
10
|
*/
|
|
11
11
|
export default defineComponent({
|
|
12
12
|
name: "HkMenuActionItem",
|
|
13
13
|
props: {
|
|
14
|
-
/** Leading icon node (e.g. `<Camera size={14} />`).
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
/** Leading icon node (e.g. `<Camera size={14} />`). Rendered inside
|
|
15
|
+
* the spec's fixed icon cell, so any size centers uniformly. Typed
|
|
16
|
+
* loosely (plain Object) so consumers building on a different vue
|
|
17
|
+
* copy than hikari's own node_modules never hit VNode type identity
|
|
18
|
+
* errors. */
|
|
17
19
|
icon: { type: Object, default: undefined },
|
|
18
20
|
label: { type: String, required: true },
|
|
19
21
|
/** Destructive styling (logout, delete…). */
|
|
@@ -28,6 +30,7 @@ export default defineComponent({
|
|
|
28
30
|
<button
|
|
29
31
|
type="button"
|
|
30
32
|
class={["hk-menu-action-item", props.danger ? "hk-menu-action-item--danger" : ""]}
|
|
33
|
+
data-danger={props.danger || undefined}
|
|
31
34
|
disabled={props.disabled}
|
|
32
35
|
role="menuitem"
|
|
33
36
|
onClick={(e: MouseEvent) => emit("click", e)}
|
|
@@ -1,16 +1,28 @@
|
|
|
1
|
+
/* HkNavItem — nav row. Speaks the unified menu-item spec's METRICS
|
|
2
|
+
* (min-height / padding / gap / font / radius / icon cell — see
|
|
3
|
+
* ./_menu-item.scss) so nav rows, menu rows, select options and theme
|
|
4
|
+
* pills share one box geometry. Colors stay on the --hi-* token family:
|
|
5
|
+
* nav rows are consumed by hosts that re-theme via --hi-*, so the
|
|
6
|
+
* interaction palette deliberately does not switch to --c-*. */
|
|
7
|
+
|
|
8
|
+
@use "./menu-item" as mi;
|
|
9
|
+
|
|
1
10
|
.hk-nav-item {
|
|
11
|
+
@include mi.item-tokens;
|
|
12
|
+
|
|
2
13
|
display: flex;
|
|
3
14
|
align-items: center;
|
|
4
|
-
gap:
|
|
5
|
-
|
|
6
|
-
|
|
15
|
+
gap: var(--hk-menu-item-gap);
|
|
16
|
+
min-height: var(--hk-menu-item-min-h);
|
|
17
|
+
padding: var(--hk-menu-item-pad-y) var(--hk-menu-item-pad-x);
|
|
18
|
+
border-radius: var(--hk-menu-item-radius);
|
|
7
19
|
color: var(--hi-color-muted);
|
|
8
|
-
font-size:
|
|
20
|
+
font-size: var(--hk-menu-item-font);
|
|
9
21
|
font-family: inherit;
|
|
10
|
-
line-height:
|
|
22
|
+
line-height: var(--hk-menu-item-lh);
|
|
11
23
|
text-decoration: none;
|
|
12
24
|
background: none;
|
|
13
|
-
border:
|
|
25
|
+
border: 1px solid transparent;
|
|
14
26
|
width: 100%;
|
|
15
27
|
text-align: left;
|
|
16
28
|
cursor: pointer;
|
|
@@ -64,12 +76,7 @@
|
|
|
64
76
|
}
|
|
65
77
|
|
|
66
78
|
.hk-nav-item-icon {
|
|
67
|
-
|
|
68
|
-
align-items: center;
|
|
69
|
-
justify-content: center;
|
|
70
|
-
flex-shrink: 0;
|
|
71
|
-
width: 1rem;
|
|
72
|
-
height: 1rem;
|
|
79
|
+
@include mi.icon;
|
|
73
80
|
}
|
|
74
81
|
|
|
75
82
|
.hk-nav-item-label {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
@use "./menu-item" as mi;
|
|
2
|
+
|
|
1
3
|
.hk-popover-backdrop {
|
|
2
4
|
position: fixed;
|
|
3
5
|
inset: 0;
|
|
@@ -54,35 +56,13 @@
|
|
|
54
56
|
padding: var(--space-4, 12px);
|
|
55
57
|
}
|
|
56
58
|
|
|
57
|
-
/* Locale picker menu items */
|
|
59
|
+
/* Locale picker menu items — unified menu-item spec (./_menu-item.scss). */
|
|
58
60
|
.hk-popup-menu-item {
|
|
59
|
-
|
|
60
|
-
align-items: center;
|
|
61
|
-
gap: var(--space-8);
|
|
62
|
-
padding: var(--space-8) var(--space-14);
|
|
63
|
-
font-size: var(--text-sm);
|
|
64
|
-
font-weight: 500;
|
|
65
|
-
font-family: inherit;
|
|
66
|
-
color: rgb(var(--color-text));
|
|
67
|
-
cursor: pointer;
|
|
68
|
-
transition: background var(--duration-normal) ease;
|
|
69
|
-
border: none;
|
|
70
|
-
background: none;
|
|
71
|
-
width: 100%;
|
|
72
|
-
text-align: left;
|
|
73
|
-
outline: none;
|
|
74
|
-
border-radius: var(--radius-sm);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/* Hover = background wash only, never on the active item (active >
|
|
78
|
-
hover precedence — see HkNavItem.scss for the spec). */
|
|
79
|
-
.hk-popup-menu-item:hover:not([data-active]) {
|
|
80
|
-
background: var(--c-primary-subtle);
|
|
61
|
+
@include mi.item;
|
|
81
62
|
}
|
|
82
63
|
|
|
83
|
-
.hk-popup-menu-item[data-active] {
|
|
84
|
-
|
|
85
|
-
font-weight: 600;
|
|
64
|
+
.hk-popup-menu-item[data-active]:hover {
|
|
65
|
+
background: var(--c-primary-dim, rgb(var(--color-primary) / 10%));
|
|
86
66
|
}
|
|
87
67
|
|
|
88
68
|
.hk-locale-submenu {
|
|
@@ -173,7 +153,7 @@
|
|
|
173
153
|
// slightly letterspaced, inset to the content edge. Hosts tune the
|
|
174
154
|
// leading inset via --hk-sheet-title-inset.
|
|
175
155
|
padding: 0 var(--space-16, 1rem) var(--space-8, 0.5rem)
|
|
176
|
-
var(--hk-sheet-title-inset, var(--space-16, 1rem));
|
|
156
|
+
var(--hk-sheet-title-inset, calc(var(--space-16, 1rem) * 2));
|
|
177
157
|
font-size: var(--text-xs, 0.75rem);
|
|
178
158
|
font-weight: 600;
|
|
179
159
|
letter-spacing: 0.02em;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
@use "../tokens";
|
|
2
|
+
@use "./menu-item" as mi;
|
|
2
3
|
|
|
3
4
|
/* HkPopupSelect */
|
|
4
5
|
|
|
@@ -108,32 +109,18 @@
|
|
|
108
109
|
padding: var(--space-2);
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
/* Unified menu-item spec (see ./_menu-item.scss); sheet geometry flows
|
|
113
|
+
* from the shared `.hk-popover-panel.hk-is-sheet` token context. */
|
|
111
114
|
.hk-popup-select-item {
|
|
112
|
-
|
|
113
|
-
align-items: center;
|
|
114
|
-
gap: var(--space-8);
|
|
115
|
-
padding: var(--space-10) var(--space-16);
|
|
116
|
-
font-size: var(--text-base);
|
|
117
|
-
color: rgb(var(--color-text));
|
|
118
|
-
border: 1px solid transparent;
|
|
119
|
-
border-radius: var(--radius-md);
|
|
120
|
-
cursor: pointer;
|
|
121
|
-
outline: none;
|
|
122
|
-
transition: background-color, border-color, color, box-shadow, opacity, transform, filter var(--duration-normal) ease;
|
|
123
|
-
text-align: left;
|
|
115
|
+
@include mi.item;
|
|
124
116
|
}
|
|
125
117
|
|
|
126
|
-
.
|
|
127
|
-
.hk-popup-select-item[data-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
.hk-popup-select-item[data-checked] {
|
|
133
|
-
background: var(--c-primary-dim);
|
|
134
|
-
border-color: var(--c-primary-strong);
|
|
135
|
-
color: rgb(var(--color-primary));
|
|
136
|
-
font-weight: 600;
|
|
118
|
+
/* Highlight face yields to checked/active (see HkSelect.scss). */
|
|
119
|
+
.hk-popup-select-item:hover:not([data-checked]):not([data-active]),
|
|
120
|
+
.hk-popup-select-item[data-highlighted]:not([data-checked]):not([data-active]) {
|
|
121
|
+
background: var(--c-primary-subtle, rgb(var(--color-primary) / 8%));
|
|
122
|
+
border-color: var(--c-primary-medium, rgb(var(--color-primary) / 25%));
|
|
123
|
+
color: rgb(var(--color-text));
|
|
137
124
|
}
|
|
138
125
|
|
|
139
126
|
.hk-popup-select-error {
|
|
@@ -143,14 +130,9 @@
|
|
|
143
130
|
}
|
|
144
131
|
|
|
145
132
|
// Mobile sheet form (HPopover sheetOnMobile): full-width option list with
|
|
146
|
-
// touch-sized rows.
|
|
133
|
+
// touch-sized rows (row geometry flows from the shared sheet tokens).
|
|
147
134
|
.hk-popover-panel.hk-is-sheet.hk-popup-select-content {
|
|
148
135
|
.hk-popup-select-viewport {
|
|
149
136
|
max-height: min(56vh, 24rem);
|
|
150
137
|
}
|
|
151
|
-
|
|
152
|
-
.hk-popup-select-item {
|
|
153
|
-
padding: var(--space-14) var(--space-16);
|
|
154
|
-
font-size: var(--text-base);
|
|
155
|
-
}
|
|
156
138
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
@use "../tokens";
|
|
2
|
+
@use "./menu-item" as mi;
|
|
2
3
|
|
|
3
4
|
/* HkSelect — popup select component */
|
|
4
5
|
|
|
@@ -198,37 +199,26 @@
|
|
|
198
199
|
}
|
|
199
200
|
}
|
|
200
201
|
|
|
202
|
+
/* Unified menu-item spec (see ./_menu-item.scss) — options, menu rows
|
|
203
|
+
* and theme pills share one grammar; sheet geometry flows from the
|
|
204
|
+
* shared sheet token context, so the old per-component sheet bump is
|
|
205
|
+
* gone. [data-highlighted] (keyboard/pointer highlight) maps to the
|
|
206
|
+
* spec's hover face. */
|
|
201
207
|
.hk-select-option {
|
|
202
|
-
|
|
203
|
-
align-items: center;
|
|
204
|
-
gap: var(--space-8);
|
|
205
|
-
padding: var(--space-10) var(--space-16);
|
|
206
|
-
font-size: var(--text-base);
|
|
207
|
-
color: rgb(var(--color-text));
|
|
208
|
-
border: 1px solid transparent;
|
|
209
|
-
border-radius: var(--radius-md);
|
|
210
|
-
cursor: pointer;
|
|
211
|
-
outline: none;
|
|
212
|
-
transition: background-color, border-color, color, box-shadow, opacity, transform, filter var(--duration-normal) ease;
|
|
213
|
-
text-align: left;
|
|
208
|
+
@include mi.item;
|
|
214
209
|
}
|
|
215
210
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
211
|
+
/* The highlight face never overrides the checked/active face — a
|
|
212
|
+
* keyboard-highlighted selected option keeps its dim wash + primary
|
|
213
|
+
* text (active > hover precedence, same as the spec mixin). */
|
|
214
|
+
.hk-select-option:hover:not([data-checked]):not([data-active]),
|
|
215
|
+
.hk-select-option[data-highlighted]:not([data-checked]):not([data-active]) {
|
|
216
|
+
background: var(--c-primary-subtle, rgb(var(--color-primary) / 8%));
|
|
217
|
+
border-color: var(--c-primary-medium, rgb(var(--color-primary) / 25%));
|
|
220
218
|
}
|
|
221
219
|
|
|
222
|
-
.hk-select-option[data-checked] {
|
|
223
|
-
|
|
224
|
-
border-color: var(--c-primary-strong);
|
|
225
|
-
color: rgb(var(--color-primary));
|
|
226
|
-
font-weight: 600;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
.hk-select-option[data-disabled] {
|
|
230
|
-
opacity: 0.4;
|
|
231
|
-
cursor: not-allowed;
|
|
220
|
+
.hk-select-option[data-highlighted]:not(:hover):not([data-checked]):not([data-active]) {
|
|
221
|
+
color: rgb(var(--color-text));
|
|
232
222
|
}
|
|
233
223
|
|
|
234
224
|
.hk-select-error {
|
|
@@ -353,11 +343,6 @@
|
|
|
353
343
|
&::-webkit-scrollbar {
|
|
354
344
|
display: none;
|
|
355
345
|
}
|
|
356
|
-
|
|
357
|
-
.hk-select-option {
|
|
358
|
-
padding: var(--space-14) var(--space-16);
|
|
359
|
-
font-size: var(--text-base);
|
|
360
|
-
}
|
|
361
346
|
}
|
|
362
347
|
|
|
363
348
|
/* Leading in-content heading hidden by the sheet duplicate-title
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
@use "./menu-item" as mi;
|
|
2
|
+
|
|
1
3
|
.s-theme-toggle {
|
|
2
4
|
display: inline-flex;
|
|
3
5
|
align-items: center;
|
|
@@ -121,6 +123,12 @@
|
|
|
121
123
|
* invisible slot left a permanent dead strip right of every row in the
|
|
122
124
|
* mobile bottom sheet — and every pill and highlight still shares the
|
|
123
125
|
* exact same width.
|
|
126
|
+
*
|
|
127
|
+
* Metrics/states come from the unified menu-item spec (./_menu-item.scss)
|
|
128
|
+
* — the same grammar as menu rows and select options; inside the mobile
|
|
129
|
+
* sheet the shared `.hk-popover-panel.hk-is-sheet` token context applies
|
|
130
|
+
* the 44px touch geometry, so the old component-local media bump is gone
|
|
131
|
+
* (only the overlaid delete button keeps its own touch sizing below).
|
|
124
132
|
*/
|
|
125
133
|
|
|
126
134
|
.s-theme-item-row {
|
|
@@ -136,30 +144,20 @@
|
|
|
136
144
|
* bespoke 6×8 tight rows (small text, borderless 12% wash) are what
|
|
137
145
|
* made the theme sheet read as "not from the standard dropdown". */
|
|
138
146
|
.s-theme-item-btn {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
gap: var(--space-8, 0.5rem);
|
|
147
|
+
@include mi.item;
|
|
148
|
+
|
|
142
149
|
min-width: 0;
|
|
143
150
|
flex: 1;
|
|
144
|
-
|
|
145
|
-
border: 1px solid transparent;
|
|
146
|
-
border-radius: var(--radius-md, 8px);
|
|
147
|
-
background: transparent;
|
|
148
|
-
color: rgb(var(--color-text));
|
|
149
|
-
font-size: var(--text-base, 0.875rem);
|
|
150
|
-
line-height: 1.35;
|
|
151
|
-
text-align: left;
|
|
152
|
-
cursor: pointer;
|
|
153
|
-
appearance: none;
|
|
154
|
-
transition: background-color 0.12s ease, border-color 0.12s ease;
|
|
151
|
+
width: auto;
|
|
155
152
|
}
|
|
156
153
|
|
|
157
154
|
.s-theme-item-name {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
155
|
+
@include mi.label;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* Leading check / customize glyphs share the spec's fixed icon cell. */
|
|
159
|
+
.s-theme-item-btn .hk-menu-item-icon {
|
|
160
|
+
@include mi.icon;
|
|
163
161
|
}
|
|
164
162
|
|
|
165
163
|
/* Custom rows keep the name clear of the overlaid delete button. */
|
|
@@ -173,14 +171,14 @@
|
|
|
173
171
|
}
|
|
174
172
|
|
|
175
173
|
.s-theme-item-btn:hover:not([data-active]) {
|
|
176
|
-
background: var(--c-primary-subtle);
|
|
177
|
-
border-color: var(--c-primary-medium);
|
|
174
|
+
background: var(--c-primary-subtle, rgb(var(--color-primary) / 8%));
|
|
175
|
+
border-color: var(--c-primary-medium, rgb(var(--color-primary) / 25%));
|
|
178
176
|
color: rgb(var(--color-text));
|
|
179
177
|
}
|
|
180
178
|
|
|
181
179
|
.s-theme-item-btn[data-active] {
|
|
182
|
-
background: var(--c-primary-dim);
|
|
183
|
-
border-color: var(--c-primary-strong);
|
|
180
|
+
background: var(--c-primary-dim, rgb(var(--color-primary) / 10%));
|
|
181
|
+
border-color: var(--c-primary-strong, rgb(var(--color-primary) / 40%));
|
|
184
182
|
color: rgb(var(--color-primary));
|
|
185
183
|
font-weight: 600;
|
|
186
184
|
}
|
|
@@ -210,17 +208,11 @@
|
|
|
210
208
|
color: rgb(var(--color-error));
|
|
211
209
|
}
|
|
212
210
|
|
|
213
|
-
/* Touch-sized
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
* follows so the two never collide on long custom scheme names. */
|
|
211
|
+
/* Touch-sized delete target on phone widths, scaled for the 44px sheet
|
|
212
|
+
* rows that the shared spec applies inside sheet contexts (see
|
|
213
|
+
* ./_menu-item.scss). The name clearance follows so the button and the
|
|
214
|
+
* label never collide on long custom scheme names. */
|
|
218
215
|
@media (max-width: 767px) {
|
|
219
|
-
.s-theme-item-btn {
|
|
220
|
-
min-height: 44px;
|
|
221
|
-
padding: var(--space-14, 0.875rem) var(--space-16, 1rem);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
216
|
.s-theme-item-delete {
|
|
225
217
|
width: 2.25rem;
|
|
226
218
|
height: 2.25rem;
|
|
@@ -241,7 +241,9 @@ export const HkThemeToggle = defineComponent({
|
|
|
241
241
|
data-active={currentTheme.value === th.id || undefined}
|
|
242
242
|
onClick={() => onSelectTheme(th.id)}
|
|
243
243
|
>
|
|
244
|
-
{currentTheme.value === th.id &&
|
|
244
|
+
{currentTheme.value === th.id && (
|
|
245
|
+
<span class="hk-menu-item-icon s-theme-item-check"><Check size={14} /></span>
|
|
246
|
+
)}
|
|
245
247
|
<span class="s-theme-item-name">{th.name}</span>
|
|
246
248
|
</button>
|
|
247
249
|
{th.isCustom && (
|
|
@@ -269,7 +271,7 @@ export const HkThemeToggle = defineComponent({
|
|
|
269
271
|
}
|
|
270
272
|
}}
|
|
271
273
|
>
|
|
272
|
-
<Palette size={14}
|
|
274
|
+
<span class="hk-menu-item-icon"><Palette size={14} /></span>
|
|
273
275
|
<span class="s-theme-item-name">{t("hikari::theme.customize")}</span>
|
|
274
276
|
</button>
|
|
275
277
|
</div>
|
|
@@ -273,8 +273,10 @@
|
|
|
273
273
|
}
|
|
274
274
|
|
|
275
275
|
.s-jt-row {
|
|
276
|
+
/* Baseline alignment keeps the small type hints (boolean/number/string)
|
|
277
|
+
on the value's text baseline instead of floating above it. */
|
|
276
278
|
display: flex;
|
|
277
|
-
align-items:
|
|
279
|
+
align-items: baseline;
|
|
278
280
|
min-height: 1.55em;
|
|
279
281
|
padding: 0 var(--space-6, 0.375rem);
|
|
280
282
|
border-radius: var(--radius-xs, 2px);
|
|
@@ -297,6 +299,10 @@
|
|
|
297
299
|
height: var(--jt-toggle);
|
|
298
300
|
flex-shrink: 0;
|
|
299
301
|
margin-right: 2px;
|
|
302
|
+
/* The toggle box has no text baseline of its own — pin it to the line
|
|
303
|
+
top instead of letting it join the row's baseline group with a
|
|
304
|
+
synthesized border-box baseline. */
|
|
305
|
+
align-self: flex-start;
|
|
300
306
|
}
|
|
301
307
|
|
|
302
308
|
.s-jt-toggle[data-parent] {
|
|
@@ -368,9 +374,8 @@
|
|
|
368
374
|
color: rgb(var(--color-muted) / 0.6);
|
|
369
375
|
font-size: var(--text-3xs, 0.5rem);
|
|
370
376
|
font-style: italic;
|
|
371
|
-
margin-left: 0.
|
|
377
|
+
margin-left: 0.75em;
|
|
372
378
|
font-weight: 400;
|
|
373
|
-
align-self: center;
|
|
374
379
|
}
|
|
375
380
|
|
|
376
381
|
.s-jt-badge {
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/* ── Unified menu-item grammar (the ONE row spec) ────────────────────
|
|
2
|
+
*
|
|
3
|
+
* Every selectable list row the library renders — cascade menu rows,
|
|
4
|
+
* action rows, select options, popup-select items, theme pills, admin
|
|
5
|
+
* header rows, nav rows, breadcrumb dropdown items — shares this exact
|
|
6
|
+
* grammar. Desktop (anchored popouts) and mobile (bottom sheets) are
|
|
7
|
+
* the same spec at two token sets; a sheet context merely swaps the
|
|
8
|
+
* tokens, so every row inside ANY sheet gets the touch geometry
|
|
9
|
+
* without per-component media queries.
|
|
10
|
+
*
|
|
11
|
+
* Metric contract (desktop / sheet):
|
|
12
|
+
* min-height 34px / 44px icon cell 16px / 20px
|
|
13
|
+
* padding 6×12px / 10×16px font 13px / 14px
|
|
14
|
+
* gap 8px · radius --radius-md · weight 500 · line-height 1.3
|
|
15
|
+
*
|
|
16
|
+
* Interaction contract:
|
|
17
|
+
* - hover: subtle primary wash + medium border (guarded — never
|
|
18
|
+
* fights the active/checked state, never changes layout: the 1px
|
|
19
|
+
* border is always reserved).
|
|
20
|
+
* - active/checked: dim primary wash + strong border + primary text
|
|
21
|
+
* + 600 (active > hover precedence).
|
|
22
|
+
* - checked mark: an SVG check inside the reserved cell, normalized
|
|
23
|
+
* to the cell like every other icon (16px / 20px in sheets).
|
|
24
|
+
* - danger rows tint red; disabled rows dim to 45%.
|
|
25
|
+
* - focus-visible: 2px inset primary ring (keyboard parity for every
|
|
26
|
+
* row family — previously only HkNavItem had one).
|
|
27
|
+
* - label carries the optical shift: small CJK rasterization biases
|
|
28
|
+
* glyph ink high against box-centered icons; a half-pixel down
|
|
29
|
+
* nudge re-centers the perceived mass (tunable per host).
|
|
30
|
+
*
|
|
31
|
+
* Adoption: `@use "./menu-item" as mi;` then
|
|
32
|
+
* .your-row { @include mi.item; }
|
|
33
|
+
* .your-row .your-icon { @include mi.icon; } // wrap TSX side!
|
|
34
|
+
* .your-row .your-label { @include mi.label; }
|
|
35
|
+
* Keep the component's own class names — this file owns metrics and
|
|
36
|
+
* states only, never selectors of the adopting rows.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/* ── Tokens ─────────────────────────────────────────────────────────── */
|
|
40
|
+
|
|
41
|
+
@mixin item-tokens {
|
|
42
|
+
--hk-menu-item-min-h: 34px;
|
|
43
|
+
--hk-menu-item-pad-x: 12px;
|
|
44
|
+
--hk-menu-item-pad-y: 6px;
|
|
45
|
+
--hk-menu-item-gap: 8px;
|
|
46
|
+
--hk-menu-item-font: var(--text-sm, 0.8125rem);
|
|
47
|
+
--hk-menu-item-weight: 500;
|
|
48
|
+
--hk-menu-item-lh: 1.3;
|
|
49
|
+
--hk-menu-item-radius: var(--radius-md, 8px);
|
|
50
|
+
--hk-menu-item-icon-box: 16px;
|
|
51
|
+
/* Label optical shift — see header comment. */
|
|
52
|
+
--hk-menu-item-optical: 0.5px;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* Mobile bottom-sheet geometry. */
|
|
56
|
+
@mixin item-sheet-tokens {
|
|
57
|
+
--hk-menu-item-min-h: 44px;
|
|
58
|
+
--hk-menu-item-pad-x: 16px;
|
|
59
|
+
--hk-menu-item-pad-y: 10px;
|
|
60
|
+
--hk-menu-item-font: var(--text-base, 0.875rem);
|
|
61
|
+
--hk-menu-item-icon-box: 20px;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* Sheet context. The tokens are re-declared ON each adopting row, not
|
|
65
|
+
* merely on the panel: a custom property declared on an element always
|
|
66
|
+
* beats an inherited value, and @mixin item declares the desktop set at
|
|
67
|
+
* row level — an ancestor-only binding would never win. The (0,2,0)
|
|
68
|
+
* descendant selector here beats the row's own (0,1,0) declaration.
|
|
69
|
+
* Therefore every adopter MUST @include mi.item at a TOP-LEVEL (0,1,0)
|
|
70
|
+
* rule — a nested include (e.g. inside its parent menu selector)
|
|
71
|
+
* compiles to a higher specificity and silently defeats this binding
|
|
72
|
+
* (HkBreadcrumb was caught exactly this way). New row families must
|
|
73
|
+
* also register their class in this list. */
|
|
74
|
+
.hk-select-sheet-panel,
|
|
75
|
+
.hk-popover-panel.hk-is-sheet,
|
|
76
|
+
.hk-drawer-panel {
|
|
77
|
+
.hk-menu-row,
|
|
78
|
+
.hk-menu-sidebar-row,
|
|
79
|
+
.hk-menu-action-item,
|
|
80
|
+
.hk-select-option,
|
|
81
|
+
.hk-popup-select-item,
|
|
82
|
+
.s-theme-item-btn,
|
|
83
|
+
.s-popup-menu-item,
|
|
84
|
+
.hk-popup-menu-item,
|
|
85
|
+
.hk-breadcrumb-dropdown-item {
|
|
86
|
+
@include item-sheet-tokens;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* ── Row grammar ────────────────────────────────────────────────────── */
|
|
91
|
+
|
|
92
|
+
@mixin item {
|
|
93
|
+
@include item-tokens;
|
|
94
|
+
display: flex;
|
|
95
|
+
align-items: center;
|
|
96
|
+
justify-content: flex-start;
|
|
97
|
+
gap: var(--hk-menu-item-gap);
|
|
98
|
+
width: 100%;
|
|
99
|
+
min-height: var(--hk-menu-item-min-h);
|
|
100
|
+
padding: var(--hk-menu-item-pad-y) var(--hk-menu-item-pad-x);
|
|
101
|
+
border: 1px solid transparent;
|
|
102
|
+
border-radius: var(--hk-menu-item-radius);
|
|
103
|
+
background: transparent;
|
|
104
|
+
color: rgb(var(--color-text) / 88%);
|
|
105
|
+
font-size: var(--hk-menu-item-font);
|
|
106
|
+
font-weight: var(--hk-menu-item-weight);
|
|
107
|
+
font-family: inherit;
|
|
108
|
+
line-height: var(--hk-menu-item-lh);
|
|
109
|
+
text-align: left;
|
|
110
|
+
cursor: pointer;
|
|
111
|
+
appearance: none;
|
|
112
|
+
user-select: none;
|
|
113
|
+
-webkit-user-select: none;
|
|
114
|
+
-webkit-tap-highlight-color: transparent;
|
|
115
|
+
transition: background-color 0.12s ease, border-color 0.12s ease, color 0.12s ease;
|
|
116
|
+
|
|
117
|
+
&:hover:not(:disabled):not([data-disabled]):not([data-active]):not([data-checked]) {
|
|
118
|
+
background: var(--c-primary-subtle, rgb(var(--color-primary) / 8%));
|
|
119
|
+
border-color: var(--c-primary-medium, rgb(var(--color-primary) / 25%));
|
|
120
|
+
color: rgb(var(--color-text));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
&:focus-visible {
|
|
124
|
+
outline: 2px solid rgb(var(--color-primary) / 70%);
|
|
125
|
+
outline-offset: -2px;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
&[data-active],
|
|
129
|
+
&[data-checked] {
|
|
130
|
+
background: var(--c-primary-dim, rgb(var(--color-primary) / 10%));
|
|
131
|
+
border-color: var(--c-primary-strong, rgb(var(--color-primary) / 40%));
|
|
132
|
+
color: rgb(var(--color-primary));
|
|
133
|
+
font-weight: 600;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
&[data-disabled],
|
|
137
|
+
&:disabled {
|
|
138
|
+
opacity: 0.45;
|
|
139
|
+
cursor: not-allowed;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
&[data-danger] {
|
|
143
|
+
color: rgb(var(--color-danger, 240 70 80) / 90%);
|
|
144
|
+
|
|
145
|
+
&:hover:not(:disabled):not([data-disabled]):not([data-active]) {
|
|
146
|
+
background: rgb(var(--color-danger, 240 70 80) / 10%);
|
|
147
|
+
border-color: rgb(var(--color-danger, 240 70 80) / 25%);
|
|
148
|
+
color: rgb(var(--color-danger, 240 70 80));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* ── Leading icon cell ────────────────────────────────────────────────
|
|
154
|
+
* Fixed square; any icon size the consumer passes is centered and
|
|
155
|
+
* normalized to the cell, so lucide 13/14/15/16px imports all read as
|
|
156
|
+
* one column. Icon inherits the row color (danger rows tint red). */
|
|
157
|
+
@mixin icon {
|
|
158
|
+
display: inline-flex;
|
|
159
|
+
align-items: center;
|
|
160
|
+
justify-content: center;
|
|
161
|
+
flex: none;
|
|
162
|
+
width: var(--hk-menu-item-icon-box);
|
|
163
|
+
height: var(--hk-menu-item-icon-box);
|
|
164
|
+
|
|
165
|
+
> svg {
|
|
166
|
+
width: 100%;
|
|
167
|
+
height: 100%;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/* ── Label ──────────────────────────────────────────────────────────── */
|
|
172
|
+
@mixin label {
|
|
173
|
+
flex: 1;
|
|
174
|
+
min-width: 0;
|
|
175
|
+
overflow: hidden;
|
|
176
|
+
text-overflow: ellipsis;
|
|
177
|
+
white-space: nowrap;
|
|
178
|
+
position: relative;
|
|
179
|
+
top: var(--hk-menu-item-optical);
|
|
180
|
+
}
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
Defines layout variables needed by plana-ui + hikari components.
|
|
4
4
|
Color tokens are provided by hikari's initTheme(). */
|
|
5
5
|
|
|
6
|
+
@use "../components/menu-item" as mi;
|
|
7
|
+
|
|
6
8
|
:root {
|
|
7
9
|
/* ── Layout tokens ─ */
|
|
8
10
|
--s-footer-height: 3rem;
|
|
@@ -357,41 +359,23 @@
|
|
|
357
359
|
}
|
|
358
360
|
|
|
359
361
|
/* ── Popup menu items ─ */
|
|
362
|
+
/* Unified menu-item spec (packages/vue/src/components/_menu-item.scss):
|
|
363
|
+
* one row grammar across menus, selects, theme pills, admin header
|
|
364
|
+
* rows and nav. The tsx wraps leading icons in .hk-menu-item-icon so
|
|
365
|
+
* the column centers against the label. */
|
|
360
366
|
.s-popup-menu-item {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
font-size: var(--text-sm);
|
|
366
|
-
font-weight: 500;
|
|
367
|
-
font-family: inherit;
|
|
368
|
-
color: rgb(var(--color-text));
|
|
369
|
-
cursor: pointer;
|
|
370
|
-
transition: background var(--duration-normal) ease;
|
|
371
|
-
border: none;
|
|
372
|
-
background: none;
|
|
373
|
-
width: 100%;
|
|
374
|
-
text-align: left;
|
|
375
|
-
/* Rows are flex containers (icon + label + optional trailing caret).
|
|
376
|
-
Without an explicit justification, Chrome's button UA default
|
|
377
|
-
(justify-content: center) applies — invisible on shrink-to-fit
|
|
378
|
-
popovers, glaring in full-width mobile sheets where icon+label
|
|
379
|
-
float to the middle. Pin to flex-start; trailing carets keep
|
|
380
|
-
parking at the far edge via their own margin-left: auto. */
|
|
381
|
-
justify-content: flex-start;
|
|
382
|
-
outline: none;
|
|
383
|
-
border-radius: var(--radius-sm);
|
|
367
|
+
@include mi.item;
|
|
368
|
+
|
|
369
|
+
/* Trailing carets keep parking at the far edge via their own
|
|
370
|
+
margin-left: auto. */
|
|
384
371
|
}
|
|
385
372
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
.s-popup-menu-item:hover:not([data-active]) {
|
|
389
|
-
background: var(--c-primary-subtle);
|
|
373
|
+
.s-popup-menu-item .hk-menu-item-icon {
|
|
374
|
+
@include mi.icon;
|
|
390
375
|
}
|
|
391
376
|
|
|
392
|
-
.s-popup-menu-item[data-active] {
|
|
393
|
-
|
|
394
|
-
font-weight: 600;
|
|
377
|
+
.s-popup-menu-item[data-active]:hover {
|
|
378
|
+
background: var(--c-primary-dim, rgb(var(--color-primary) / 10%));
|
|
395
379
|
}
|
|
396
380
|
|
|
397
381
|
/* ── User identity header (shared) ─ */
|