@celestia-island/hikari 0.55.188 → 0.55.189
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/HkMenuActionItem.tsx +6 -0
- 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/_menu-item.scss +7 -1
- 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/reset.scss +19 -0
- package/src/styles/resetSheet.test.ts +20 -0
package/package.json
CHANGED
|
@@ -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
|
+
});
|
|
@@ -71,7 +71,7 @@ export default defineComponent({
|
|
|
71
71
|
* both viewport edges (2026-09-16 report). The ellipsis counts inside the
|
|
72
72
|
* budget: a 10-unit cap renders ten ideographs, or nine plus the
|
|
73
73
|
* ellipsis. Full names stay in the DOM for assistive tech and in the
|
|
74
|
-
*
|
|
74
|
+
* all-layers menu.
|
|
75
75
|
*/
|
|
76
76
|
maxLabelUnits: { type: Number, default: 10 },
|
|
77
77
|
/**
|
|
@@ -287,16 +287,40 @@ export default defineComponent({
|
|
|
287
287
|
const tail = computed(() => crumbs.value.slice(hiddenCount.value));
|
|
288
288
|
const folded = computed(() => hidden.value.length > 0);
|
|
289
289
|
|
|
290
|
-
// ──
|
|
291
|
-
// The trigger lists
|
|
292
|
-
// their FULL names — the strip's clamp never reaches this menu.
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
//
|
|
290
|
+
// ── All-layers menu ──────────────────────────────────────────────
|
|
291
|
+
// The trigger lists EVERY layer of the stack, topmost first, with
|
|
292
|
+
// their FULL names — the strip's clamp never reaches this menu. The
|
|
293
|
+
// layer the user is currently on is marked with the menu row's own
|
|
294
|
+
// selected face (HkMenuActionItem `active` → the unified menu-item
|
|
295
|
+
// grammar's [data-active] box). HkPopover owns the form-factor rules
|
|
296
|
+
// (anchored under the trigger on desktop, bottom-up sheet on mobile)
|
|
297
|
+
// and registers the surface with the popup manager, so the menu is a
|
|
298
|
+
// first-class layer of the very stack it lists — and must not list
|
|
299
|
+
// ITSELF: the popover's `registered` handle tells this strip which
|
|
300
|
+
// layer to drop from the rows.
|
|
297
301
|
const menuOpen = ref(false);
|
|
298
|
-
const
|
|
299
|
-
const menuLabel = computed(() => t("hikari::modal.
|
|
302
|
+
const menuSheetId = ref<string | null>(null);
|
|
303
|
+
const menuLabel = computed(() => t("hikari::modal.allLayers", "All layers"));
|
|
304
|
+
|
|
305
|
+
/** Live rows: every stack layer but the menu's own surface, topmost
|
|
306
|
+
* first (reverse stack order — the current layer leads the list).
|
|
307
|
+
* Live, not a snapshot, so an anchored desktop menu keeps agreeing
|
|
308
|
+
* with a stack that changed underneath it. */
|
|
309
|
+
const menuItems = computed<{ id: string; label: string; current: boolean }[]>(() => {
|
|
310
|
+
const layers = stackEntries.value.filter((entry) => entry.id !== menuSheetId.value);
|
|
311
|
+
return layers
|
|
312
|
+
.slice()
|
|
313
|
+
.reverse()
|
|
314
|
+
.map((entry, i) => ({
|
|
315
|
+
id: entry.id,
|
|
316
|
+
label:
|
|
317
|
+
entry.title ||
|
|
318
|
+
(entry.kind === "dropdown"
|
|
319
|
+
? t("hikari::modal.unnamedSheet", "Menu")
|
|
320
|
+
: t("hikari::modal.unnamedWindow", "Window")),
|
|
321
|
+
current: i === 0,
|
|
322
|
+
}));
|
|
323
|
+
});
|
|
300
324
|
|
|
301
325
|
/** The trigger must stay mounted while its menu is open (the popover
|
|
302
326
|
* anchors to it); a stack change that unfolds everything mid-menu
|
|
@@ -304,29 +328,37 @@ export default defineComponent({
|
|
|
304
328
|
const triggerShown = computed(() => folded.value || menuOpen.value);
|
|
305
329
|
|
|
306
330
|
function openMenu(): void {
|
|
307
|
-
if (!
|
|
331
|
+
if (!crumbs.value.length) return;
|
|
308
332
|
// The two surfaces are never open together — and the symmetric close
|
|
309
333
|
// matters on a phone: the strip paints above the sheet's scrim, so
|
|
310
334
|
// its trigger stays tappable while a reveal is docked (review round
|
|
311
335
|
// two), and a menu opening underneath it would leave the pair
|
|
312
336
|
// fighting over one Escape.
|
|
313
337
|
revealed.value = null;
|
|
314
|
-
menuItems.value = hidden.value.map(({ id, label }) => ({ id, label }));
|
|
315
338
|
menuOpen.value = true;
|
|
316
339
|
}
|
|
317
340
|
|
|
318
|
-
/** Jump
|
|
341
|
+
/** Jump to a listed layer: the menu closes and everything stacked
|
|
319
342
|
* ABOVE the chosen level is asked to close, top-down. The close rides
|
|
320
343
|
* the popup manager's per-entry close channel, so a layer whose owner
|
|
321
344
|
* registered none simply stays — the strip never guesses at foreign
|
|
322
|
-
* state.
|
|
345
|
+
* state. The CURRENT layer sits on top with nothing above it, so
|
|
346
|
+
* picking it just dismisses the menu. */
|
|
323
347
|
function selectLayer(id: string): void {
|
|
324
348
|
menuOpen.value = false;
|
|
325
349
|
manager.closeAbove(id);
|
|
326
350
|
}
|
|
327
351
|
|
|
328
|
-
//
|
|
329
|
-
// drop the
|
|
352
|
+
// The menu's own sheet id is only meaningful while that surface lives;
|
|
353
|
+
// drop it when the surface goes so a FUTURE popover registration is
|
|
354
|
+
// never mistaken for "the menu".
|
|
355
|
+
function onMenuClosed(): void {
|
|
356
|
+
menuSheetId.value = null;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Unfolding everything while the menu is open dissolves the trigger's
|
|
360
|
+
// reason to exist — the strip itself now shows every layer, so the
|
|
361
|
+
// anchored pair steps aside instead of duplicating it.
|
|
330
362
|
watch(folded, (stillFolded) => {
|
|
331
363
|
if (!stillFolded) menuOpen.value = false;
|
|
332
364
|
});
|
|
@@ -702,12 +734,20 @@ export default defineComponent({
|
|
|
702
734
|
sheetOnMobile
|
|
703
735
|
title={menuLabel.value}
|
|
704
736
|
class="hk-modal-breadcrumb-menu"
|
|
737
|
+
// The menu's own registry handle: while the sheet form blocks,
|
|
738
|
+
// it is a layer of the very stack this menu lists — the rows
|
|
739
|
+
// must skip it (selecting "the menu" is not a place to go).
|
|
740
|
+
onRegistered={(handle: { id: string }) => {
|
|
741
|
+
menuSheetId.value = handle.id;
|
|
742
|
+
}}
|
|
743
|
+
onClosed={onMenuClosed}
|
|
705
744
|
>
|
|
706
745
|
<HkMenuPanel label={menuLabel.value}>
|
|
707
746
|
{menuItems.value.map((item) => (
|
|
708
747
|
<HkMenuActionItem
|
|
709
748
|
key={item.id}
|
|
710
749
|
label={item.label}
|
|
750
|
+
active={item.current}
|
|
711
751
|
onClick={() => selectLayer(item.id)}
|
|
712
752
|
/>
|
|
713
753
|
))}
|
|
@@ -85,6 +85,12 @@ export default defineComponent({
|
|
|
85
85
|
},
|
|
86
86
|
emits: {
|
|
87
87
|
"update:modelValue": (_v: boolean) => true,
|
|
88
|
+
/** Fired when the surface joins the popup registry — the handle
|
|
89
|
+
* (id + zIndex) is the surface's identity as a stack layer. Hosts
|
|
90
|
+
* that reason about the WINDOW STACK (the modal breadcrumb listing
|
|
91
|
+
* every layer but its own sheet) use this edge to tell the
|
|
92
|
+
* popover's layer apart from the windows it floats above. */
|
|
93
|
+
registered: (_handle: PopupHandle) => true,
|
|
88
94
|
/** Fired once the close has fully settled (the leave transition
|
|
89
95
|
* completed and the panel left the DOM) — the moment focus that
|
|
90
96
|
* lived inside the closing panel falls back to <body>. Hosts that
|
|
@@ -169,13 +175,15 @@ export default defineComponent({
|
|
|
169
175
|
// they govern accidental DISMISSAL GESTURES, not closability, and
|
|
170
176
|
// the sheet always renders its own ✕ (this popover has no
|
|
171
177
|
// `closable` to mirror HkModal's).
|
|
172
|
-
|
|
178
|
+
const registered = manager.register(
|
|
173
179
|
"dropdown",
|
|
174
180
|
false,
|
|
175
181
|
props.title || undefined,
|
|
176
182
|
sheetMode.value,
|
|
177
183
|
close,
|
|
178
184
|
);
|
|
185
|
+
handle.value = registered;
|
|
186
|
+
emit("registered", registered);
|
|
179
187
|
if (sheetMode.value) {
|
|
180
188
|
// Bottom sheet: nothing to anchor-measure; the scrim handles
|
|
181
189
|
// dismissal (the outside-click shield is desktop-only).
|
|
@@ -96,7 +96,12 @@
|
|
|
96
96
|
cursor: pointer;
|
|
97
97
|
user-select: none;
|
|
98
98
|
-webkit-user-select: none;
|
|
99
|
-
|
|
99
|
+
/* Same policy as the canonical reset; keeps standalone component styles
|
|
100
|
+
working and lets the document-level native escape hatch through. */
|
|
101
|
+
-webkit-tap-highlight-color: var(--hk-tap-highlight-color, transparent);
|
|
102
|
+
html[data-hk-tap-highlight='native'] & {
|
|
103
|
+
-webkit-tap-highlight-color: revert;
|
|
104
|
+
}
|
|
100
105
|
appearance: none;
|
|
101
106
|
transition: color 0.15s ease, background 0.15s ease, transform 0.12s ease;
|
|
102
107
|
}
|
|
@@ -133,7 +133,13 @@
|
|
|
133
133
|
appearance: none;
|
|
134
134
|
user-select: none;
|
|
135
135
|
-webkit-user-select: none;
|
|
136
|
-
|
|
136
|
+
/* Ride the canonical reset policy: this component sheet is also exported
|
|
137
|
+
standalone, so it keeps the token fallback (transparent) and honours the
|
|
138
|
+
document-level native escape hatch. */
|
|
139
|
+
-webkit-tap-highlight-color: var(--hk-tap-highlight-color, transparent);
|
|
140
|
+
html[data-hk-tap-highlight='native'] & {
|
|
141
|
+
-webkit-tap-highlight-color: revert;
|
|
142
|
+
}
|
|
137
143
|
transition: background-color 0.12s ease, border-color 0.12s ease, color 0.12s ease;
|
|
138
144
|
|
|
139
145
|
&:hover:not(:disabled):not([data-disabled]):not([data-active]):not([data-checked]) {
|
package/src/demo.ts
CHANGED
|
@@ -3,8 +3,9 @@ import { createApp } from 'vue'
|
|
|
3
3
|
import { initTheme } from './theme/useTheme'
|
|
4
4
|
import * as hikari from './index'
|
|
5
5
|
|
|
6
|
-
// Mobile UX contract (viewport zoom never refused; tap-highlight
|
|
7
|
-
//
|
|
6
|
+
// Mobile UX contract (viewport zoom never refused; the tap-highlight reset
|
|
7
|
+
// lives in the canonical host reset, styles/reset.scss). Applied at
|
|
8
|
+
// bootstrap before mount, per the hook contract.
|
|
8
9
|
hikari.applyViewportPolicy({ allowZoomOut: true })
|
|
9
10
|
|
|
10
11
|
localStorage.setItem('hikari-theme', 'default')
|
|
@@ -240,7 +240,7 @@
|
|
|
240
240
|
"hikari::tagInput.remove": "إزالة",
|
|
241
241
|
"hikari::tagInput.addCustom": "استخدام \"{q}\"",
|
|
242
242
|
"hikari::tagInput.reorder": "إعادة الترتيب — {label}",
|
|
243
|
-
"hikari::modal.
|
|
243
|
+
"hikari::modal.allLayers": "جميع الطبقات",
|
|
244
244
|
"hikari::theme.modeTabHint": "هذه التبويبات تختار لوحة الألوان التي تحرّرها؛ لتبديل الوضع الفاتح/الداكن للتطبيق كله استخدم شريط الأوضاع أعلى نافذة السمة",
|
|
245
245
|
"hikari::theme.defaultThemeName": "افتراضي",
|
|
246
246
|
"hikari::emptyState.loading": "جارٍ التحميل",
|
|
@@ -240,7 +240,7 @@
|
|
|
240
240
|
"hikari::tagInput.remove": "Entfernen",
|
|
241
241
|
"hikari::tagInput.addCustom": "„{q}“ verwenden",
|
|
242
242
|
"hikari::tagInput.reorder": "Neu anordnen — {label}",
|
|
243
|
-
"hikari::modal.
|
|
243
|
+
"hikari::modal.allLayers": "Alle Ebenen",
|
|
244
244
|
"hikari::theme.modeTabHint": "Diese Reiter wählen die zu bearbeitende Palette; die appweite Hell/Dunkel-Umschaltung liegt im Modusstreifen des Themen-Popups",
|
|
245
245
|
"hikari::theme.defaultThemeName": "Standard",
|
|
246
246
|
"hikari::emptyState.loading": "Wird geladen",
|
|
@@ -240,7 +240,7 @@
|
|
|
240
240
|
"hikari::tagInput.remove": "Remove",
|
|
241
241
|
"hikari::tagInput.addCustom": "Use “{q}”",
|
|
242
242
|
"hikari::tagInput.reorder": "Reorder {label}",
|
|
243
|
-
"hikari::modal.
|
|
243
|
+
"hikari::modal.allLayers": "All layers",
|
|
244
244
|
"hikari::theme.modeTabHint": "These tabs pick which palette you are EDITING; to switch the app-wide light/dark, use the mode strip at the top of the theme popup",
|
|
245
245
|
"hikari::theme.defaultThemeName": "Default",
|
|
246
246
|
"hikari::emptyState.loading": "Loading",
|
|
@@ -240,7 +240,7 @@
|
|
|
240
240
|
"hikari::tagInput.remove": "Quitar",
|
|
241
241
|
"hikari::tagInput.addCustom": "Usar «{q}»",
|
|
242
242
|
"hikari::tagInput.reorder": "Reordenar — {label}",
|
|
243
|
-
"hikari::modal.
|
|
243
|
+
"hikari::modal.allLayers": "Todas las capas",
|
|
244
244
|
"hikari::theme.modeTabHint": "Estas pestañas eligen la paleta que estás EDITANDO; para cambiar el modo claro/oscuro de toda la app, usa la franja de modos de la parte superior del panel de temas",
|
|
245
245
|
"hikari::theme.defaultThemeName": "Predeterminado",
|
|
246
246
|
"hikari::emptyState.loading": "Cargando",
|
|
@@ -240,7 +240,7 @@
|
|
|
240
240
|
"hikari::tagInput.remove": "Retirer",
|
|
241
241
|
"hikari::tagInput.addCustom": "Utiliser « {q} »",
|
|
242
242
|
"hikari::tagInput.reorder": "Réorganiser — {label}",
|
|
243
|
-
"hikari::modal.
|
|
243
|
+
"hikari::modal.allLayers": "Toutes les couches",
|
|
244
244
|
"hikari::theme.modeTabHint": "Ces onglets choisissent la palette éditée ; pour basculer clair/sombre dans toute l’app, utilisez la barre de mode du menu de thème",
|
|
245
245
|
"hikari::theme.defaultThemeName": "Par défaut",
|
|
246
246
|
"hikari::emptyState.loading": "Chargement",
|
|
@@ -240,7 +240,7 @@
|
|
|
240
240
|
"hikari::tagInput.remove": "削除",
|
|
241
241
|
"hikari::tagInput.addCustom": "「{q}」を使用",
|
|
242
242
|
"hikari::tagInput.reorder": "並べ替え — {label}",
|
|
243
|
-
"hikari::modal.
|
|
243
|
+
"hikari::modal.allLayers": "すべての階層",
|
|
244
244
|
"hikari::theme.modeTabHint": "このタブは編集対象のパレットを選ぶだけです。アプリ全体の明/暗はテーマポップアップ上部のモードストリップで切り替えてください",
|
|
245
245
|
"hikari::theme.defaultThemeName": "デフォルト",
|
|
246
246
|
"hikari::emptyState.loading": "読み込み中",
|
|
@@ -240,7 +240,7 @@
|
|
|
240
240
|
"hikari::tagInput.remove": "제거",
|
|
241
241
|
"hikari::tagInput.addCustom": "\"{q}\" 사용",
|
|
242
242
|
"hikari::tagInput.reorder": "순서 변경 — {label}",
|
|
243
|
-
"hikari::modal.
|
|
243
|
+
"hikari::modal.allLayers": "모든 레이어",
|
|
244
244
|
"hikari::theme.modeTabHint": "이 탭은 편집할 팔레트를 고를 뿐입니다. 앱 전체 밝기/어둠은 테마 팝업 상단의 모드 스트립에서 바꾸세요",
|
|
245
245
|
"hikari::theme.defaultThemeName": "기본값",
|
|
246
246
|
"hikari::emptyState.loading": "불러오는 중",
|
|
@@ -240,7 +240,7 @@
|
|
|
240
240
|
"hikari::tagInput.remove": "Remover",
|
|
241
241
|
"hikari::tagInput.addCustom": "Usar \"{q}\"",
|
|
242
242
|
"hikari::tagInput.reorder": "Reordenar — {label}",
|
|
243
|
-
"hikari::modal.
|
|
243
|
+
"hikari::modal.allLayers": "Todas as camadas",
|
|
244
244
|
"hikari::theme.modeTabHint": "Estas abas escolhem a paleta que você está EDITANDO; para alternar o claro/escuro do app inteiro, use a faixa de modos no topo do painel de temas",
|
|
245
245
|
"hikari::theme.defaultThemeName": "Padrão",
|
|
246
246
|
"hikari::emptyState.loading": "Carregando",
|
|
@@ -240,7 +240,7 @@
|
|
|
240
240
|
"hikari::tagInput.remove": "Удалить",
|
|
241
241
|
"hikari::tagInput.addCustom": "Использовать «{q}»",
|
|
242
242
|
"hikari::tagInput.reorder": "Изменить порядок — {label}",
|
|
243
|
-
"hikari::modal.
|
|
243
|
+
"hikari::modal.allLayers": "Все слои",
|
|
244
244
|
"hikari::theme.modeTabHint": "Вкладки выбирают редактируемую палитру; общесистемное переключение светлой/тёмной — в полосе режимов вверху меню темы",
|
|
245
245
|
"hikari::theme.defaultThemeName": "По умолчанию",
|
|
246
246
|
"hikari::emptyState.loading": "Загрузка",
|
|
@@ -244,7 +244,7 @@
|
|
|
244
244
|
"hikari::tagInput.remove": "移除",
|
|
245
245
|
"hikari::tagInput.addCustom": "使用「{q}」",
|
|
246
246
|
"hikari::tagInput.reorder": "拖动排序 — {label}",
|
|
247
|
-
"hikari::modal.
|
|
247
|
+
"hikari::modal.allLayers": "所有层级",
|
|
248
248
|
"hikari::theme.modeTabHint": "切换这里编辑的是对应模式的调色板;要切换应用整体深/浅,请用主题弹窗顶部的模式条",
|
|
249
249
|
"hikari::theme.defaultThemeName": "默认",
|
|
250
250
|
"hikari::emptyState.loading": "加载中",
|
|
@@ -244,7 +244,7 @@
|
|
|
244
244
|
"hikari::tagInput.remove": "移除",
|
|
245
245
|
"hikari::tagInput.addCustom": "使用「{q}」",
|
|
246
246
|
"hikari::tagInput.reorder": "拖曳排序 — {label}",
|
|
247
|
-
"hikari::modal.
|
|
247
|
+
"hikari::modal.allLayers": "所有層級",
|
|
248
248
|
"hikari::theme.modeTabHint": "切換這裡編輯的是對應模式的調色盤;要切換應用整體深/淺,請用主題彈窗頂部的模式條",
|
|
249
249
|
"hikari::theme.defaultThemeName": "預設",
|
|
250
250
|
"hikari::emptyState.loading": "載入中",
|
|
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* The `hikari::modal.*` family is the window chrome's own vocabulary
|
|
5
|
-
* (stack label, unnamed-window/sheet fallbacks, the
|
|
5
|
+
* (stack label, unnamed-window/sheet fallbacks, the all-layers menu),
|
|
6
6
|
* referenced from source by literal key with an English fallback.
|
|
7
7
|
*
|
|
8
8
|
* flatKeyParity.test.ts proves the locales AGREE with en — but it takes
|
|
@@ -23,7 +23,7 @@ const REQUIRED_KEYS = [
|
|
|
23
23
|
"hikari::modal.stackLabel",
|
|
24
24
|
"hikari::modal.unnamedSheet",
|
|
25
25
|
"hikari::modal.unnamedWindow",
|
|
26
|
-
"hikari::modal.
|
|
26
|
+
"hikari::modal.allLayers",
|
|
27
27
|
];
|
|
28
28
|
|
|
29
29
|
describe("hikari::modal locale keys", () => {
|
package/src/index.ts
CHANGED
|
@@ -391,6 +391,9 @@ export {
|
|
|
391
391
|
type HkTooltipBridgeOptions,
|
|
392
392
|
installHkImageFallback,
|
|
393
393
|
type HkImageFallbackOptions,
|
|
394
|
+
installHkTapHighlight,
|
|
395
|
+
type HkTapHighlightOptions,
|
|
396
|
+
type HkTapHighlightColor,
|
|
394
397
|
reportHkRuntime,
|
|
395
398
|
getHkRuntimeEntry,
|
|
396
399
|
listHkRuntime,
|
package/src/runtime/index.ts
CHANGED
|
@@ -12,6 +12,11 @@ export {
|
|
|
12
12
|
installHkImageFallback,
|
|
13
13
|
type HkImageFallbackOptions,
|
|
14
14
|
} from "./imageFallback";
|
|
15
|
+
export {
|
|
16
|
+
installHkTapHighlight,
|
|
17
|
+
type HkTapHighlightOptions,
|
|
18
|
+
type HkTapHighlightColor,
|
|
19
|
+
} from "./tapHighlight";
|
|
15
20
|
export {
|
|
16
21
|
tooltipPositionStyle,
|
|
17
22
|
applyTooltipPosition,
|
|
@@ -84,9 +84,11 @@ function clampOutScale(scale: number): string {
|
|
|
84
84
|
* Normalize the document's viewport meta so mobile browsers never refuse
|
|
85
85
|
* user zoom. Idempotent; safe to call during app bootstrap before mount.
|
|
86
86
|
*
|
|
87
|
-
* The tap-highlight half of the mobile UX contract lives in the
|
|
88
|
-
*
|
|
89
|
-
*
|
|
87
|
+
* The tap-highlight half of the mobile UX contract lives in the canonical
|
|
88
|
+
* host reset (`styles/reset.scss`, composed first by `styles/index.scss`):
|
|
89
|
+
* it clears `-webkit-tap-highlight-color` at the root, and
|
|
90
|
+
* `installHkTapHighlight()` is the runtime policy layer for hosts that
|
|
91
|
+
* need a themed tint or the iOS :active enabler.
|
|
90
92
|
*/
|
|
91
93
|
export function applyViewportPolicy(
|
|
92
94
|
options: ApplyViewportPolicyOptions = {},
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { getHkRuntimeEntry, readHkRuntime } from "./registry";
|
|
4
|
+
import { installHkTapHighlight } from "./tapHighlight";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* installHkTapHighlight contract tests:
|
|
8
|
+
* - default policy clears the UA overlay at the root
|
|
9
|
+
* - a CSS colour keeps the mechanism and tints it
|
|
10
|
+
* - "native" restores the browser default through the data attribute
|
|
11
|
+
* - invalid colours fall back to transparent instead of the UA default
|
|
12
|
+
* - the iOS :active enabler is a passive document touchstart, removable
|
|
13
|
+
* - install is idempotent per document; uninstall restores prior inline
|
|
14
|
+
* state and disposes the registry entry
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const TOKEN = "--hk-tap-highlight-color";
|
|
18
|
+
const NATIVE_ATTR = "data-hk-tap-highlight";
|
|
19
|
+
|
|
20
|
+
const uninstalls: Array<() => void> = [];
|
|
21
|
+
|
|
22
|
+
function install(options?: Parameters<typeof installHkTapHighlight>[0]) {
|
|
23
|
+
const uninstall = installHkTapHighlight(options);
|
|
24
|
+
uninstalls.push(uninstall);
|
|
25
|
+
return uninstall;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function root(): HTMLElement {
|
|
29
|
+
return document.documentElement;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
for (const uninstall of uninstalls.splice(0)) uninstall();
|
|
34
|
+
vi.unstubAllGlobals();
|
|
35
|
+
vi.restoreAllMocks();
|
|
36
|
+
root().removeAttribute("style");
|
|
37
|
+
root().removeAttribute(NATIVE_ATTR);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("installHkTapHighlight", () => {
|
|
41
|
+
it("clears the UA overlay at the root by default", () => {
|
|
42
|
+
install();
|
|
43
|
+
expect(root().style.getPropertyValue(TOKEN)).toBe("transparent");
|
|
44
|
+
expect(root().hasAttribute(NATIVE_ATTR)).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("keeps a themed tint when a colour is given", () => {
|
|
48
|
+
install({ color: "rgb(47 111 237 / 12%)" });
|
|
49
|
+
expect(root().style.getPropertyValue(TOKEN)).toBe(
|
|
50
|
+
"rgb(47 111 237 / 12%)",
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("restores the UA default through the native escape hatch", () => {
|
|
55
|
+
install({ color: "native" });
|
|
56
|
+
expect(root().getAttribute(NATIVE_ATTR)).toBe("native");
|
|
57
|
+
expect(root().style.getPropertyValue(TOKEN)).toBe("");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("falls back to transparent for an invalid colour", () => {
|
|
61
|
+
vi.stubGlobal("CSS", { supports: () => false });
|
|
62
|
+
install({ color: "not-a-colour" });
|
|
63
|
+
expect(root().style.getPropertyValue(TOKEN)).toBe("transparent");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("adds a passive touchstart enabler and removes it on uninstall", () => {
|
|
67
|
+
const add = vi.spyOn(document, "addEventListener");
|
|
68
|
+
const remove = vi.spyOn(document, "removeEventListener");
|
|
69
|
+
const uninstall = install();
|
|
70
|
+
expect(add).toHaveBeenCalledWith("touchstart", expect.any(Function), {
|
|
71
|
+
passive: true,
|
|
72
|
+
});
|
|
73
|
+
uninstall();
|
|
74
|
+
expect(remove).toHaveBeenCalledWith("touchstart", expect.any(Function));
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("can opt out of the active-state enabler", () => {
|
|
78
|
+
const add = vi.spyOn(document, "addEventListener");
|
|
79
|
+
install({ enableActiveState: false });
|
|
80
|
+
expect(add).not.toHaveBeenCalledWith(
|
|
81
|
+
"touchstart",
|
|
82
|
+
expect.any(Function),
|
|
83
|
+
expect.anything(),
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("restores a pre-existing inline token and attribute on uninstall", () => {
|
|
88
|
+
root().style.setProperty(TOKEN, "red");
|
|
89
|
+
root().setAttribute(NATIVE_ATTR, "native");
|
|
90
|
+
const uninstall = install({ color: "blue" });
|
|
91
|
+
expect(root().style.getPropertyValue(TOKEN)).toBe("blue");
|
|
92
|
+
expect(root().hasAttribute(NATIVE_ATTR)).toBe(false);
|
|
93
|
+
uninstall();
|
|
94
|
+
expect(root().style.getPropertyValue(TOKEN)).toBe("red");
|
|
95
|
+
expect(root().getAttribute(NATIVE_ATTR)).toBe("native");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("is idempotent per document (second install keeps the first policy)", () => {
|
|
99
|
+
install({ color: "red" });
|
|
100
|
+
install({ color: "blue" });
|
|
101
|
+
expect(root().style.getPropertyValue(TOKEN)).toBe("red");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("treats an uninstaller from a replaced install as a no-op", () => {
|
|
105
|
+
const first = install({ color: "red" });
|
|
106
|
+
first();
|
|
107
|
+
install({ color: "blue" });
|
|
108
|
+
first();
|
|
109
|
+
expect(root().style.getPropertyValue(TOKEN)).toBe("blue");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("reports the effective colour and disposes the registry entry", () => {
|
|
113
|
+
vi.stubGlobal("CSS", { supports: () => false });
|
|
114
|
+
const uninstall = install({
|
|
115
|
+
color: "not-a-colour",
|
|
116
|
+
enableActiveState: false,
|
|
117
|
+
});
|
|
118
|
+
expect(readHkRuntime("tapHighlight")).toMatchObject({
|
|
119
|
+
color: "none",
|
|
120
|
+
requestedColor: "not-a-colour",
|
|
121
|
+
});
|
|
122
|
+
expect(getHkRuntimeEntry("tapHighlight")?.status).toBe("active");
|
|
123
|
+
uninstall();
|
|
124
|
+
expect(getHkRuntimeEntry("tapHighlight")?.status).toBe("disposed");
|
|
125
|
+
expect(readHkRuntime("tapHighlight")).toBeUndefined();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("no-ops without a DOM", () => {
|
|
129
|
+
vi.stubGlobal("document", undefined);
|
|
130
|
+
const uninstall = installHkTapHighlight();
|
|
131
|
+
expect(typeof uninstall).toBe("function");
|
|
132
|
+
expect(() => uninstall()).not.toThrow();
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// installHkTapHighlight — document-level policy for the UA tap highlight.
|
|
2
|
+
//
|
|
3
|
+
// Blink/WebKit paint a platform-owned translucent overlay over the tapped
|
|
4
|
+
// node (Android Chrome measures rgba(51, 181, 229, 0.4) in mobile
|
|
5
|
+
// emulation). The canonical host reset (styles/reset.scss) already clears
|
|
6
|
+
// it at the root and exposes two declarative knobs:
|
|
7
|
+
//
|
|
8
|
+
// --hk-tap-highlight-color: transparent → no overlay (the default)
|
|
9
|
+
// --hk-tap-highlight-color: <CSS color> → keep the feedback, tint it
|
|
10
|
+
// html[data-hk-tap-highlight="native"] → restore the UA default
|
|
11
|
+
//
|
|
12
|
+
// This hook is the runtime policy layer on top of that contract. It also
|
|
13
|
+
// enables `:active` on iOS Safari: WebKit only applies the pseudo-class
|
|
14
|
+
// when a touch listener exists (WebKit bug 138816), so clearing the UA
|
|
15
|
+
// overlay must not leave taps without a pressed state.
|
|
16
|
+
//
|
|
17
|
+
// Lifecycle mirrors installHkImageFallback: idempotent per document,
|
|
18
|
+
// returns the uninstaller, restores the previous inline token/attribute,
|
|
19
|
+
// and reports itself to the runtime registry.
|
|
20
|
+
|
|
21
|
+
import { reportHkRuntime } from "./registry";
|
|
22
|
+
import type { HkRuntimeHandle } from "./registry";
|
|
23
|
+
|
|
24
|
+
export type HkTapHighlightColor = "none" | "native" | (string & {});
|
|
25
|
+
|
|
26
|
+
export interface HkTapHighlightOptions {
|
|
27
|
+
/**
|
|
28
|
+
* "none" clears the UA overlay (the app convention); "native" restores
|
|
29
|
+
* the browser default; any other valid CSS color keeps the feedback but
|
|
30
|
+
* tints it — e.g. `rgb(var(--color-primary) / 12%)`.
|
|
31
|
+
*/
|
|
32
|
+
color?: HkTapHighlightColor;
|
|
33
|
+
/**
|
|
34
|
+
* Enable `:active` on iOS Safari with a passive document touchstart
|
|
35
|
+
* listener (default true). Removing the UA highlight makes the pressed
|
|
36
|
+
* state the only feedback, so this is on by default; hosts with their
|
|
37
|
+
* own touch handling may opt out.
|
|
38
|
+
*/
|
|
39
|
+
enableActiveState?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const TOKEN = "--hk-tap-highlight-color";
|
|
43
|
+
const NATIVE_ATTR = "data-hk-tap-highlight";
|
|
44
|
+
const TRANSPARENT = "transparent";
|
|
45
|
+
|
|
46
|
+
interface TapHighlightState {
|
|
47
|
+
doc: Document;
|
|
48
|
+
root: HTMLElement;
|
|
49
|
+
previousToken: string;
|
|
50
|
+
previousNative: string | null;
|
|
51
|
+
enableActiveState: boolean;
|
|
52
|
+
onTouchStart: EventListener;
|
|
53
|
+
installedColor: HkTapHighlightColor;
|
|
54
|
+
requestedColor: HkTapHighlightColor;
|
|
55
|
+
runtime?: HkRuntimeHandle;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const INSTALLS = new WeakMap<Document, TapHighlightState>();
|
|
59
|
+
|
|
60
|
+
function noop(): void {}
|
|
61
|
+
|
|
62
|
+
function normalizeColor(color: HkTapHighlightColor | undefined): {
|
|
63
|
+
token: string | null;
|
|
64
|
+
native: boolean;
|
|
65
|
+
effective: HkTapHighlightColor;
|
|
66
|
+
} {
|
|
67
|
+
if (color === undefined || color === "none") {
|
|
68
|
+
return { token: TRANSPARENT, native: false, effective: "none" };
|
|
69
|
+
}
|
|
70
|
+
if (color === "native") {
|
|
71
|
+
return { token: null, native: true, effective: "native" };
|
|
72
|
+
}
|
|
73
|
+
// Validate against the property so a bad host value cannot become an
|
|
74
|
+
// "invalid at computed-value time" flash of the UA default colour.
|
|
75
|
+
const supported =
|
|
76
|
+
typeof CSS === "undefined" ||
|
|
77
|
+
typeof CSS.supports !== "function" ||
|
|
78
|
+
CSS.supports("-webkit-tap-highlight-color", color);
|
|
79
|
+
return supported
|
|
80
|
+
? { token: color, native: false, effective: color }
|
|
81
|
+
: { token: TRANSPARENT, native: false, effective: "none" };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function uninstall(state: TapHighlightState): void {
|
|
85
|
+
// A teardown from a previous install must not clear the policy that
|
|
86
|
+
// replaced it (uninstall → reinstall → call the old uninstaller).
|
|
87
|
+
if (INSTALLS.get(state.doc) !== state) return;
|
|
88
|
+
INSTALLS.delete(state.doc);
|
|
89
|
+
if (state.enableActiveState) {
|
|
90
|
+
state.doc.removeEventListener("touchstart", state.onTouchStart);
|
|
91
|
+
}
|
|
92
|
+
if (state.previousToken) {
|
|
93
|
+
state.root.style.setProperty(TOKEN, state.previousToken);
|
|
94
|
+
} else {
|
|
95
|
+
state.root.style.removeProperty(TOKEN);
|
|
96
|
+
}
|
|
97
|
+
if (state.previousNative === null) {
|
|
98
|
+
state.root.removeAttribute(NATIVE_ATTR);
|
|
99
|
+
} else {
|
|
100
|
+
state.root.setAttribute(NATIVE_ATTR, state.previousNative);
|
|
101
|
+
}
|
|
102
|
+
state.runtime?.dispose();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Install the document-level tap-highlight policy. Returns the
|
|
107
|
+
* uninstaller; idempotent per document (a second call returns the
|
|
108
|
+
* existing teardown). No-op outside a DOM.
|
|
109
|
+
*/
|
|
110
|
+
export function installHkTapHighlight(
|
|
111
|
+
options: HkTapHighlightOptions = {},
|
|
112
|
+
): () => void {
|
|
113
|
+
if (typeof document === "undefined") return () => undefined;
|
|
114
|
+
const doc = document;
|
|
115
|
+
const existing = INSTALLS.get(doc);
|
|
116
|
+
if (existing) return () => uninstall(existing);
|
|
117
|
+
|
|
118
|
+
const root = doc.documentElement;
|
|
119
|
+
const { token, native, effective } = normalizeColor(options.color);
|
|
120
|
+
const enableActiveState = options.enableActiveState ?? true;
|
|
121
|
+
const requestedColor = options.color ?? "none";
|
|
122
|
+
const state: TapHighlightState = {
|
|
123
|
+
doc,
|
|
124
|
+
root,
|
|
125
|
+
previousToken: root.style.getPropertyValue(TOKEN),
|
|
126
|
+
previousNative: root.getAttribute(NATIVE_ATTR),
|
|
127
|
+
enableActiveState,
|
|
128
|
+
onTouchStart: noop,
|
|
129
|
+
installedColor: effective,
|
|
130
|
+
requestedColor,
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
if (token === null) {
|
|
134
|
+
root.style.removeProperty(TOKEN);
|
|
135
|
+
} else {
|
|
136
|
+
root.style.setProperty(TOKEN, token);
|
|
137
|
+
}
|
|
138
|
+
if (native) {
|
|
139
|
+
root.setAttribute(NATIVE_ATTR, "native");
|
|
140
|
+
} else {
|
|
141
|
+
root.removeAttribute(NATIVE_ATTR);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (enableActiveState) {
|
|
145
|
+
doc.addEventListener("touchstart", state.onTouchStart, { passive: true });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
state.runtime = reportHkRuntime("tapHighlight", {
|
|
149
|
+
kind: "hook",
|
|
150
|
+
description:
|
|
151
|
+
"Document-level mobile tap-highlight policy: clears or tints the UA overlay and enables :active on iOS.",
|
|
152
|
+
meta: {
|
|
153
|
+
color: state.installedColor,
|
|
154
|
+
requestedColor: state.requestedColor,
|
|
155
|
+
activeState: enableActiveState,
|
|
156
|
+
native,
|
|
157
|
+
},
|
|
158
|
+
read: () => ({
|
|
159
|
+
installed: true,
|
|
160
|
+
color: state.installedColor,
|
|
161
|
+
requestedColor: state.requestedColor,
|
|
162
|
+
activeState: enableActiveState,
|
|
163
|
+
native,
|
|
164
|
+
}),
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
INSTALLS.set(doc, state);
|
|
168
|
+
return () => uninstall(state);
|
|
169
|
+
}
|
package/src/styles/reset.scss
CHANGED
|
@@ -25,6 +25,25 @@
|
|
|
25
25
|
|
|
26
26
|
html {
|
|
27
27
|
line-height: 1.5;
|
|
28
|
+
|
|
29
|
+
/* Mobile tap highlight (UA-owned): Blink/WebKit paint a translucent
|
|
30
|
+
* overlay over the tapped node — Android Chrome measures
|
|
31
|
+
* rgba(51, 181, 229, 0.4) in mobile emulation. The property is
|
|
32
|
+
* inherited, so this root declaration covers every element (including
|
|
33
|
+
* shadow trees). Hosts opt into a themed tint with
|
|
34
|
+
* --hk-tap-highlight-color, or restore the UA default with
|
|
35
|
+
* data-hk-tap-highlight="native".
|
|
36
|
+
*
|
|
37
|
+
* This is the canonical home of the #325 tap-highlight contract. It
|
|
38
|
+
* first lived in the Gen-1 foundation sheet; #480 retired that sheet
|
|
39
|
+
* and #523/#861 replaced it with this reset, which then missed the
|
|
40
|
+
* rule — every host composing the canonical entry silently regained
|
|
41
|
+
* the blue flash. Keep it here, not in a host stylesheet. */
|
|
42
|
+
-webkit-tap-highlight-color: var(--hk-tap-highlight-color, transparent);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
html[data-hk-tap-highlight='native'] {
|
|
46
|
+
-webkit-tap-highlight-color: revert;
|
|
28
47
|
}
|
|
29
48
|
|
|
30
49
|
body {
|
|
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { dirname, resolve } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
|
|
5
|
+
import * as sass from "sass";
|
|
5
6
|
import { describe, expect, it } from "vitest";
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -31,6 +32,25 @@ describe("host reset sheet", () => {
|
|
|
31
32
|
expect(first).toBe("./reset.scss");
|
|
32
33
|
});
|
|
33
34
|
|
|
35
|
+
it("clears the UA tap highlight at the root (mobile parity)", () => {
|
|
36
|
+
// Regression guard for the #325 contract: #480 retired the Gen-1
|
|
37
|
+
// foundation sheet, and #523/#861 composed this reset without the
|
|
38
|
+
// tap rule, so chest/hydro silently re-gained the Android blue
|
|
39
|
+
// overlay. Removing either half of this contract must fail here.
|
|
40
|
+
//
|
|
41
|
+
// Assert on the COMPILED sheet (compressed drops both comment
|
|
42
|
+
// styles): raw-text matching would pass on a commented-out rule.
|
|
43
|
+
const { css } = sass.compile(resolve(stylesDir, "reset.scss"), {
|
|
44
|
+
style: "compressed",
|
|
45
|
+
});
|
|
46
|
+
expect(css).toMatch(
|
|
47
|
+
/html\s*\{[^}]*?-webkit-tap-highlight-color:\s*var\(--hk-tap-highlight-color,\s*transparent\)/,
|
|
48
|
+
);
|
|
49
|
+
expect(css).toMatch(
|
|
50
|
+
/html\[data-hk-tap-highlight=(?:"|')?native(?:"|')?\]\s*\{[^}]*?-webkit-tap-highlight-color:\s*revert/,
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
34
54
|
it("normalizes the native control typography chain", () => {
|
|
35
55
|
const reset = read("reset.scss");
|
|
36
56
|
for (const selector of ["button,", "input,", "textarea"]) {
|