@celestia-island/hikari 0.55.123 → 0.55.125
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/HkPopover.closed.test.tsx +86 -0
- package/src/components/HkPopover.tsx +20 -0
- package/src/components/HkTemplateField.scss +261 -0
- package/src/components/HkTemplateField.styles.test.ts +120 -0
- package/src/components/HkTemplateField.test.tsx +656 -0
- package/src/components/HkTemplateField.tsx +1085 -0
- package/src/components/HkTemplateText.test.tsx +77 -0
- package/src/components/HkTemplateText.tsx +70 -0
- package/src/components/templateGrammar.test.ts +110 -0
- package/src/components/templateGrammar.ts +98 -0
- package/src/i18n/locales/ar/components.json +5 -0
- package/src/i18n/locales/de/components.json +5 -0
- package/src/i18n/locales/en/components.json +5 -0
- package/src/i18n/locales/es/components.json +5 -0
- package/src/i18n/locales/fr/components.json +5 -0
- package/src/i18n/locales/ja/components.json +5 -0
- package/src/i18n/locales/ko/components.json +5 -0
- package/src/i18n/locales/pt/components.json +5 -0
- package/src/i18n/locales/ru/components.json +5 -0
- package/src/i18n/locales/zh-Hans/components.json +5 -0
- package/src/i18n/locales/zh-Hant/components.json +5 -0
- package/src/index.ts +12 -0
package/package.json
CHANGED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HkPopover `closed` contract: the settled-leave edge hosts reclaim
|
|
3
|
+
* focus from (the template field's Escape path). Pins:
|
|
4
|
+
*
|
|
5
|
+
* - a close emits `closed` exactly once, AFTER the panel has left
|
|
6
|
+
* the DOM (that ordering is the whole point — focus is only
|
|
7
|
+
* orphaned once the panel is gone);
|
|
8
|
+
* - opening emits nothing;
|
|
9
|
+
* - unmounting while open does NOT emit (`closed` means "closed",
|
|
10
|
+
* not "disposed" — the UNMOUNT phase skips the after-leave hook).
|
|
11
|
+
*/
|
|
12
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
13
|
+
import { createApp, h, nextTick, ref } from "vue";
|
|
14
|
+
|
|
15
|
+
import HkPopover from "./HkPopover";
|
|
16
|
+
|
|
17
|
+
const apps: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
for (const { app, container } of apps.splice(0)) {
|
|
21
|
+
app.unmount();
|
|
22
|
+
container.remove();
|
|
23
|
+
}
|
|
24
|
+
document.body
|
|
25
|
+
.querySelectorAll(".hk-popover-panel, .hk-popover-scrim")
|
|
26
|
+
.forEach((el) => el.remove());
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function mountPopover() {
|
|
30
|
+
const container = document.createElement("div");
|
|
31
|
+
document.body.appendChild(container);
|
|
32
|
+
const open = ref(false);
|
|
33
|
+
const closedCalls: boolean[] = [];
|
|
34
|
+
const app = createApp({
|
|
35
|
+
render: () =>
|
|
36
|
+
h(
|
|
37
|
+
HkPopover as never,
|
|
38
|
+
{
|
|
39
|
+
modelValue: open.value,
|
|
40
|
+
title: "probe",
|
|
41
|
+
"onUpdate:modelValue": (v: boolean) => { open.value = v; },
|
|
42
|
+
onClosed: () => closedCalls.push(!!document.body.querySelector(".hk-popover-panel")),
|
|
43
|
+
},
|
|
44
|
+
() => h("div", { class: "probe-body" }, "content"),
|
|
45
|
+
),
|
|
46
|
+
});
|
|
47
|
+
app.mount(container);
|
|
48
|
+
apps.push({ app, container });
|
|
49
|
+
return { open, closedCalls };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("HkPopover closed event", () => {
|
|
53
|
+
it("fires once on a settled close, with the panel already gone", async () => {
|
|
54
|
+
const { open, closedCalls } = mountPopover();
|
|
55
|
+
await nextTick();
|
|
56
|
+
expect(closedCalls).toHaveLength(0); // opening emits nothing
|
|
57
|
+
|
|
58
|
+
open.value = true;
|
|
59
|
+
await nextTick();
|
|
60
|
+
expect(document.body.querySelector(".hk-popover-panel")).not.toBeNull();
|
|
61
|
+
|
|
62
|
+
open.value = false;
|
|
63
|
+
await nextTick();
|
|
64
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
65
|
+
await nextTick();
|
|
66
|
+
|
|
67
|
+
expect(closedCalls).toHaveLength(1);
|
|
68
|
+
// The edge that matters: by the time `closed` ran, the panel had
|
|
69
|
+
// left the DOM (focus orphaned), not merely started leaving.
|
|
70
|
+
expect(closedCalls[0]).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("does not emit on unmount-while-open", async () => {
|
|
74
|
+
const { open, closedCalls } = mountPopover();
|
|
75
|
+
await nextTick();
|
|
76
|
+
open.value = true;
|
|
77
|
+
await nextTick();
|
|
78
|
+
expect(document.body.querySelector(".hk-popover-panel")).not.toBeNull();
|
|
79
|
+
|
|
80
|
+
apps.pop()!.app.unmount();
|
|
81
|
+
await nextTick();
|
|
82
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
83
|
+
await nextTick();
|
|
84
|
+
expect(closedCalls).toHaveLength(0);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -76,6 +76,18 @@ export default defineComponent({
|
|
|
76
76
|
},
|
|
77
77
|
emits: {
|
|
78
78
|
"update:modelValue": (_v: boolean) => true,
|
|
79
|
+
/** Fired once the close has fully settled (the leave transition
|
|
80
|
+
* completed and the panel left the DOM) — the moment focus that
|
|
81
|
+
* lived inside the closing panel falls back to <body>. Hosts that
|
|
82
|
+
* reclaim focus after an Escape-style close want THIS edge, not a
|
|
83
|
+
* timer: a fixed delay either races a long leave animation or
|
|
84
|
+
* waits past a short one.
|
|
85
|
+
*
|
|
86
|
+
* The emit rides a nextTick after the machine's closed phase, so a
|
|
87
|
+
* component UNMOUNTED in that same window drops it (Vue skips
|
|
88
|
+
* emits on unmounted instances) — a host that itself is going away
|
|
89
|
+
* has nothing to reclaim anyway. */
|
|
90
|
+
closed: () => true,
|
|
79
91
|
},
|
|
80
92
|
setup(props, { emit, slots }) {
|
|
81
93
|
const attrs = useAttrs();
|
|
@@ -450,6 +462,14 @@ export default defineComponent({
|
|
|
450
462
|
|
|
451
463
|
function onPopupAfterLeave() {
|
|
452
464
|
fullCleanup();
|
|
465
|
+
// Emit AFTER the patch that removes the panel: the machine's
|
|
466
|
+
// closed phase triggers the re-render, so emitting inline would
|
|
467
|
+
// run hosts' handlers while the panel is still in the DOM (and
|
|
468
|
+
// focus still sits inside it) — the literal meaning of "closed"
|
|
469
|
+
// is only true once the vnode has been patched away.
|
|
470
|
+
nextTick(() => {
|
|
471
|
+
emit("closed");
|
|
472
|
+
});
|
|
453
473
|
}
|
|
454
474
|
|
|
455
475
|
// Overlay scrollbar (shared chrome) — only the mobile sheet scrolls
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// HkTemplateField / HkTemplateText — the fill-template surfaces.
|
|
2
|
+
// Self-sufficient styling (house rule): the field box, label, hint and
|
|
3
|
+
// error rows live here rather than borrowing HkInput's sheet, and the
|
|
4
|
+
// chip is THE shared token visual — the editor's chips and the
|
|
5
|
+
// read-only renderer's chips are the same class, so a stored template
|
|
6
|
+
// reads identically wherever it appears.
|
|
7
|
+
|
|
8
|
+
.hk-template-field {
|
|
9
|
+
display: flex;
|
|
10
|
+
flex-direction: column;
|
|
11
|
+
gap: var(--space-4, 4px);
|
|
12
|
+
width: 100%;
|
|
13
|
+
|
|
14
|
+
&[data-disabled] {
|
|
15
|
+
opacity: 0.55;
|
|
16
|
+
pointer-events: none;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.hk-template-field-label {
|
|
21
|
+
font-size: var(--text-xs, 0.75rem);
|
|
22
|
+
font-weight: 500;
|
|
23
|
+
color: var(--hi-color-text-primary);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.hk-template-field-hint,
|
|
27
|
+
.hk-template-field-error {
|
|
28
|
+
margin: 0;
|
|
29
|
+
font-size: var(--text-2xs, 0.625rem);
|
|
30
|
+
line-height: 1.4;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.hk-template-field-hint {
|
|
34
|
+
color: var(--hi-color-muted);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.hk-template-field-error {
|
|
38
|
+
color: var(--hi-color-danger);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.hk-template-field-box {
|
|
42
|
+
box-sizing: border-box;
|
|
43
|
+
display: block;
|
|
44
|
+
width: 100%;
|
|
45
|
+
padding: var(--space-6, 0.375rem) var(--space-10, 0.625rem);
|
|
46
|
+
background: var(--hi-color-surface);
|
|
47
|
+
border: 1px solid var(--hi-color-border);
|
|
48
|
+
border-radius: 6px;
|
|
49
|
+
transition:
|
|
50
|
+
border-color var(--hi-transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1)),
|
|
51
|
+
box-shadow var(--hi-transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1));
|
|
52
|
+
|
|
53
|
+
&:hover {
|
|
54
|
+
border-color: var(--hi-color-primary);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
&:focus-within {
|
|
58
|
+
border-color: var(--hi-color-primary);
|
|
59
|
+
box-shadow: 0 0 0 3px color-mix(in srgb, var(--hi-color-primary) 18%, transparent);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.hk-template-field-box-error {
|
|
64
|
+
border-color: var(--hi-color-danger);
|
|
65
|
+
|
|
66
|
+
&:focus-within {
|
|
67
|
+
border-color: var(--hi-color-danger);
|
|
68
|
+
box-shadow: 0 0 0 3px color-mix(in srgb, var(--hi-color-danger) 15%, transparent);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.hk-template-field-edit {
|
|
73
|
+
// Slightly tighter than a plain input: chips carry their own padding,
|
|
74
|
+
// and the field must not read taller than the HkInput beside it.
|
|
75
|
+
min-height: calc(1.5rem + var(--space-4, 4px));
|
|
76
|
+
display: flex;
|
|
77
|
+
flex-wrap: wrap;
|
|
78
|
+
align-items: center;
|
|
79
|
+
gap: 2px 3px;
|
|
80
|
+
font-size: var(--text-base, 0.875rem);
|
|
81
|
+
line-height: 1.5;
|
|
82
|
+
color: var(--hi-color-text-primary);
|
|
83
|
+
outline: none;
|
|
84
|
+
word-break: break-all;
|
|
85
|
+
cursor: text;
|
|
86
|
+
|
|
87
|
+
&:empty::before {
|
|
88
|
+
content: attr(data-ph);
|
|
89
|
+
color: var(--hi-color-muted);
|
|
90
|
+
opacity: 0.6;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── The chip ─────────────────────────────────────────────────────────
|
|
95
|
+
// Monospace (it IS code — the exact text the host fills), primary
|
|
96
|
+
// tinted, unbreakable. Editable context adds the click affordance and
|
|
97
|
+
// the unknown marker both surfaces share.
|
|
98
|
+
|
|
99
|
+
.hk-tpl-chip {
|
|
100
|
+
display: inline-flex;
|
|
101
|
+
align-items: center;
|
|
102
|
+
padding: 0 var(--space-6, 6px);
|
|
103
|
+
border-radius: var(--radius-sm, 4px);
|
|
104
|
+
border: 1px solid color-mix(in srgb, var(--hi-color-primary) 32%, transparent);
|
|
105
|
+
background: color-mix(in srgb, var(--hi-color-primary) 12%, transparent);
|
|
106
|
+
color: color-mix(in srgb, var(--hi-color-primary) 70%, rgb(0 0 0));
|
|
107
|
+
font-family: var(--font-mono, monospace);
|
|
108
|
+
font-size: 0.8em;
|
|
109
|
+
line-height: 1.5;
|
|
110
|
+
white-space: nowrap;
|
|
111
|
+
user-select: none;
|
|
112
|
+
|
|
113
|
+
[data-theme="dark"] & {
|
|
114
|
+
color: color-mix(in srgb, var(--hi-color-primary) 60%, rgb(255 255 255));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
&[data-unknown] {
|
|
118
|
+
border-color: var(--hi-color-border);
|
|
119
|
+
background: color-mix(in srgb, var(--hi-color-warning) 12%, transparent);
|
|
120
|
+
color: var(--hi-color-muted);
|
|
121
|
+
font-style: italic;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.hk-template-field-edit .hk-tpl-chip {
|
|
126
|
+
cursor: pointer;
|
|
127
|
+
|
|
128
|
+
&:hover {
|
|
129
|
+
border-color: var(--hi-color-primary);
|
|
130
|
+
background: color-mix(in srgb, var(--hi-color-primary) 20%, transparent);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
&:focus-visible {
|
|
134
|
+
outline: 2px solid var(--hi-color-primary);
|
|
135
|
+
outline-offset: 1px;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.hk-template-text {
|
|
140
|
+
display: inline;
|
|
141
|
+
max-width: 100%;
|
|
142
|
+
|
|
143
|
+
&[data-multiline] {
|
|
144
|
+
display: block;
|
|
145
|
+
overflow-wrap: anywhere;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.hk-template-text-run {
|
|
150
|
+
white-space: pre-wrap;
|
|
151
|
+
overflow-wrap: anywhere;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Vocabulary rows (panel + chip editor share them) ────────────────
|
|
155
|
+
|
|
156
|
+
.hk-tpl-rows,
|
|
157
|
+
.hk-tpl-editor-rows {
|
|
158
|
+
display: flex;
|
|
159
|
+
flex-direction: column;
|
|
160
|
+
gap: 2px;
|
|
161
|
+
padding: var(--space-4, 4px);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.hk-tpl-group {
|
|
165
|
+
padding: var(--space-6, 6px) var(--space-8, 8px) var(--space-2, 2px);
|
|
166
|
+
font-size: var(--text-2xs, 0.625rem);
|
|
167
|
+
font-weight: 600;
|
|
168
|
+
letter-spacing: 0.02em;
|
|
169
|
+
// No text-transform: labels arrive from the host (usually i18n
|
|
170
|
+
// strings) and the translator owns the casing.
|
|
171
|
+
color: var(--hi-color-muted);
|
|
172
|
+
user-select: none;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.hk-tpl-row {
|
|
176
|
+
display: flex;
|
|
177
|
+
flex-direction: column;
|
|
178
|
+
align-items: flex-start;
|
|
179
|
+
gap: 1px;
|
|
180
|
+
padding: var(--space-4, 4px) var(--space-8, 8px);
|
|
181
|
+
border: none;
|
|
182
|
+
border-radius: var(--radius-sm, 4px);
|
|
183
|
+
background: transparent;
|
|
184
|
+
color: var(--hi-color-text-primary);
|
|
185
|
+
text-align: start;
|
|
186
|
+
cursor: pointer;
|
|
187
|
+
|
|
188
|
+
&:hover,
|
|
189
|
+
&[data-active] {
|
|
190
|
+
background: color-mix(in srgb, var(--hi-color-primary) 10%, transparent);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.hk-tpl-row-name {
|
|
195
|
+
font-family: var(--font-mono, monospace);
|
|
196
|
+
font-size: var(--text-xs, 0.75rem);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.hk-tpl-row-desc {
|
|
200
|
+
font-size: var(--text-2xs, 0.625rem);
|
|
201
|
+
color: var(--hi-color-muted);
|
|
202
|
+
line-height: 1.4;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.hk-tpl-rows-empty {
|
|
206
|
+
padding: var(--space-8, 8px) var(--space-12, 12px);
|
|
207
|
+
margin: 0;
|
|
208
|
+
font-size: var(--text-xs, 0.75rem);
|
|
209
|
+
color: var(--hi-color-muted);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── Chip editor form ────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
.hk-tpl-editor {
|
|
215
|
+
display: flex;
|
|
216
|
+
flex-direction: column;
|
|
217
|
+
gap: var(--space-8, 8px);
|
|
218
|
+
min-width: 16rem;
|
|
219
|
+
max-width: min(22rem, 90vw);
|
|
220
|
+
padding: var(--space-8, 8px);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.hk-tpl-editor-current {
|
|
224
|
+
display: flex;
|
|
225
|
+
align-items: baseline;
|
|
226
|
+
gap: var(--space-8, 8px);
|
|
227
|
+
flex-wrap: wrap;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
.hk-tpl-editor-desc {
|
|
231
|
+
font-size: var(--text-2xs, 0.625rem);
|
|
232
|
+
color: var(--hi-color-muted);
|
|
233
|
+
line-height: 1.4;
|
|
234
|
+
flex: 1 1 10rem;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ONE scrollbar per window (family contract): the mobile sheet's panel
|
|
238
|
+
// already scrolls the whole form through its own overlay scrollbar, so
|
|
239
|
+
// the rows viewport exists ONLY in the desktop popover (which sizes to
|
|
240
|
+
// content and otherwise has no scroll region at all). The viewport
|
|
241
|
+
// keeps native overflow MECHANICS but hides the native bar — the shared
|
|
242
|
+
// overlay scrollbar (attachOverlayScrollbars) draws the house chrome
|
|
243
|
+
// onto the dedicated host wrapper, which wraps exactly this viewport.
|
|
244
|
+
.hk-tpl-editor-scroll {
|
|
245
|
+
position: relative;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.hk-popover-panel:not(.hk-is-sheet) .hk-tpl-editor-rows {
|
|
249
|
+
max-height: min(16rem, 40dvh);
|
|
250
|
+
overflow-y: auto;
|
|
251
|
+
scrollbar-width: none;
|
|
252
|
+
|
|
253
|
+
&::-webkit-scrollbar {
|
|
254
|
+
display: none;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
.hk-tpl-editor-footer {
|
|
259
|
+
display: flex;
|
|
260
|
+
justify-content: flex-end;
|
|
261
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import { compile } from "sass";
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Compiled-stylesheet guard for the template surfaces. Vitest stubs CSS,
|
|
10
|
+
* so the runtime suite can never catch a lost rule — this test compiles
|
|
11
|
+
* the sheet directly and pins the pieces the runtime tests cannot see:
|
|
12
|
+
*
|
|
13
|
+
* 1. the editor rows' native scrollbar is HIDDEN inside the DESKTOP
|
|
14
|
+
* branch only (`scrollbar-width: none` + `::-webkit-scrollbar {
|
|
15
|
+
* display: none }` under `.hk-popover-panel:not(.hk-is-sheet)`) —
|
|
16
|
+
* the family draws its shared overlay chrome instead, and the
|
|
17
|
+
* mobile sheet must keep the panel as the ONE scroll region;
|
|
18
|
+
* 2. the scroll host is a positioning context (`position: relative`)
|
|
19
|
+
* for the overlay rails;
|
|
20
|
+
* 3. a curated class list checks TSX→CSS (no unstyled element) and a
|
|
21
|
+
* reverse scan flags any `hk-tpl-*`/`hk-template-*` selector in the
|
|
22
|
+
* sheet that no source file renders (no dead rule).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const componentDir = resolve(dirname(fileURLToPath(import.meta.url)));
|
|
26
|
+
|
|
27
|
+
function classTokensFrom(source: string): Set<string> {
|
|
28
|
+
const found = new Set<string>();
|
|
29
|
+
for (const match of source.matchAll(/\b(?:hk|s)-[\w-]+/g)) {
|
|
30
|
+
found.add(match[0]);
|
|
31
|
+
}
|
|
32
|
+
return found;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("HkTemplateField stylesheet contract", () => {
|
|
36
|
+
const tsx = readFileSync(resolve(componentDir, "HkTemplateField.tsx"), "utf8");
|
|
37
|
+
const rendererTsx = readFileSync(resolve(componentDir, "HkTemplateText.tsx"), "utf8");
|
|
38
|
+
const grammars = readFileSync(resolve(componentDir, "templateGrammar.ts"), "utf8");
|
|
39
|
+
const css = compile(resolve(componentDir, "HkTemplateField.scss"), {
|
|
40
|
+
style: "expanded",
|
|
41
|
+
}).css;
|
|
42
|
+
|
|
43
|
+
it("hides the native scrollbar of the editor rows viewport (desktop branch only)", () => {
|
|
44
|
+
// Both spellings: standard property + the WebKit pseudo-element,
|
|
45
|
+
// and both under the `:not(.hk-is-sheet)` scoping — a bare rule
|
|
46
|
+
// would also strip the sheet's native bar while no overlay is
|
|
47
|
+
// attached there (sheet attaches none; the panel owns its scroll).
|
|
48
|
+
expect(css).toMatch(
|
|
49
|
+
/\.hk-popover-panel:not\(\.hk-is-sheet\)[^{]*\.hk-tpl-editor-rows[^{]*\{[^}]*scrollbar-width:\s*none/s,
|
|
50
|
+
);
|
|
51
|
+
// The WebKit spelling gets the SAME desktop scoping (R2 M8c: the
|
|
52
|
+
// unscoped form of this rule survived every assertion).
|
|
53
|
+
expect(css).toMatch(
|
|
54
|
+
/\.hk-popover-panel:not\(\.hk-is-sheet\)[^{]*hk-tpl-editor-rows[^{]*::-webkit-scrollbar[^{]*\{[^}]*display:\s*none/s,
|
|
55
|
+
);
|
|
56
|
+
// EVERY rule that hides a native bar must carry the desktop scoping
|
|
57
|
+
// — regardless of how its selector is spelled. Two spellings hide a
|
|
58
|
+
// bar: `scrollbar-width: none` (a declaration, body) and a
|
|
59
|
+
// `::-webkit-scrollbar` marker (a SELECTOR, not a declaration — the
|
|
60
|
+
// R3 P3-1 correction). At-rule preludes (`@media …`) are skipped:
|
|
61
|
+
// the naive block splitter attributes an at-rule's inner rules to
|
|
62
|
+
// its prelude, which would false-positive on any future media query.
|
|
63
|
+
const rules = [...css.matchAll(/([^{}]+)\{([^}]*)\}/gs)];
|
|
64
|
+
let barHiding = 0;
|
|
65
|
+
for (const [, selector, body] of rules) {
|
|
66
|
+
const sel = selector ?? "";
|
|
67
|
+
if (sel.trimStart().startsWith("@")) continue;
|
|
68
|
+
const hidesBar = (body ?? "").includes("scrollbar-width: none") || sel.includes("::-webkit-scrollbar");
|
|
69
|
+
if (!hidesBar) continue;
|
|
70
|
+
barHiding += 1;
|
|
71
|
+
expect(sel, `bar-hiding rule must be sheet-scoped: ${sel}`).toContain(
|
|
72
|
+
":not(.hk-is-sheet)",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
// Extraction sanity (verification-loop rule: a 0-hit scan is a
|
|
76
|
+
// pattern bug until proven otherwise) — the sheet does hide the bar
|
|
77
|
+
// somewhere, so the loop above must have inspected at least one rule.
|
|
78
|
+
expect(barHiding).toBeGreaterThan(0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("has no dead template selector in the sheet", () => {
|
|
82
|
+
// Reverse direction: every `hk-tpl-*` / `hk-template-*` selector in
|
|
83
|
+
// the compiled CSS must appear in one of the two source files.
|
|
84
|
+
const sources = tsx + rendererTsx + grammars;
|
|
85
|
+
const selectors = new Set<string>();
|
|
86
|
+
for (const m of css.matchAll(/\.((?:hk-tpl|hk-template)[\w-]*)/g)) {
|
|
87
|
+
selectors.add(m[1]!);
|
|
88
|
+
}
|
|
89
|
+
expect(selectors.size).toBeGreaterThan(5); // extraction sanity (R1: never trust a 0-hit)
|
|
90
|
+
for (const sel of selectors) {
|
|
91
|
+
expect(sources.includes(sel), `no source renders ${sel}`).toBe(true);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("keeps group headings free of text-transform (the host owns casing)", () => {
|
|
96
|
+
// R2 M13: re-adding `text-transform: uppercase` used to stay green.
|
|
97
|
+
expect(css).not.toMatch(/\.hk-tpl-group[^{]*\{[^}]*text-transform/s);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("gives the scroll host a positioning context for the overlay rails", () => {
|
|
101
|
+
expect(css).toMatch(/\.hk-tpl-editor-scroll\s*\{[^}]*position:\s*relative/s);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("styles every editor class the TSX renders", () => {
|
|
105
|
+
const emitted = classTokensFrom(tsx);
|
|
106
|
+
for (const cls of ["hk-template-field", "hk-template-field-edit", "hk-template-field-box",
|
|
107
|
+
"hk-template-field-label", "hk-tpl-chip", "hk-tpl-row", "hk-tpl-rows", "hk-tpl-group",
|
|
108
|
+
"hk-tpl-editor", "hk-tpl-editor-rows", "hk-tpl-editor-scroll"]) {
|
|
109
|
+
expect(emitted.has(cls), `TSX renders ${cls}`).toBe(true);
|
|
110
|
+
expect(css.includes(`.${cls}`), `sheet styles ${cls}`).toBe(true);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("keeps the read-only renderer's classes styled by the shared sheet", () => {
|
|
115
|
+
expect(rendererTsx).toContain("hk-template-text");
|
|
116
|
+
expect(css.includes(".hk-template-text")).toBe(true);
|
|
117
|
+
// The read-only chips ride the SAME chip class as the editor's.
|
|
118
|
+
expect(rendererTsx).toContain("hk-tpl-chip");
|
|
119
|
+
});
|
|
120
|
+
});
|