@celestia-island/hikari 0.55.188 → 0.55.190
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/HkAffixPicker.scss +2 -2
- package/src/components/HkAuthSubmitButton.scss +1 -1
- package/src/components/HkAvatar.scss +2 -2
- package/src/components/HkBlockingToast.scss +6 -2
- package/src/components/HkConfirmDialog.scss +1 -1
- package/src/components/HkCornerBadge.scss +1 -1
- package/src/components/HkDockBar.scss +8 -2
- package/src/components/HkDrawer.motion.test.ts +111 -0
- package/src/components/HkDrawer.scss +24 -0
- package/src/components/HkIconButtonGroup.scss +1 -1
- package/src/components/HkMenu.scss +1 -1
- package/src/components/HkMenuActionItem.tsx +6 -0
- package/src/components/HkMessageBox.scss +1 -1
- package/src/components/HkModalBreadcrumb.overflow.contract.test.ts +1 -1
- package/src/components/HkModalBreadcrumb.scss +30 -9
- package/src/components/HkModalBreadcrumb.test.tsx +45 -22
- package/src/components/HkModalBreadcrumb.tokens.contract.test.ts +81 -0
- package/src/components/HkModalBreadcrumb.tsx +56 -16
- package/src/components/HkPopover.tsx +9 -1
- package/src/components/HkThemeToggle.scss +6 -1
- package/src/components/HkToast.scss +4 -2
- package/src/components/_menu-item.scss +11 -5
- package/src/demo.ts +3 -2
- package/src/i18n/locales/ar/components.json +1 -1
- package/src/i18n/locales/de/components.json +1 -1
- package/src/i18n/locales/en/components.json +1 -1
- package/src/i18n/locales/es/components.json +1 -1
- package/src/i18n/locales/fr/components.json +1 -1
- package/src/i18n/locales/ja/components.json +1 -1
- package/src/i18n/locales/ko/components.json +1 -1
- package/src/i18n/locales/pt/components.json +1 -1
- package/src/i18n/locales/ru/components.json +1 -1
- package/src/i18n/locales/zh-Hans/components.json +1 -1
- package/src/i18n/locales/zh-Hant/components.json +1 -1
- package/src/i18n/modalKeys.test.ts +2 -2
- package/src/index.ts +3 -0
- package/src/runtime/index.ts +5 -0
- package/src/runtime/mobileViewport.ts +5 -3
- package/src/runtime/tapHighlight.test.ts +134 -0
- package/src/runtime/tapHighlight.ts +169 -0
- package/src/styles/admin-tokens.scss +3 -3
- package/src/styles/channelFallbacks.test.ts +8 -6
- package/src/styles/ghostChannels.contract.test.ts +138 -0
- package/src/styles/reset.scss +19 -0
- package/src/styles/resetSheet.test.ts +20 -0
package/package.json
CHANGED
|
@@ -196,8 +196,8 @@
|
|
|
196
196
|
|
|
197
197
|
&:hover,
|
|
198
198
|
&:focus-visible {
|
|
199
|
-
color: rgb(var(--color-
|
|
200
|
-
background: rgb(var(--color-
|
|
199
|
+
color: rgb(var(--color-error, 247 118 142));
|
|
200
|
+
background: rgb(var(--color-error, 247 118 142) / 12%);
|
|
201
201
|
}
|
|
202
202
|
}
|
|
203
203
|
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
align-items: center;
|
|
8
8
|
justify-content: center;
|
|
9
9
|
overflow: hidden;
|
|
10
|
-
background-color: var(--
|
|
11
|
-
color: var(--
|
|
10
|
+
background-color: rgb(var(--color-muted, 108 108 108) / 15%);
|
|
11
|
+
color: rgb(var(--color-text-secondary, 102 102 102));
|
|
12
12
|
transition: all 0.2s ease;
|
|
13
13
|
|
|
14
14
|
img {
|
|
@@ -80,17 +80,21 @@
|
|
|
80
80
|
// dark-on-amber warning text for contrast).
|
|
81
81
|
.hk-blocking-toast-info {
|
|
82
82
|
background: var(--hi-color-info, #6abed6);
|
|
83
|
-
|
|
83
|
+
/* On-solid content: ride the canonical slot (HkToast family), keep the
|
|
84
|
+
* #fff fallback so themeless hosts render as before. */
|
|
85
|
+
color: var(--hi-color-text-on-solid, #fff);
|
|
84
86
|
}
|
|
85
87
|
|
|
86
88
|
.hk-blocking-toast-warning {
|
|
87
89
|
background: var(--hi-color-warning, #eed677);
|
|
90
|
+
/* Deliberate DARK-on-amber: the warning band is light by design, and
|
|
91
|
+
* the on-solid slot is white — a white glyph here would be unreadable. */
|
|
88
92
|
color: #1c1c1e;
|
|
89
93
|
}
|
|
90
94
|
|
|
91
95
|
.hk-blocking-toast-danger {
|
|
92
96
|
background: var(--hi-color-danger, #f85149);
|
|
93
|
-
color: #fff;
|
|
97
|
+
color: var(--hi-color-text-on-danger, #fff);
|
|
94
98
|
}
|
|
95
99
|
|
|
96
100
|
// ── Actions on a tinted surface ─────────────────────────────────────
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
.hk-confirm-dialog-message {
|
|
18
18
|
margin: 0;
|
|
19
19
|
color: color-mix(in srgb, var(--hi-color-text-secondary) 85%, transparent);
|
|
20
|
-
font-size: var(--
|
|
20
|
+
font-size: var(--text-sm, 0.8125rem);
|
|
21
21
|
line-height: 1.6;
|
|
22
22
|
overflow-wrap: anywhere;
|
|
23
23
|
}
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
background: var(--hk-badge-bg);
|
|
74
74
|
color: var(--hk-badge-text);
|
|
75
75
|
border: 1px solid var(--hk-badge-border);
|
|
76
|
-
font-size: var(--text-
|
|
76
|
+
font-size: var(--text-2xs, 0.625rem);
|
|
77
77
|
font-weight: 700;
|
|
78
78
|
line-height: 1;
|
|
79
79
|
font-variant-numeric: tabular-nums;
|
|
@@ -117,8 +117,14 @@
|
|
|
117
117
|
border: 1px solid var(--border-subtle);
|
|
118
118
|
border-radius: var(--radius-md);
|
|
119
119
|
/* Reverse soft light — dark mode: white halo; light mode: soft black
|
|
120
|
-
* shadow. Strength scales with --glow-scale (Style → Shadow).
|
|
121
|
-
|
|
120
|
+
* shadow. Strength scales with --glow-scale (Style → Shadow). The
|
|
121
|
+
* default derives the halo from the theme's own ink channel, which IS
|
|
122
|
+
* mode-reversed — before 2026-10-19 the bare var(--glow-dock) had no
|
|
123
|
+
* publisher and no fallback, so the dock halo silently never rendered. */
|
|
124
|
+
box-shadow: var(
|
|
125
|
+
--glow-dock,
|
|
126
|
+
0 8px 32px rgb(var(--color-text, 30 30 30) / calc(8% * var(--glow-scale, 1)))
|
|
127
|
+
);
|
|
122
128
|
}
|
|
123
129
|
|
|
124
130
|
/* Vertical side docks (west / east edges) stack along the viewport's
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import * as sass from "sass";
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Side-drawer motion contract (2026-10-19 phone recording).
|
|
10
|
+
*
|
|
11
|
+
* The left/right drawer used to share the scrim's opacity fade: at
|
|
12
|
+
* halfway through the close the panel was still on screen but its right
|
|
13
|
+
* half was transparent, so the page behind showed through and the right
|
|
14
|
+
* rounded corners dissolved — it read as a width shrink. Side drawers
|
|
15
|
+
* must slide as an opaque surface (transform only); the scrim keeps its
|
|
16
|
+
* own fade, and top/bottom sheets keep the fade.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const src = readFileSync(join(here, "HkDrawer.scss"), "utf-8");
|
|
21
|
+
|
|
22
|
+
// The side-drawer overrides sit after the generic blocks and before the
|
|
23
|
+
// reduced-motion block (which also lists the active classes with
|
|
24
|
+
// `transition: none`, so the full-file last occurrence would find that).
|
|
25
|
+
const sideStart = src.indexOf("/* Side drawers slide as an OPAQUE surface.");
|
|
26
|
+
const reduceStart = src.indexOf("@media (prefers-reduced-motion: reduce)");
|
|
27
|
+
const side = src.slice(sideStart, reduceStart);
|
|
28
|
+
|
|
29
|
+
/** Body of the LAST rule for a selector — the side overrides are declared
|
|
30
|
+
* after the generic four-direction blocks. */
|
|
31
|
+
function lastRule(selector: string, from = src): string {
|
|
32
|
+
const at = from.lastIndexOf(selector);
|
|
33
|
+
expect(at, `${selector} exists`).toBeGreaterThanOrEqual(0);
|
|
34
|
+
const open = from.indexOf("{", at);
|
|
35
|
+
const close = from.indexOf("}", open);
|
|
36
|
+
return from.slice(open + 1, close);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("HkDrawer side motion (opaque slide)", () => {
|
|
40
|
+
it("does not fade the left/right panel out", () => {
|
|
41
|
+
for (const sel of [
|
|
42
|
+
".hk-drawer-left-enter-active",
|
|
43
|
+
".hk-drawer-left-leave-active",
|
|
44
|
+
".hk-drawer-right-enter-active",
|
|
45
|
+
".hk-drawer-right-leave-active",
|
|
46
|
+
]) {
|
|
47
|
+
const rule = lastRule(sel, side);
|
|
48
|
+
expect(rule, sel).toContain("transform");
|
|
49
|
+
expect(rule, sel).not.toContain("opacity");
|
|
50
|
+
}
|
|
51
|
+
for (const sel of [
|
|
52
|
+
".hk-drawer-left-enter-from",
|
|
53
|
+
".hk-drawer-left-leave-to",
|
|
54
|
+
".hk-drawer-right-enter-from",
|
|
55
|
+
".hk-drawer-right-leave-to",
|
|
56
|
+
]) {
|
|
57
|
+
expect(lastRule(sel, side), sel).toContain("opacity: 1");
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("keeps the opaque side overrides after the generic fade rules (compiled order)", () => {
|
|
62
|
+
// The source slices above are anchored on a comment marker; this
|
|
63
|
+
// compiled assertion pins the cascade itself: the side override must
|
|
64
|
+
// come AFTER the generic four-direction block in the emitted CSS.
|
|
65
|
+
const fullCss = sass.compile(resolve(here, "HkDrawer.scss")).css;
|
|
66
|
+
// The reduced-motion block repeats the active selectors with
|
|
67
|
+
// `transition: none`; exclude it so the last occurrence is the side
|
|
68
|
+
// override under test.
|
|
69
|
+
const reduceAt = fullCss.lastIndexOf(
|
|
70
|
+
"@media (prefers-reduced-motion: reduce)",
|
|
71
|
+
);
|
|
72
|
+
const css = fullCss.slice(0, reduceAt > 0 ? reduceAt : fullCss.length);
|
|
73
|
+
const genericAt = css.indexOf(".hk-drawer-left-enter-active");
|
|
74
|
+
const overrideAt = css.lastIndexOf(".hk-drawer-left-enter-active");
|
|
75
|
+
expect(overrideAt).toBeGreaterThan(genericAt);
|
|
76
|
+
const overrideBody = css.slice(overrideAt, css.indexOf("}", overrideAt));
|
|
77
|
+
expect(overrideBody).toContain("transform");
|
|
78
|
+
expect(overrideBody).not.toContain("opacity");
|
|
79
|
+
|
|
80
|
+
const genericFromAt = css.indexOf(".hk-drawer-left-enter-from");
|
|
81
|
+
const sideFromAt = css.lastIndexOf(".hk-drawer-left-enter-from");
|
|
82
|
+
expect(sideFromAt).toBeGreaterThan(genericFromAt);
|
|
83
|
+
const sideFromBody = css.slice(sideFromAt, css.indexOf("}", sideFromAt));
|
|
84
|
+
expect(sideFromBody).toContain("opacity: 1");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("keeps the sheet active opacity transition", () => {
|
|
88
|
+
// R3 mutation: deleting the generic active opacity transition kept the
|
|
89
|
+
// suite green. Guard that top/bottom sheets still animate opacity.
|
|
90
|
+
const generic = src.slice(0, sideStart);
|
|
91
|
+
expect(lastRule(".hk-drawer-top-enter-active", generic)).toContain("opacity");
|
|
92
|
+
expect(lastRule(".hk-drawer-bottom-leave-active", generic)).toContain(
|
|
93
|
+
"opacity",
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("keeps the off-screen side transforms", () => {
|
|
98
|
+
// The side override only resets opacity to 1; the generic from/to
|
|
99
|
+
// blocks must keep the translateX(±100%) that carries the panel in
|
|
100
|
+
// and out of the screen.
|
|
101
|
+
const left = src.slice(src.indexOf(".hk-drawer-left-enter-from"));
|
|
102
|
+
expect(left.slice(0, left.indexOf("}") + 1)).toContain("translateX(-100%)");
|
|
103
|
+
const right = src.slice(src.indexOf(".hk-drawer-right-enter-from"));
|
|
104
|
+
expect(right.slice(0, right.indexOf("}") + 1)).toContain("translateX(100%)");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("keeps the fade for top/bottom sheets", () => {
|
|
108
|
+
expect(lastRule(".hk-drawer-top-enter-from")).toContain("opacity: 0");
|
|
109
|
+
expect(lastRule(".hk-drawer-bottom-enter-from")).toContain("opacity: 0");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -123,6 +123,30 @@
|
|
|
123
123
|
transform: translateY(100%);
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/* Side drawers slide as an OPAQUE surface. The panel used to share the
|
|
127
|
+
* scrim's fade (opacity: 0 in the enter/leave states); on a phone the
|
|
128
|
+
* drawer was still partly on screen when its right half went transparent
|
|
129
|
+
* — the page behind showed through and the right rounded corners
|
|
130
|
+
* dissolved, which reads as a width shrink (screen recording
|
|
131
|
+
* 2026-10-19). The scrim keeps its own fade; the panel only translates.
|
|
132
|
+
* Top/bottom sheets keep the fade above. */
|
|
133
|
+
.hk-drawer-left-enter-active,
|
|
134
|
+
.hk-drawer-right-enter-active {
|
|
135
|
+
transition: transform var(--duration-normal, 0.3s) cubic-bezier(0.16, 1, 0.3, 1);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.hk-drawer-left-leave-active,
|
|
139
|
+
.hk-drawer-right-leave-active {
|
|
140
|
+
transition: transform var(--duration-normal, 0.3s) cubic-bezier(0.4, 0, 1, 1);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.hk-drawer-left-enter-from,
|
|
144
|
+
.hk-drawer-left-leave-to,
|
|
145
|
+
.hk-drawer-right-enter-from,
|
|
146
|
+
.hk-drawer-right-leave-to {
|
|
147
|
+
opacity: 1;
|
|
148
|
+
}
|
|
149
|
+
|
|
126
150
|
.hk-drawer-header {
|
|
127
151
|
display: flex;
|
|
128
152
|
align-items: center;
|
|
@@ -132,7 +132,7 @@
|
|
|
132
132
|
padding: 0 var(--space-4, 4px);
|
|
133
133
|
border-radius: var(--radius-full, 9999px);
|
|
134
134
|
background: rgb(var(--color-error, 247 118 142));
|
|
135
|
-
color: #fff;
|
|
135
|
+
color: var(--hi-color-text-on-danger, #fff);
|
|
136
136
|
font-size: var(--text-2xs, 10px);
|
|
137
137
|
font-weight: 700;
|
|
138
138
|
line-height: 1;
|
|
@@ -21,6 +21,11 @@ export default defineComponent({
|
|
|
21
21
|
/** Destructive styling (logout, delete…). */
|
|
22
22
|
danger: { type: Boolean, default: false },
|
|
23
23
|
disabled: { type: Boolean, default: false },
|
|
24
|
+
/** Selected/current row — the unified menu-item grammar's [data-active]
|
|
25
|
+
* face (primary wash + strong border + primary text; see
|
|
26
|
+
* _menu-item.scss). For lists that mark "you are here", e.g. the
|
|
27
|
+
* modal-breadcrumb layer picker highlighting the current layer. */
|
|
28
|
+
active: { type: Boolean, default: false },
|
|
24
29
|
},
|
|
25
30
|
emits: {
|
|
26
31
|
click: (_e: MouseEvent) => true,
|
|
@@ -31,6 +36,7 @@ export default defineComponent({
|
|
|
31
36
|
type="button"
|
|
32
37
|
class={["hk-menu-action-item", props.danger ? "hk-menu-action-item--danger" : ""]}
|
|
33
38
|
data-danger={props.danger || undefined}
|
|
39
|
+
data-active={props.active || undefined}
|
|
34
40
|
disabled={props.disabled}
|
|
35
41
|
role="menuitem"
|
|
36
42
|
onClick={(e: MouseEvent) => emit("click", e)}
|
|
@@ -95,7 +95,7 @@ describe("HkModalBreadcrumb overflow fence", () => {
|
|
|
95
95
|
expect(offset).toBeGreaterThan(clearance);
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
-
it("routes the
|
|
98
|
+
it("routes the all-layers menu through the shared form-factor rule", () => {
|
|
99
99
|
// HkPopover owns "anchored under the trigger on desktop, bottom-up
|
|
100
100
|
// sheet on mobile" and registers the surface with the popup manager;
|
|
101
101
|
// a bespoke panel here would fork that behaviour.
|
|
@@ -109,9 +109,22 @@
|
|
|
109
109
|
inset: calc(-1 * var(--space-8, 0.5rem));
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
112
|
+
/* Hover AND the open state (aria-expanded — on a phone the tap leaves
|
|
113
|
+
* :hover stuck on the trigger, so the two must share one legible face).
|
|
114
|
+
* Both colors ride tokens that exist in every theme: the wash is the
|
|
115
|
+
* menu-item grammar's own primary-subtle (a raw rgba(0,0,0,…) fallback
|
|
116
|
+
* vanished on dark surfaces), and the text is --hi-color-text-primary —
|
|
117
|
+
* the token family defines NO --hi-color-text, so the old fallback
|
|
118
|
+
* (#1f2328, light-theme ink) used to repaint the glyph near-black on a
|
|
119
|
+
* dark strip, i.e. unreadable exactly after the tap (2026-10-18 report).
|
|
120
|
+
* The host knob --hk-breadcrumb-more-hover still wins if declared. */
|
|
121
|
+
.hk-modal-breadcrumb-more:hover,
|
|
122
|
+
.hk-modal-breadcrumb-more[aria-expanded="true"] {
|
|
123
|
+
background: var(
|
|
124
|
+
--hk-breadcrumb-more-hover,
|
|
125
|
+
var(--c-primary-subtle, rgb(var(--color-primary, 122 162 247) / 8%))
|
|
126
|
+
);
|
|
127
|
+
color: var(--hi-color-text-primary, rgb(var(--color-text, 30 30 30)));
|
|
115
128
|
}
|
|
116
129
|
|
|
117
130
|
.hk-modal-breadcrumb-more:focus-visible {
|
|
@@ -119,7 +132,7 @@
|
|
|
119
132
|
outline-offset: 1px;
|
|
120
133
|
}
|
|
121
134
|
|
|
122
|
-
/* The
|
|
135
|
+
/* The all-layers menu rides HkPopover, so it inherits the family's
|
|
123
136
|
dropdown chrome and mobile-sheet form. Only the inset is re-anchored:
|
|
124
137
|
the popover panel already pads 4px, and HkMenuPanel's drawer-scale 12px
|
|
125
138
|
inset on top of it read as a double margin. */
|
|
@@ -128,7 +141,7 @@
|
|
|
128
141
|
}
|
|
129
142
|
|
|
130
143
|
/* A visible-but-cut label is tappable: it opens the whole name in the same
|
|
131
|
-
popover family as the
|
|
144
|
+
popover family as the all-layers menu. The strip is pointer-
|
|
132
145
|
transparent, so only these buttons (and the "…" trigger) take the click. */
|
|
133
146
|
.hk-modal-breadcrumb-item-reveal {
|
|
134
147
|
/* No padding: the strip's ruler measures a plain item, so a padded
|
|
@@ -160,8 +173,14 @@
|
|
|
160
173
|
|
|
161
174
|
.hk-modal-breadcrumb-item-reveal:hover,
|
|
162
175
|
.hk-modal-breadcrumb-item-reveal[aria-expanded="true"] {
|
|
163
|
-
|
|
164
|
-
|
|
176
|
+
/* Same token contract as the "…" trigger above (see there): sticky
|
|
177
|
+
* touch hover makes this face linger after the tap, so it must stay
|
|
178
|
+
* legible on dark surfaces too. */
|
|
179
|
+
background: var(
|
|
180
|
+
--hk-breadcrumb-more-hover,
|
|
181
|
+
var(--c-primary-subtle, rgb(var(--color-primary, 122 162 247) / 8%))
|
|
182
|
+
);
|
|
183
|
+
color: var(--hi-color-text-primary, rgb(var(--color-text, 30 30 30)));
|
|
165
184
|
}
|
|
166
185
|
|
|
167
186
|
.hk-modal-breadcrumb-item-reveal:focus-visible {
|
|
@@ -170,7 +189,9 @@
|
|
|
170
189
|
}
|
|
171
190
|
|
|
172
191
|
/* The revealed name itself: the strip's own type, wrapping instead of
|
|
173
|
-
cutting, and selectable (the strip around it is not).
|
|
192
|
+
cutting, and selectable (the strip around it is not). Same token
|
|
193
|
+
contract as the trigger faces above — --hi-color-text does not exist
|
|
194
|
+
in any theme, and its light-ink fallback was unreadable on dark. */
|
|
174
195
|
.hk-modal-breadcrumb-reveal {
|
|
175
196
|
margin: 0;
|
|
176
197
|
padding: var(--space-8, 0.5rem) var(--space-12, 0.75rem);
|
|
@@ -178,7 +199,7 @@
|
|
|
178
199
|
font-size: var(--text-xs, 0.75rem);
|
|
179
200
|
font-weight: 600;
|
|
180
201
|
line-height: 1.5;
|
|
181
|
-
color: var(--hi-color-text,
|
|
202
|
+
color: var(--hi-color-text-primary, rgb(var(--color-text, 30 30 30)));
|
|
182
203
|
overflow-wrap: anywhere;
|
|
183
204
|
user-select: text;
|
|
184
205
|
}
|
|
@@ -483,7 +483,7 @@ describe("HkModalBreadcrumb label budget", () => {
|
|
|
483
483
|
expect(document.body.querySelector(".hk-modal-breadcrumb-reveal")).toBeNull();
|
|
484
484
|
});
|
|
485
485
|
|
|
486
|
-
it("opens the reveal instead of the
|
|
486
|
+
it("opens the reveal instead of the all-layers menu", async () => {
|
|
487
487
|
// The two surfaces are never open together: tapping a crumb closes the
|
|
488
488
|
// menu it was opened from (its leave plays out, so the logical state is
|
|
489
489
|
// what the assertion reads).
|
|
@@ -905,34 +905,52 @@ describe("HkModalBreadcrumb overflow fold", () => {
|
|
|
905
905
|
});
|
|
906
906
|
});
|
|
907
907
|
|
|
908
|
-
describe("HkModalBreadcrumb
|
|
908
|
+
describe("HkModalBreadcrumb all-layers menu", () => {
|
|
909
909
|
function registerStack(extraTitle: string): void {
|
|
910
910
|
STACK_TITLES.forEach((title) => manager.register("modal", true, title));
|
|
911
911
|
manager.register("modal", true, extraTitle);
|
|
912
912
|
}
|
|
913
913
|
|
|
914
|
-
it("lists
|
|
914
|
+
it("lists every layer reversed, full names, current row selected", async () => {
|
|
915
915
|
setViewport(360);
|
|
916
916
|
registerStack("细节");
|
|
917
917
|
await mountStrip();
|
|
918
918
|
const allTitles = [...STACK_TITLES, "细节"];
|
|
919
|
-
// The tail that stays on the strip before the menu opens: the menu
|
|
920
|
-
// snapshots exactly the layers this run folds away.
|
|
921
|
-
const tailBefore = labels();
|
|
922
919
|
expect(more()).not.toBeNull();
|
|
923
|
-
expect(tailBefore.length).toBeGreaterThan(0);
|
|
924
|
-
expect(tailBefore.length).toBeLessThan(allTitles.length);
|
|
925
920
|
|
|
926
921
|
more()!.click();
|
|
927
922
|
await nextTick();
|
|
928
923
|
await nextTick();
|
|
929
924
|
const rows = menuRows();
|
|
930
|
-
//
|
|
925
|
+
// EVERY layer, topmost first (reverse stack order) — not just the
|
|
926
|
+
// folded prefix — with full names: the strip's clamp never reaches
|
|
927
|
+
// here.
|
|
931
928
|
expect(rows.map((row) => row.textContent)).toEqual(
|
|
932
|
-
allTitles.slice(
|
|
929
|
+
allTitles.slice().reverse(),
|
|
933
930
|
);
|
|
934
|
-
expect(rows.map((row) => row.textContent)).not.toContain(LONG_TAIL);
|
|
935
931
|
expect(rows.some((row) => row.textContent?.includes("…"))).toBe(false);
|
|
932
|
+
// The current layer is marked with the unified menu-item grammar's
|
|
933
|
+
// selected face (HkMenuActionItem `active` → [data-active]), and it
|
|
934
|
+
// is the ONLY selected row.
|
|
935
|
+
const selected = rows.filter((row) => row.hasAttribute("data-active"));
|
|
936
|
+
expect(selected).toHaveLength(1);
|
|
937
|
+
expect(selected[0].textContent).toBe("细节");
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
it("never lists the menu's own sheet among the layers", async () => {
|
|
941
|
+
setViewport(360);
|
|
942
|
+
registerStack("细节");
|
|
943
|
+
await mountStrip();
|
|
944
|
+
more()!.click();
|
|
945
|
+
await nextTick();
|
|
946
|
+
await nextTick();
|
|
947
|
+
// The sheet form registered as a blocking layer (the strip itself
|
|
948
|
+
// lists it as a crumb — see the docking test below), yet the menu's
|
|
949
|
+
// rows stay the WINDOWS only: selecting "the menu" is not a place
|
|
950
|
+
// to go.
|
|
951
|
+
const rows = menuRows();
|
|
952
|
+
expect(rows.map((row) => row.textContent)).not.toContain("All layers");
|
|
953
|
+
expect(rows).toHaveLength(STACK_TITLES.length + 1);
|
|
936
954
|
});
|
|
937
955
|
|
|
938
956
|
it("docks the menu as a bottom-up sheet on mobile", async () => {
|
|
@@ -950,12 +968,12 @@ describe("HkModalBreadcrumb hidden-layers menu", () => {
|
|
|
950
968
|
// window layer — it is named, and it counts as a layer of the stack it
|
|
951
969
|
// was opened from.
|
|
952
970
|
const blocking = [...manager.registry.value.values()].filter((entry) => entry.blocking);
|
|
953
|
-
expect(blocking.map((entry) => entry.title)).toContain("
|
|
971
|
+
expect(blocking.map((entry) => entry.title)).toContain("All layers");
|
|
954
972
|
// It is a window layer in every sense — including naming itself as the
|
|
955
973
|
// current crumb of the very strip it was opened from.
|
|
956
974
|
expect(
|
|
957
975
|
strip()!.querySelector(".hk-modal-breadcrumb-item-current")!.textContent,
|
|
958
|
-
).toBe("
|
|
976
|
+
).toBe("All layers");
|
|
959
977
|
});
|
|
960
978
|
|
|
961
979
|
it("drops the trigger and the menu once the stack stops folding", async () => {
|
|
@@ -971,8 +989,9 @@ describe("HkModalBreadcrumb hidden-layers menu", () => {
|
|
|
971
989
|
expect(more()!.getAttribute("aria-expanded")).toBe("true");
|
|
972
990
|
|
|
973
991
|
// Wide enough for the whole stack, still the same form factor (so the
|
|
974
|
-
// popover's own breakpoint rule is not what closes it):
|
|
975
|
-
//
|
|
992
|
+
// popover's own breakpoint rule is not what closes it): the strip now
|
|
993
|
+
// shows every layer itself, and the trigger the menu is anchored to
|
|
994
|
+
// goes with it.
|
|
976
995
|
setViewport(760);
|
|
977
996
|
await until(
|
|
978
997
|
() =>
|
|
@@ -993,7 +1012,7 @@ describe("HkModalBreadcrumb hidden-layers menu", () => {
|
|
|
993
1012
|
const panel = document.body.querySelector<HTMLElement>(".hk-popover-panel")!;
|
|
994
1013
|
expect(panel).toBeTruthy();
|
|
995
1014
|
expect(panel.classList.contains("hk-is-sheet")).toBe(false);
|
|
996
|
-
expect(panel.getAttribute("aria-label")).toBe("
|
|
1015
|
+
expect(panel.getAttribute("aria-label")).toBe("All layers");
|
|
997
1016
|
expect(document.body.querySelector(".hk-popover-scrim")).toBeNull();
|
|
998
1017
|
// Anchored = a hidden level: nothing new joins the window stack.
|
|
999
1018
|
expect([...manager.registry.value.values()].filter((entry) => entry.blocking)).toHaveLength(0);
|
|
@@ -1015,15 +1034,19 @@ describe("HkModalBreadcrumb hidden-layers menu", () => {
|
|
|
1015
1034
|
await nextTick();
|
|
1016
1035
|
const rows = menuRows();
|
|
1017
1036
|
expect(rows.length).toBeGreaterThan(1);
|
|
1018
|
-
|
|
1037
|
+
// Reversed order: the topmost layer (the no-close-channel one) leads.
|
|
1038
|
+
expect(rows[0].textContent).toBe("没有关闭通道的层级占位文本");
|
|
1019
1039
|
|
|
1020
|
-
//
|
|
1040
|
+
// Rows are reverse stack order: past the no-channel topmost (row 0),
|
|
1041
|
+
// rows[k] is STACK_TITLES[STACK_TITLES.length - k] — pick k = 3,
|
|
1042
|
+
// i.e. STACK_TITLES[5]; everything above it closes, it and
|
|
1021
1043
|
// everything below it stay.
|
|
1022
|
-
rows[
|
|
1044
|
+
rows[3].click();
|
|
1023
1045
|
await nextTick();
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1046
|
+
for (let i = 0; i <= 5; i++) {
|
|
1047
|
+
expect(closes[i]).not.toHaveBeenCalled();
|
|
1048
|
+
}
|
|
1049
|
+
for (let i = 6; i < closes.length; i++) {
|
|
1027
1050
|
expect(closes[i]).toHaveBeenCalledTimes(1);
|
|
1028
1051
|
}
|
|
1029
1052
|
expect(manager.registry.value.has(noChannel.id)).toBe(true);
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source contract for the strip's trigger/reveal interaction face.
|
|
3
|
+
*
|
|
4
|
+
* The behavioural suite runs in happy-dom, which resolves no theme
|
|
5
|
+
* custom properties — a rule that falls back to a WRONG literal paints
|
|
6
|
+
* fine in the suite and breaks only in a real theme. That is exactly
|
|
7
|
+
* what shipped: the hover/open faces colored their glyphs with a
|
|
8
|
+
* var() reference to --hi-color-text (fallback #1f2328) — a token NO
|
|
9
|
+
* theme defines (the family spells it --hi-color-text-primary), so the
|
|
10
|
+
* light-theme ink #1f2328 won
|
|
11
|
+
* — and on a phone the tap leaves :hover stuck, making the "…" trigger
|
|
12
|
+
* near-invisible on every dark theme right after the tap (2026-10-18
|
|
13
|
+
* user report). The pins live here, next to the reason each exists.
|
|
14
|
+
*/
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
|
|
19
|
+
import { describe, expect, it } from "vitest";
|
|
20
|
+
|
|
21
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const scss = readFileSync(join(here, "HkModalBreadcrumb.scss"), "utf-8");
|
|
23
|
+
|
|
24
|
+
function rule(selector: string): string {
|
|
25
|
+
return scss.match(new RegExp(`${selector}\\s*{[^}]*}`, "m"))?.[0] ?? "";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("HkModalBreadcrumb trigger face tokens", () => {
|
|
29
|
+
it("never references the nonexistent --hi-color-text alias", () => {
|
|
30
|
+
// The alias family defines --hi-color-text-primary; --hi-color-text
|
|
31
|
+
// does not exist, so any var() use silently takes its light-theme
|
|
32
|
+
// fallback. (Matched as var() usage so prose in comments about the
|
|
33
|
+
// alias itself stays legal.)
|
|
34
|
+
expect(scss).not.toMatch(/var\(\s*--hi-color-text[,)]/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("paints the trigger's hover AND open face from live theme tokens", () => {
|
|
38
|
+
// One legible face for both states: the tap on a phone leaves :hover
|
|
39
|
+
// stuck on the trigger, so aria-expanded must share the rule.
|
|
40
|
+
const group = scss.match(
|
|
41
|
+
/\.hk-modal-breadcrumb-more:hover,\s*\n\.hk-modal-breadcrumb-more\[aria-expanded="true"\]\s*{[^}]*}/,
|
|
42
|
+
)?.[0];
|
|
43
|
+
expect(group).toBeTruthy();
|
|
44
|
+
expect(group).toContain("var(--hi-color-text-primary");
|
|
45
|
+
// The wash falls back to the menu-item grammar's own primary-subtle —
|
|
46
|
+
// a raw rgba(0,0,0,…) fallback vanished on dark surfaces.
|
|
47
|
+
expect(group).toContain("var(--c-primary-subtle");
|
|
48
|
+
expect(group).not.toContain("rgba(0, 0, 0");
|
|
49
|
+
// The host knob still wins when declared.
|
|
50
|
+
expect(group).toContain("--hk-breadcrumb-more-hover");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("gives the cut-label reveal button the same token contract", () => {
|
|
54
|
+
const group = scss.match(
|
|
55
|
+
/\.hk-modal-breadcrumb-item-reveal:hover,\s*\n\.hk-modal-breadcrumb-item-reveal\[aria-expanded="true"\]\s*{[^}]*}/,
|
|
56
|
+
)?.[0];
|
|
57
|
+
expect(group).toBeTruthy();
|
|
58
|
+
expect(group).toContain("var(--hi-color-text-primary");
|
|
59
|
+
expect(group).toContain("var(--c-primary-subtle");
|
|
60
|
+
expect(group).not.toContain("rgba(0, 0, 0");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("keeps the base faces on the tokens the strip already used", () => {
|
|
64
|
+
// Untouched by the fix, pinned so a refactor cannot quietly
|
|
65
|
+
// reintroduce a raw literal here either.
|
|
66
|
+
expect(rule(".hk-modal-breadcrumb-more")).toContain(
|
|
67
|
+
"color: var(--hi-color-text-secondary",
|
|
68
|
+
);
|
|
69
|
+
expect(rule(".hk-modal-breadcrumb-item")).toContain(
|
|
70
|
+
"color: var(--hi-color-text-secondary",
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("colors the revealed full name from a live token too", () => {
|
|
75
|
+
// The reveal body text rode the same broken alias — near-black ink
|
|
76
|
+
// on every dark theme.
|
|
77
|
+
expect(rule(".hk-modal-breadcrumb-reveal")).toContain(
|
|
78
|
+
"color: var(--hi-color-text-primary",
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
});
|