@celestia-island/hikari 0.55.176 → 0.55.178
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/HkDraggableList.test.tsx +163 -1
- package/src/components/HkDraggableList.tsx +99 -19
- package/src/components/HkPopover.scss +29 -9
- package/src/components/HkPopover.sheetdock.test.ts +6 -3
- package/src/components/HkPopoverSheet.test.tsx +26 -0
- package/src/components/HkStatusBar.tsx +7 -2
- package/src/components/containerPad.styles.test.ts +23 -0
- package/src/i18n/locales/ar/components.json +2 -1
- package/src/i18n/locales/de/components.json +2 -1
- package/src/i18n/locales/en/components.json +2 -1
- package/src/i18n/locales/es/components.json +2 -1
- package/src/i18n/locales/fr/components.json +2 -1
- package/src/i18n/locales/ja/components.json +2 -1
- package/src/i18n/locales/ko/components.json +2 -1
- package/src/i18n/locales/pt/components.json +2 -1
- package/src/i18n/locales/ru/components.json +2 -1
- package/src/i18n/locales/zh-Hans/components.json +2 -1
- package/src/i18n/locales/zh-Hant/components.json +2 -1
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { createApp, h, nextTick, ref } from "vue";
|
|
3
3
|
|
|
4
4
|
import HDraggableList from "./HkDraggableList";
|
|
@@ -180,3 +180,165 @@ describe("HkDraggableList locked-lane contract", () => {
|
|
|
180
180
|
expect(root.hasAttribute("data-all-locked")).toBe(false);
|
|
181
181
|
});
|
|
182
182
|
});
|
|
183
|
+
|
|
184
|
+
describe("HkDraggableList list semantics", () => {
|
|
185
|
+
/** A reorderable list is a list for AT users whether or not the host
|
|
186
|
+
* names it: the root always carries role="list" and every row
|
|
187
|
+
* role="listitem" (the vocabulary a11y rulings #736/#737 applied to
|
|
188
|
+
* the picker surfaces — the drag list adopts the same contract). The
|
|
189
|
+
* accessible NAME stays the host's call: aria-label renders only when
|
|
190
|
+
* the `label` prop is passed, so an unnamed list is still structural
|
|
191
|
+
* and never announces an empty string. */
|
|
192
|
+
|
|
193
|
+
it("announces the root and rows as a list regardless of the label", () => {
|
|
194
|
+
const root = mountList(["a", "b"], []);
|
|
195
|
+
|
|
196
|
+
expect(root.getAttribute("role")).toBe("list");
|
|
197
|
+
expect(root.hasAttribute("aria-label")).toBe(false);
|
|
198
|
+
const rows = Array.from(root.querySelectorAll(".hk-draggable-list-item"));
|
|
199
|
+
expect(rows).toHaveLength(2);
|
|
200
|
+
expect(rows.every((row) => row.getAttribute("role") === "listitem")).toBe(true);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("names the list through the label prop", () => {
|
|
204
|
+
const container = document.createElement("div");
|
|
205
|
+
document.body.appendChild(container);
|
|
206
|
+
const app = createApp({
|
|
207
|
+
render: () =>
|
|
208
|
+
h(
|
|
209
|
+
HDraggableList,
|
|
210
|
+
{ items: [{ key: "a" }], label: "Sortable accounts" },
|
|
211
|
+
{
|
|
212
|
+
item: ({ item }: { item: { key: string } }) =>
|
|
213
|
+
h("span", { class: "row-body" }, item.key),
|
|
214
|
+
},
|
|
215
|
+
),
|
|
216
|
+
});
|
|
217
|
+
app.mount(container);
|
|
218
|
+
mounts.push({ app, container });
|
|
219
|
+
|
|
220
|
+
const root = container.querySelector(".hk-draggable-list") as HTMLElement;
|
|
221
|
+
expect(root.getAttribute("role")).toBe("list");
|
|
222
|
+
expect(root.getAttribute("aria-label")).toBe("Sortable accounts");
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("HkDraggableList press ergonomics (clickable rows)", () => {
|
|
227
|
+
/** Two guards make a clickable row safe inside the list (ported from
|
|
228
|
+
* wowsp's SortableList twin, which ran them in production):
|
|
229
|
+
*
|
|
230
|
+
* 1. ARM — a press under 5px of travel never publishes drag state
|
|
231
|
+
* (no ghost flash on a jittered tap) and keeps the tap's native
|
|
232
|
+
* semantics: no preventDefault until the arm.
|
|
233
|
+
* 2. SWALLOW — the release of an ARMED drag lands as a click on the
|
|
234
|
+
* row the press started from; a capture-phase guard on the root
|
|
235
|
+
* eats it, so dropping a row onto a clickable card never also
|
|
236
|
+
* activates that card. Time-boxed at 300ms so it can never eat
|
|
237
|
+
* a later keyboard activation, which has no pointerdown to
|
|
238
|
+
* reset it. */
|
|
239
|
+
|
|
240
|
+
const PointerEventCtor: typeof MouseEvent =
|
|
241
|
+
(globalThis as { PointerEvent?: typeof MouseEvent }).PointerEvent ?? MouseEvent;
|
|
242
|
+
|
|
243
|
+
const pointerInit: MouseEventInit = { bubbles: true, cancelable: true };
|
|
244
|
+
|
|
245
|
+
function fire(el: Element, type: string, x = 0, y = 0) {
|
|
246
|
+
el.dispatchEvent(new PointerEventCtor(type, {
|
|
247
|
+
...pointerInit,
|
|
248
|
+
button: 0,
|
|
249
|
+
clientX: x,
|
|
250
|
+
clientY: y,
|
|
251
|
+
}));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function fireWin(type: string, x = 0, y = 0) {
|
|
255
|
+
window.dispatchEvent(new PointerEventCtor(type, {
|
|
256
|
+
...pointerInit,
|
|
257
|
+
clientX: x,
|
|
258
|
+
clientY: y,
|
|
259
|
+
}));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function mountClickable(): { root: HTMLElement; clicks: () => number } {
|
|
263
|
+
let clicked = 0;
|
|
264
|
+
const container = document.createElement("div");
|
|
265
|
+
document.body.appendChild(container);
|
|
266
|
+
const app = createApp({
|
|
267
|
+
render: () =>
|
|
268
|
+
h(
|
|
269
|
+
HDraggableList,
|
|
270
|
+
{ items: [{ key: "a" }, { key: "b" }] },
|
|
271
|
+
{
|
|
272
|
+
item: () =>
|
|
273
|
+
h(
|
|
274
|
+
"div",
|
|
275
|
+
{ class: "row-body", onClick: () => { clicked++; } },
|
|
276
|
+
"row",
|
|
277
|
+
),
|
|
278
|
+
},
|
|
279
|
+
),
|
|
280
|
+
});
|
|
281
|
+
app.mount(container);
|
|
282
|
+
mounts.push({ app, container });
|
|
283
|
+
const root = container.querySelector(".hk-draggable-list") as HTMLElement;
|
|
284
|
+
return { root, clicks: () => clicked };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
it("keeps a plain tap a tap: no drag state, and the row's click fires", async () => {
|
|
288
|
+
const { root, clicks } = mountClickable();
|
|
289
|
+
const row = root.querySelectorAll(".hk-draggable-list-item")[0] as HTMLElement;
|
|
290
|
+
|
|
291
|
+
fire(row, "pointerdown", 10, 10);
|
|
292
|
+
// A 1px jitter stays under the arm threshold — no drag publishes.
|
|
293
|
+
fireWin("pointermove", 11, 10);
|
|
294
|
+
await nextTick();
|
|
295
|
+
expect(row.hasAttribute("data-dragging")).toBe(false);
|
|
296
|
+
|
|
297
|
+
fireWin("pointerup");
|
|
298
|
+
(row.querySelector(".row-body") as HTMLElement).dispatchEvent(
|
|
299
|
+
new MouseEvent("click", { bubbles: true, cancelable: true }),
|
|
300
|
+
);
|
|
301
|
+
expect(clicks()).toBe(1);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("arms past the threshold, drags, and swallows the release click", async () => {
|
|
305
|
+
const { root, clicks } = mountClickable();
|
|
306
|
+
const row = root.querySelectorAll(".hk-draggable-list-item")[0] as HTMLElement;
|
|
307
|
+
|
|
308
|
+
fire(row, "pointerdown", 10, 10);
|
|
309
|
+
fireWin("pointermove", 30, 40);
|
|
310
|
+
await nextTick();
|
|
311
|
+
expect(row.getAttribute("data-dragging")).not.toBeNull();
|
|
312
|
+
|
|
313
|
+
fireWin("pointerup");
|
|
314
|
+
(row.querySelector(".row-body") as HTMLElement).dispatchEvent(
|
|
315
|
+
new MouseEvent("click", { bubbles: true, cancelable: true }),
|
|
316
|
+
);
|
|
317
|
+
expect(clicks()).toBe(0);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("time-boxes the swallow — a click after the window passes through", async () => {
|
|
321
|
+
vi.useFakeTimers();
|
|
322
|
+
try {
|
|
323
|
+
const { root, clicks } = mountClickable();
|
|
324
|
+
const row = root.querySelectorAll(".hk-draggable-list-item")[0] as HTMLElement;
|
|
325
|
+
|
|
326
|
+
fire(row, "pointerdown", 10, 10);
|
|
327
|
+
fireWin("pointermove", 30, 40);
|
|
328
|
+
fireWin("pointerup");
|
|
329
|
+
|
|
330
|
+
(row.querySelector(".row-body") as HTMLElement).dispatchEvent(
|
|
331
|
+
new MouseEvent("click", { bubbles: true, cancelable: true }),
|
|
332
|
+
);
|
|
333
|
+
expect(clicks()).toBe(0);
|
|
334
|
+
|
|
335
|
+
vi.advanceTimersByTime(301);
|
|
336
|
+
(row.querySelector(".row-body") as HTMLElement).dispatchEvent(
|
|
337
|
+
new MouseEvent("click", { bubbles: true, cancelable: true }),
|
|
338
|
+
);
|
|
339
|
+
expect(clicks()).toBe(1);
|
|
340
|
+
} finally {
|
|
341
|
+
vi.useRealTimers();
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineComponent, onBeforeUnmount, ref, shallowRef, type PropType } from "vue";
|
|
1
|
+
import { defineComponent, onBeforeUnmount, onMounted, ref, shallowRef, type PropType } from "vue";
|
|
2
2
|
import { onceFrame } from "../runtime/animationBus";
|
|
3
3
|
import { useReportedTransition } from "../composables/useReportedTransition";
|
|
4
4
|
import { ancestorZoom } from "../runtime/cssZoom";
|
|
@@ -14,10 +14,23 @@ interface DragState {
|
|
|
14
14
|
fromIndex: number;
|
|
15
15
|
placeholderIndex: number;
|
|
16
16
|
originX: number;
|
|
17
|
+
originY: number;
|
|
17
18
|
grabOffsetX: number;
|
|
18
19
|
grabOffsetY: number;
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
/** Press-to-drag arm distance — a plain click on a row (or its handle)
|
|
23
|
+
* never starts a drag: the ghost only rises once the pointer moves past
|
|
24
|
+
* this many px, so a clickable row keeps its tap action and a jittered
|
|
25
|
+
* tap never flashes a ghost. */
|
|
26
|
+
const ARM_PX = 5;
|
|
27
|
+
|
|
28
|
+
/** How long after a drop the release click is still recognized as the
|
|
29
|
+
* drag's own click. Time-boxed (rather than a sticky flag) so a drag
|
|
30
|
+
* released off-window can never eat a later keyboard activation,
|
|
31
|
+
* which has no pointerdown to reset it. */
|
|
32
|
+
const SWALLOW_CLICK_MS = 300;
|
|
33
|
+
|
|
21
34
|
export default defineComponent({
|
|
22
35
|
name: "HkDraggableList",
|
|
23
36
|
props: {
|
|
@@ -27,6 +40,12 @@ export default defineComponent({
|
|
|
27
40
|
* locked the root carries data-all-locked and the sheet collapses
|
|
28
41
|
* the lane entirely — the list cannot reorder at all. */
|
|
29
42
|
lockedKeys: { type: Array as PropType<string[]>, default: () => [] },
|
|
43
|
+
/** Accessible name for the list (aria-label on the role="list" root).
|
|
44
|
+
* The list semantics themselves are unconditional — a reorderable
|
|
45
|
+
* list is a list for AT users whether or not the host names it —
|
|
46
|
+
* mirroring the menu/vocabulary a11y rulings (#736/#737): chrome
|
|
47
|
+
* announces structure, the host supplies the name. */
|
|
48
|
+
label: { type: String, default: undefined },
|
|
30
49
|
},
|
|
31
50
|
emits: {
|
|
32
51
|
reorder: (_fromIndex: number, _toIndex: number) => true,
|
|
@@ -46,6 +65,38 @@ export default defineComponent({
|
|
|
46
65
|
|
|
47
66
|
let ghostNode: HTMLElement | null = null;
|
|
48
67
|
|
|
68
|
+
/** Press bookkeeping — captured at pointerdown, never read during
|
|
69
|
+
* render. `armed` flips only when the pointer crosses ARM_PX; until
|
|
70
|
+
* then the press is a plain click-in-progress (no ghost, no drag
|
|
71
|
+
* state, no preventDefault — the tap keeps its native semantics). */
|
|
72
|
+
let press: {
|
|
73
|
+
rect: DOMRect;
|
|
74
|
+
snapshot: string;
|
|
75
|
+
state: DragState;
|
|
76
|
+
armed: boolean;
|
|
77
|
+
} | null = null;
|
|
78
|
+
/** Timestamp of an armed drag's release — the capture-phase click
|
|
79
|
+
* guard below eats the click a completed drag leaves behind (press
|
|
80
|
+
* + release on the same element), so dropping a row onto a
|
|
81
|
+
* clickable card never also activates that card. */
|
|
82
|
+
let swallowClickAt = 0;
|
|
83
|
+
|
|
84
|
+
const rootEl = ref<HTMLElement | null>(null);
|
|
85
|
+
|
|
86
|
+
/** Capture-phase click guard — runs before any row content's own
|
|
87
|
+
* handlers (Vue's JSX typings have no onClickCapture attribute, so
|
|
88
|
+
* the listener is wired imperatively in onMounted). */
|
|
89
|
+
function onRootClickCapture(e: MouseEvent) {
|
|
90
|
+
if (Date.now() - swallowClickAt > SWALLOW_CLICK_MS) return;
|
|
91
|
+
swallowClickAt = 0;
|
|
92
|
+
e.preventDefault();
|
|
93
|
+
e.stopPropagation();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
onMounted(() => {
|
|
97
|
+
rootEl.value?.addEventListener("click", onRootClickCapture, true);
|
|
98
|
+
});
|
|
99
|
+
|
|
49
100
|
const anim = useReportedTransition(250);
|
|
50
101
|
|
|
51
102
|
function bump() { tick.value++; }
|
|
@@ -68,28 +119,29 @@ export default defineComponent({
|
|
|
68
119
|
const row = rowEls.value[index];
|
|
69
120
|
if (!row) return;
|
|
70
121
|
|
|
71
|
-
e.preventDefault();
|
|
72
122
|
const rect = row.getBoundingClientRect();
|
|
73
123
|
|
|
74
124
|
const snapshot = row.outerHTML
|
|
75
125
|
.replace(/\sdraggable="[^"]*"/g, "")
|
|
76
126
|
.replace(/\sdata-dragging="[^"]*"/g, "");
|
|
77
127
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
128
|
+
// The press captures everything the arm needs. Nothing publishes
|
|
129
|
+
// yet — no preventDefault either: the handle lane carries its own
|
|
130
|
+
// touch-action:none and the rows are user-select:none, so a plain
|
|
131
|
+
// tap keeps its native click/scroll semantics untouched.
|
|
132
|
+
press = {
|
|
133
|
+
rect,
|
|
134
|
+
snapshot,
|
|
135
|
+
state: {
|
|
136
|
+
fromIndex: index,
|
|
137
|
+
placeholderIndex: index,
|
|
138
|
+
originX: e.clientX,
|
|
139
|
+
originY: e.clientY,
|
|
140
|
+
grabOffsetX: e.clientX - rect.left,
|
|
141
|
+
grabOffsetY: e.clientY - rect.top,
|
|
142
|
+
},
|
|
143
|
+
armed: false,
|
|
84
144
|
};
|
|
85
|
-
drag.value = state;
|
|
86
|
-
bump();
|
|
87
|
-
|
|
88
|
-
onceFrame(() => {
|
|
89
|
-
if (!drag.value) return;
|
|
90
|
-
attachGhost(rect, snapshot);
|
|
91
|
-
anim.run();
|
|
92
|
-
});
|
|
93
145
|
|
|
94
146
|
window.addEventListener("pointermove", onPointerMove, { passive: false });
|
|
95
147
|
window.addEventListener("pointerup", onPointerUp, { once: true });
|
|
@@ -170,22 +222,45 @@ export default defineComponent({
|
|
|
170
222
|
}
|
|
171
223
|
|
|
172
224
|
function onPointerMove(e: PointerEvent) {
|
|
173
|
-
const
|
|
174
|
-
if (!
|
|
225
|
+
const p = press;
|
|
226
|
+
if (!p) return;
|
|
227
|
+
|
|
228
|
+
if (!p.armed) {
|
|
229
|
+
// Arm past the threshold: only now does the drag publish, the
|
|
230
|
+
// ghost rise, and the gesture claim its defaults.
|
|
231
|
+
if (Math.hypot(e.clientX - p.state.originX, e.clientY - p.state.originY) <= ARM_PX) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
p.armed = true;
|
|
235
|
+
drag.value = p.state;
|
|
236
|
+
bump();
|
|
237
|
+
const { rect, snapshot } = p;
|
|
238
|
+
onceFrame(() => {
|
|
239
|
+
if (!drag.value) return;
|
|
240
|
+
attachGhost(rect, snapshot);
|
|
241
|
+
anim.run();
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
175
245
|
e.preventDefault();
|
|
176
246
|
moveGhost(e.clientY);
|
|
177
247
|
recomputePlaceholder(e.clientY);
|
|
178
248
|
}
|
|
179
249
|
|
|
180
250
|
function onPointerUp() {
|
|
181
|
-
const s = drag.value;
|
|
182
251
|
window.removeEventListener("pointermove", onPointerMove);
|
|
183
252
|
|
|
253
|
+
const s = drag.value;
|
|
184
254
|
if (s) {
|
|
255
|
+
// An armed drag's release lands as a click on the row the press
|
|
256
|
+
// started from — mark it for the capture guard or every drop
|
|
257
|
+
// onto a clickable card would also activate the card.
|
|
258
|
+
swallowClickAt = Date.now();
|
|
185
259
|
if (s.placeholderIndex !== s.fromIndex) {
|
|
186
260
|
emit("reorder", s.fromIndex, s.placeholderIndex);
|
|
187
261
|
}
|
|
188
262
|
}
|
|
263
|
+
press = null;
|
|
189
264
|
anim.run();
|
|
190
265
|
drag.value = null;
|
|
191
266
|
bump();
|
|
@@ -193,6 +268,7 @@ export default defineComponent({
|
|
|
193
268
|
}
|
|
194
269
|
|
|
195
270
|
onBeforeUnmount(() => {
|
|
271
|
+
rootEl.value?.removeEventListener("click", onRootClickCapture, true);
|
|
196
272
|
window.removeEventListener("pointermove", onPointerMove);
|
|
197
273
|
detachGhost();
|
|
198
274
|
});
|
|
@@ -211,7 +287,10 @@ export default defineComponent({
|
|
|
211
287
|
props.items.length > 0 && props.items.every((item) => isLocked(item.key));
|
|
212
288
|
return (
|
|
213
289
|
<div
|
|
290
|
+
ref={rootEl}
|
|
214
291
|
class="hk-draggable-list"
|
|
292
|
+
role="list"
|
|
293
|
+
aria-label={props.label || undefined}
|
|
215
294
|
data-dragging={s ? "" : undefined}
|
|
216
295
|
data-all-locked={allLocked || undefined}
|
|
217
296
|
>
|
|
@@ -227,6 +306,7 @@ export default defineComponent({
|
|
|
227
306
|
key={item.key}
|
|
228
307
|
ref={(el) => setRowEl(index, el as Element | null)}
|
|
229
308
|
class="hk-draggable-list-item"
|
|
309
|
+
role="listitem"
|
|
230
310
|
data-locked={locked || undefined}
|
|
231
311
|
data-dragging={isSource || undefined}
|
|
232
312
|
data-placeholder={placeholderDir || undefined}
|
|
@@ -165,13 +165,17 @@
|
|
|
165
165
|
/* The sheet's bottom-edge distance itself comes from the shared
|
|
166
166
|
* --hk-sheet-bottom-gap (inline in HkPopover's sheet style); here the
|
|
167
167
|
* content keeps the last row clear of the home bar: the safe-area
|
|
168
|
-
* inset stacked on
|
|
169
|
-
*
|
|
168
|
+
* inset stacked on the shared container-pad token, whose legacy
|
|
169
|
+
* fallback is the 12px family base (which also restores a bottom
|
|
170
|
+
* cushion over the glass dropdown chrome — equal specificity, earlier
|
|
170
171
|
* in source, whose own bottom padding the sheet rule would otherwise
|
|
171
|
-
* zero out).
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
|
|
172
|
+
* zero out). A host that sets the `padding` prop retunes the sheet's
|
|
173
|
+
* bottom inset with the SAME step its anchored panel uses (#808
|
|
174
|
+
* completion — hosts stop hand-padding the sheet branch); the safe-
|
|
175
|
+
* area term always rides on top. The modal sheet footer goes further
|
|
176
|
+
* (it adds --hk-sheet-footer-gap for its action buttons); this sheet
|
|
177
|
+
* has no footer, so this is the family baseline. */
|
|
178
|
+
padding-bottom: calc(var(--hk-pad, var(--space-12, 0.75rem)) + env(safe-area-inset-bottom, 0px));
|
|
175
179
|
|
|
176
180
|
/* Overlay scrollbar (useOverlayScrollbar, sheet mode only) — the
|
|
177
181
|
native chrome is hidden, never styled. */
|
|
@@ -184,9 +188,14 @@
|
|
|
184
188
|
|
|
185
189
|
.hk-popover-sheet-grabber {
|
|
186
190
|
flex-shrink: 0;
|
|
191
|
+
// Chrome, not content: cancel the panel's shared container pad inline
|
|
192
|
+
// (see the header twin above) so the grabber zone spans the full sheet
|
|
193
|
+
// width at any `padding` step — the centered bar never drifts inward.
|
|
194
|
+
// Longhand AFTER the shorthand or `margin: 0 auto` would win the cascade.
|
|
195
|
+
margin: 0 auto;
|
|
196
|
+
margin-inline: calc(-1 * var(--hk-pad, 0px));
|
|
187
197
|
// Grabber zone: tap target tall enough for a thumb, bar visually small.
|
|
188
198
|
padding: 14px 0 20px;
|
|
189
|
-
margin: 0 auto;
|
|
190
199
|
width: 100%;
|
|
191
200
|
cursor: pointer;
|
|
192
201
|
display: flex;
|
|
@@ -212,15 +221,26 @@
|
|
|
212
221
|
* The title aligns to the CONTENT edge: the leading inset defaults to
|
|
213
222
|
* --space-12, matching the padded content blocks popover sheets host
|
|
214
223
|
* (e.g. the house 0.75rem popover body). Hosts tune it via
|
|
215
|
-
* --hk-sheet-title-inset; the trailing inset hugs the close button.
|
|
224
|
+
* --hk-sheet-title-inset; the trailing inset hugs the close button.
|
|
225
|
+
*
|
|
226
|
+
* Shared container pad (#808 completion): the panel's `padding` prop
|
|
227
|
+
* insets the CONTENT band, so this chrome row cancels it inline
|
|
228
|
+
* (negative margin, the valid-CSS twin of a negative padding) and
|
|
229
|
+
* keeps its own title-inset from the panel frame — the title lands on
|
|
230
|
+
* the content edge whenever the pad step matches title-inset, at any
|
|
231
|
+
* step. The trailing block gap is the CONTENT's block-start air under
|
|
232
|
+
* the header: it reads the same pad step when the prop is set (legacy
|
|
233
|
+
* 8px otherwise), so a padded host gets one uniform inset around its
|
|
234
|
+
* sheet content too — no hand-padded wrapper branch downstream. */
|
|
216
235
|
.hk-popover-sheet-header {
|
|
217
236
|
flex-shrink: 0;
|
|
218
237
|
display: flex;
|
|
219
238
|
align-items: center;
|
|
220
239
|
gap: var(--space-8, 0.5rem);
|
|
240
|
+
margin-inline: calc(-1 * var(--hk-pad, 0px));
|
|
221
241
|
// Logical longhand (was physical `0 x y title-inset`): the title inset is
|
|
222
242
|
// the inline START edge so RTL mirrors it with the mirrored title/close.
|
|
223
|
-
padding-block: 0 var(--space-8, 0.5rem);
|
|
243
|
+
padding-block: 0 var(--hk-pad, var(--space-8, 0.5rem));
|
|
224
244
|
padding-inline: var(--hk-sheet-title-inset, var(--space-12, 0.75rem))
|
|
225
245
|
var(--space-8, 0.5rem);
|
|
226
246
|
}
|
|
@@ -28,9 +28,12 @@ describe("HkPopover mobile sheet docking contract", () => {
|
|
|
28
28
|
|
|
29
29
|
it("keeps sheet content clear of the home bar via the safe-area inset", () => {
|
|
30
30
|
const block = scss.match(/\.hk-popover-panel\.hk-is-sheet\s*{[^}]*}/)?.[0] ?? "";
|
|
31
|
-
// Stacked on the
|
|
32
|
-
//
|
|
33
|
-
|
|
31
|
+
// Stacked on the shared container-pad token whose legacy fallback is
|
|
32
|
+
// the family 12px base, so the glass dropdown chrome's bottom padding
|
|
33
|
+
// survives on non-notched phones too — and a host's `padding` prop
|
|
34
|
+
// retunes the base on the sheet face exactly like the anchored one
|
|
35
|
+
// (#808 completion; see containerPad.styles.test.ts for the pin).
|
|
36
|
+
expect(block).toContain("calc(var(--hk-pad, var(--space-12");
|
|
34
37
|
expect(block).toContain("env(safe-area-inset-bottom");
|
|
35
38
|
});
|
|
36
39
|
});
|
|
@@ -169,6 +169,32 @@ describe("HkPopover sheetOnMobile", () => {
|
|
|
169
169
|
expect(header.querySelector(".hk-popover-sheet-close")).toBeTruthy();
|
|
170
170
|
});
|
|
171
171
|
|
|
172
|
+
// #808 completion: the sheet branch consumes the same shared container
|
|
173
|
+
// pad its anchored twin does — the inline --hk-pad the `padding` prop
|
|
174
|
+
// writes on the panel reaches the sheet's content band (the scss
|
|
175
|
+
// contract in containerPad.styles.test.ts pins the consuming rules),
|
|
176
|
+
// so a host retunes ONE prop and both faces follow. No more hand-padded
|
|
177
|
+
// sheet-only wrapper branches downstream (chest's HkContextRing used
|
|
178
|
+
// to need one).
|
|
179
|
+
it("carries the padding prop's inline token onto the sheet panel too", async () => {
|
|
180
|
+
setViewport(375);
|
|
181
|
+
mountPopover({ sheetOnMobile: true, title: "Sheet", padding: "md" });
|
|
182
|
+
await nextTick();
|
|
183
|
+
|
|
184
|
+
const panel = document.body.querySelector<HTMLElement>(".hk-popover-panel")!;
|
|
185
|
+
expect(panel.classList.contains("hk-is-sheet")).toBe(true);
|
|
186
|
+
expect(panel.style.getPropertyValue("--hk-pad")).toBe("var(--space-12, 0.75rem)");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("keeps the sheet panel token-free when no padding prop is set", async () => {
|
|
190
|
+
setViewport(375);
|
|
191
|
+
mountPopover({ sheetOnMobile: true, title: "Sheet" });
|
|
192
|
+
await nextTick();
|
|
193
|
+
|
|
194
|
+
const panel = document.body.querySelector<HTMLElement>(".hk-popover-panel")!;
|
|
195
|
+
expect(panel.style.getPropertyValue("--hk-pad")).toBe("");
|
|
196
|
+
});
|
|
197
|
+
|
|
172
198
|
it("keeps the heading row out of anchored desktop mode", async () => {
|
|
173
199
|
setViewport(1200);
|
|
174
200
|
mountPopover({ sheetOnMobile: true, title: "Context usage" });
|
|
@@ -180,7 +180,7 @@ export const HkStatusBar = defineComponent({
|
|
|
180
180
|
panelBuildHash: { type: String as PropType<string | undefined>, default: undefined },
|
|
181
181
|
engineBuildHash: { type: String as PropType<string | undefined>, default: undefined },
|
|
182
182
|
connectionStatus: {
|
|
183
|
-
type: String as PropType<"connected" | "reconnecting" | "disconnected" | "connecting">,
|
|
183
|
+
type: String as PropType<"connected" | "reconnecting" | "disconnected" | "connecting" | "idle">,
|
|
184
184
|
default: "disconnected",
|
|
185
185
|
},
|
|
186
186
|
connectionInfo: {
|
|
@@ -238,6 +238,10 @@ export const HkStatusBar = defineComponent({
|
|
|
238
238
|
connecting: "rgb(var(--color-warning))",
|
|
239
239
|
reconnecting: "rgb(var(--color-warning))",
|
|
240
240
|
disconnected: "rgb(var(--color-error))",
|
|
241
|
+
// Idle (2026-10-17 user direction): the shell is alive but has no
|
|
242
|
+
// session — the login page's resting light. Neutral gray, not red:
|
|
243
|
+
// red means FAULT, and "not yet logged in" is not a fault.
|
|
244
|
+
idle: "rgb(var(--color-text-tertiary, var(--color-text-secondary, 128 128 128)))",
|
|
241
245
|
};
|
|
242
246
|
|
|
243
247
|
// ── Recovery flash ─────────────────────────────────────────────
|
|
@@ -380,7 +384,8 @@ export const HkStatusBar = defineComponent({
|
|
|
380
384
|
const countdown = props.countdown ?? info?.countdown ?? 0;
|
|
381
385
|
|
|
382
386
|
const tierLabelKey = `hikari::statusBar.tier.${tier}`;
|
|
383
|
-
const statusText = mode === "
|
|
387
|
+
const statusText = mode === "idle" ? t("hikari::statusBar.idle", "Ready")
|
|
388
|
+
: mode === "connected" ? t("hikari::statusBar.connected", "Connected")
|
|
384
389
|
: mode === "reconnecting" || mode === "connecting" ? t("hikari::statusBar.connecting", "Connecting...")
|
|
385
390
|
: t("hikari::statusBar.disconnected", "Disconnected");
|
|
386
391
|
|
|
@@ -48,6 +48,29 @@ describe("container pad token — wired sheets", () => {
|
|
|
48
48
|
);
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
+
it("HkPopover sheet: the content band reads the token, chrome cancels it", () => {
|
|
52
|
+
// The sheet branch completes the #808 contract: a host that sets the
|
|
53
|
+
// `padding` prop gets the SAME step around its content docked as a
|
|
54
|
+
// bottom sheet (bottom = step + safe-area, block-start air under the
|
|
55
|
+
// header = step) instead of hand-padding a sheet-only wrapper — the
|
|
56
|
+
// gap chest's HkContextRing had to compensate for locally. Grabber
|
|
57
|
+
// and header are chrome: they cancel the panel's inline pad so the
|
|
58
|
+
// tap zone and title keep their own frame-relative insets.
|
|
59
|
+
const css = compileSheet("HkPopover.scss");
|
|
60
|
+
expect(css).toContain(
|
|
61
|
+
"padding-bottom: calc(var(--hk-pad, var(--space-12, 0.75rem)) + env(safe-area-inset-bottom, 0px))",
|
|
62
|
+
);
|
|
63
|
+
expect(ruleFor(css, ".hk-popover-sheet-header {")).toContain(
|
|
64
|
+
"margin-inline: calc(-1 * var(--hk-pad, 0px))",
|
|
65
|
+
);
|
|
66
|
+
expect(ruleFor(css, ".hk-popover-sheet-header {")).toContain(
|
|
67
|
+
"padding-block: 0 var(--hk-pad, var(--space-8, 0.5rem))",
|
|
68
|
+
);
|
|
69
|
+
expect(ruleFor(css, ".hk-popover-sheet-grabber {")).toContain(
|
|
70
|
+
"margin-inline: calc(-1 * var(--hk-pad, 0px))",
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
51
74
|
it("HkCard: body 1rem, unpadded 0, the scrollable pad wrapper 1rem", () => {
|
|
52
75
|
const css = compileSheet("HkCard.scss");
|
|
53
76
|
expect(ruleFor(css, ".hk-card-body {")).toContain(
|
|
@@ -283,5 +283,6 @@
|
|
|
283
283
|
"hikari::diff.loadFailed": "فشل التحميل، انقر لإعادة المحاولة",
|
|
284
284
|
"hikari::search.trigger": "بحث",
|
|
285
285
|
"hikari::search.clear": "مسح البحث",
|
|
286
|
-
"hikari::search.apply": "تطبيق"
|
|
286
|
+
"hikari::search.apply": "تطبيق",
|
|
287
|
+
"hikari::statusBar.idle": "جاهز"
|
|
287
288
|
}
|
|
@@ -283,5 +283,6 @@
|
|
|
283
283
|
"hikari::diff.loadFailed": "Laden fehlgeschlagen, zum Wiederholen klicken",
|
|
284
284
|
"hikari::search.trigger": "Suchen",
|
|
285
285
|
"hikari::search.clear": "Suche löschen",
|
|
286
|
-
"hikari::search.apply": "Anwenden"
|
|
286
|
+
"hikari::search.apply": "Anwenden",
|
|
287
|
+
"hikari::statusBar.idle": "Bereit"
|
|
287
288
|
}
|
|
@@ -283,5 +283,6 @@
|
|
|
283
283
|
"hikari::diff.loadFailed": "Load failed, click to retry",
|
|
284
284
|
"hikari::search.trigger": "Search",
|
|
285
285
|
"hikari::search.clear": "Clear search",
|
|
286
|
-
"hikari::search.apply": "Apply"
|
|
286
|
+
"hikari::search.apply": "Apply",
|
|
287
|
+
"hikari::statusBar.idle": "Ready"
|
|
287
288
|
}
|
|
@@ -283,5 +283,6 @@
|
|
|
283
283
|
"hikari::diff.loadFailed": "Error al cargar, haz clic para reintentar",
|
|
284
284
|
"hikari::search.trigger": "Buscar",
|
|
285
285
|
"hikari::search.clear": "Borrar búsqueda",
|
|
286
|
-
"hikari::search.apply": "Aplicar"
|
|
286
|
+
"hikari::search.apply": "Aplicar",
|
|
287
|
+
"hikari::statusBar.idle": "Listo"
|
|
287
288
|
}
|
|
@@ -283,5 +283,6 @@
|
|
|
283
283
|
"hikari::diff.loadFailed": "Échec du chargement, cliquez pour réessayer",
|
|
284
284
|
"hikari::search.trigger": "Rechercher",
|
|
285
285
|
"hikari::search.clear": "Effacer la recherche",
|
|
286
|
-
"hikari::search.apply": "Appliquer"
|
|
286
|
+
"hikari::search.apply": "Appliquer",
|
|
287
|
+
"hikari::statusBar.idle": "Prêt"
|
|
287
288
|
}
|
|
@@ -283,5 +283,6 @@
|
|
|
283
283
|
"hikari::diff.loadFailed": "Falha ao carregar, clique para tentar novamente",
|
|
284
284
|
"hikari::search.trigger": "Pesquisar",
|
|
285
285
|
"hikari::search.clear": "Limpar pesquisa",
|
|
286
|
-
"hikari::search.apply": "Aplicar"
|
|
286
|
+
"hikari::search.apply": "Aplicar",
|
|
287
|
+
"hikari::statusBar.idle": "Pronto"
|
|
287
288
|
}
|
|
@@ -283,5 +283,6 @@
|
|
|
283
283
|
"hikari::diff.loadFailed": "Не удалось загрузить, нажмите для повтора",
|
|
284
284
|
"hikari::search.trigger": "Поиск",
|
|
285
285
|
"hikari::search.clear": "Очистить поиск",
|
|
286
|
-
"hikari::search.apply": "Применить"
|
|
286
|
+
"hikari::search.apply": "Применить",
|
|
287
|
+
"hikari::statusBar.idle": "Готово"
|
|
287
288
|
}
|