@celestia-island/hikari 0.55.112 → 0.55.113

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.112",
3
+ "version": "0.55.113",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -143,3 +143,24 @@ describe("HkFloatingLayer", () => {
143
143
  expect(m.root()!.querySelector(".probe")).not.toBeNull();
144
144
  });
145
145
  });
146
+
147
+ describe("HkFloatingLayer yieldToWindows", () => {
148
+ it("hides while any window is open and returns when the last window closes", async () => {
149
+ const manager = usePopupManager();
150
+ const win = manager.register("modal", true, "Blocking");
151
+ const m = mountLayer({ yieldToWindows: true });
152
+ expect(m.root()).toBeNull();
153
+ manager.unregister(win.id);
154
+ await nextTick();
155
+ expect(m.root()).not.toBeNull();
156
+ });
157
+
158
+ it("default layers keep floating over windows (opt-in only)", async () => {
159
+ const manager = usePopupManager();
160
+ const win = manager.register("modal", true, "Blocking");
161
+ const m = mountLayer({});
162
+ expect(m.root()).not.toBeNull();
163
+ manager.unregister(win.id);
164
+ await nextTick();
165
+ });
166
+ });
@@ -7,7 +7,7 @@ import {
7
7
  type PropType,
8
8
  } from "vue";
9
9
 
10
- import { usePopupManager } from "../runtime/usePopupManager";
10
+ import { hasLiveWindow, usePopupManager } from "../runtime/usePopupManager";
11
11
  import "./HkFloatingLayer.scss";
12
12
 
13
13
  type FloatCorner = "bottom-right" | "bottom-left" | "top-right" | "top-left";
@@ -63,6 +63,11 @@ export default defineComponent({
63
63
  offsetY: { type: String, default: undefined },
64
64
  /** Accessible group name for the chrome (e.g. "Date dial"). */
65
65
  ariaLabel: { type: String, default: "" },
66
+ /** Stack priority (user direction 2026-10-04): when true the layer
67
+ * yields the screen while ANY window is open - page-level dial
68
+ * chrome disappears under the top window instead of floating over
69
+ * it, and returns when the last window closes. */
70
+ yieldToWindows: { type: Boolean, default: false },
66
71
  },
67
72
  setup(props, { slots }) {
68
73
  const { register, unregister } = usePopupManager();
@@ -71,7 +76,10 @@ export default defineComponent({
71
76
  // Annotation-band chrome, not a breadcrumb level: non-blocking,
72
77
  // untitled, no scroll lock, no outside-dismiss channel. Registered
73
78
  // exactly while shown (immediate, so the first paint already
74
- // carries the band z); a hidden layer holds no slot.
79
+ // carries the band z); a hidden layer holds no slot. (A
80
+ // yielded layer keeps its slot held while hidden — constant chrome
81
+ // flipping a registered slot on every window open/close would churn
82
+ // the band for no visual effect.)
75
83
  watch(
76
84
  () => props.open,
77
85
  (show) => {
@@ -89,7 +97,7 @@ export default defineComponent({
89
97
  });
90
98
 
91
99
  return () => {
92
- if (!props.open) return null;
100
+ if (!props.open || (props.yieldToWindows && hasLiveWindow.value)) return null;
93
101
  return (
94
102
  <Teleport to="body">
95
103
  <div
@@ -415,3 +415,64 @@ describe("HkModal autoFollow", () => {
415
415
  expect(s.jump()).toBeNull();
416
416
  });
417
417
  });
418
+
419
+ // Stack-priority dial slot (user direction 2026-10-04): the window owns
420
+ // its dial while it is the topmost window, yields while another window
421
+ // stacks above, and the page dial returns on close. The surface machine
422
+ // runs on macrotask timers, so every stack transition below is staged
423
+ // through vi.waitFor, never bare ticks.
424
+ describe("HkModal dial slot (stack priority)", () => {
425
+ async function mountDialModal(withDial: boolean) {
426
+ const container = document.createElement("div");
427
+ document.body.appendChild(container);
428
+ containers.push(container);
429
+ const open = ref(true);
430
+ const Wrapper = defineComponent({
431
+ setup() {
432
+ return () =>
433
+ h(HkModal, {
434
+ modelValue: open.value,
435
+ title: "Window",
436
+ "onUpdate:modelValue": (v: boolean) => { open.value = v; },
437
+ }, withDial
438
+ ? {
439
+ default: () => h("div", "body content"),
440
+ dial: () => h("button", { class: "dial-probe" }, "dial"),
441
+ }
442
+ : { default: () => h("div", "body content") });
443
+ },
444
+ });
445
+ const app = createApp(Wrapper);
446
+ mounts.push(app);
447
+ app.mount(container);
448
+ return { open };
449
+ }
450
+ const dialLayer = () => document.body.querySelector(".hk-floating-layer");
451
+
452
+ it("mounts its dial slot on the top layer while the window is topmost", async () => {
453
+ await mountDialModal(true);
454
+ await vi.waitFor(() => {
455
+ const layer = dialLayer();
456
+ expect(layer).not.toBeNull();
457
+ expect(layer!.querySelector(".dial-probe")).not.toBeNull();
458
+ });
459
+ });
460
+
461
+ it("yields while another window stacks above and returns when it closes", async () => {
462
+ const m = await mountDialModal(true);
463
+ await vi.waitFor(() => expect(dialLayer()).not.toBeNull());
464
+ const { register, unregister } = usePopupManager();
465
+ const above = register("modal", true, "Above");
466
+ await vi.waitFor(() => expect(dialLayer()).toBeNull());
467
+ unregister(above.id);
468
+ await vi.waitFor(() => expect(dialLayer()).not.toBeNull());
469
+ m.open.value = false;
470
+ await vi.waitFor(() => expect(dialLayer()).toBeNull());
471
+ });
472
+
473
+ it("renders no dial layer when the window declares none", async () => {
474
+ await mountDialModal(false);
475
+ await vi.waitFor(() => expect(document.body.querySelector(".hk-modal-root")).not.toBeNull());
476
+ expect(dialLayer()).toBeNull();
477
+ });
478
+ });
@@ -2,9 +2,10 @@ import { computed, defineComponent, nextTick, onBeforeUnmount, onMounted, ref, s
2
2
 
3
3
  import { useI18n } from "../i18n/context";
4
4
  import "./HkModal.scss";
5
+ import HkFloatingLayer from "./HkFloatingLayer";
5
6
  import { focusFirst, trapFocus } from "../utils/dom";
6
7
  import { useOverlay } from "../runtime/useOverlay";
7
- import { usePopupManager } from "../runtime/usePopupManager";
8
+ import { topWindowZ, usePopupManager } from "../runtime/usePopupManager";
8
9
  import { createBackGuard } from "../runtime/backStack";
9
10
  import { scheduleFrame, type AnimationHandle } from "../runtime/animationBus";
10
11
  import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
@@ -936,12 +937,24 @@ export default defineComponent({
936
937
 
937
938
  const contentHold = useSurfaceContentHold(machine.phase);
938
939
 
940
+ // Stack-priority dial chrome (user direction 2026-10-04): the
941
+ // window's own dial replaces the page's while this window is the
942
+ // topmost one, yields while another window stacks above it (dial
943
+ // or not), and the page dial returns when the last window closes.
944
+ const dialVisible = computed(() =>
945
+ Boolean(
946
+ machine.mounted.value &&
947
+ handle.value &&
948
+ (topWindowZ.value === null || topWindowZ.value <= handle.value.zIndex),
949
+ ),
950
+ );
951
+
939
952
  return () => {
940
953
  if (!machine.mounted.value) return null;
941
954
 
942
955
  const headerShown = props.title || props.closable || slots.header || slots.headerLead;
943
956
 
944
- return (
957
+ const windowSurface = (
945
958
  <Teleport to="body">
946
959
  <div
947
960
  class="hk-modal-root"
@@ -1066,6 +1079,19 @@ export default defineComponent({
1066
1079
  </div>
1067
1080
  </Teleport>
1068
1081
  );
1082
+
1083
+ // The dial rides the top layer beside its window (stack
1084
+ // priority): mounted with the window, yielded above, retired
1085
+ // when the window closes so the page dial can return.
1086
+ if (!slots.dial) return windowSurface;
1087
+ return (
1088
+ <>
1089
+ {windowSurface}
1090
+ <HkFloatingLayer open={dialVisible.value} corner="bottom-right">
1091
+ {slots.dial()}
1092
+ </HkFloatingLayer>
1093
+ </>
1094
+ );
1069
1095
  };
1070
1096
 
1071
1097
  function renderWindowedBody() {
package/src/index.ts CHANGED
@@ -348,6 +348,9 @@ export {
348
348
  setReducedMotion,
349
349
  notifyScrollStart,
350
350
  useOverlay,
351
+ hasLiveWindow,
352
+ isWindowEntry,
353
+ topWindowZ,
351
354
  usePopupManager,
352
355
  POPUP_Z_BANDS,
353
356
  POPUP_Z_STEP,
@@ -56,7 +56,16 @@ export {
56
56
  } from "./backStack";
57
57
  export { useMediaQuery, releaseMediaQuery } from "./useMediaQuery";
58
58
  export { useOverlay, closeAll, isOverlayOpen, type OverlayHandle, type UseOverlayOptions } from "./useOverlay";
59
- export { usePopupManager, POPUP_Z_BANDS, POPUP_Z_STEP, type PopupHandle, type PopupKind } from "./usePopupManager";
59
+ export {
60
+ hasLiveWindow,
61
+ isWindowEntry,
62
+ topWindowZ,
63
+ usePopupManager,
64
+ POPUP_Z_BANDS,
65
+ POPUP_Z_STEP,
66
+ type PopupHandle,
67
+ type PopupKind,
68
+ } from "./usePopupManager";
60
69
  export { useToast, TOAST_DURATION, type ToastItem, type ToastMessage, type ToastType } from "./useToast";
61
70
  export { useConfirm } from "./useConfirm";
62
71
  export { useBlockingToast, showBlockingToast, resolveBlockingToast, clearBlockingToasts, type BlockingToastItem, type BlockingToastOptions, type BlockingToastVariant } from "./useBlockingToast";
@@ -1,7 +1,13 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { watch } from "vue";
3
3
 
4
- import { POPUP_Z_BANDS, POPUP_Z_STEP, usePopupManager } from "./usePopupManager";
4
+ import {
5
+ hasLiveWindow,
6
+ POPUP_Z_BANDS,
7
+ POPUP_Z_STEP,
8
+ topWindowZ,
9
+ usePopupManager,
10
+ } from "./usePopupManager";
5
11
 
6
12
  const managers: ReturnType<typeof usePopupManager>[] = [];
7
13
 
@@ -338,3 +344,43 @@ describe("usePopupManager closeAbove", () => {
338
344
  }
339
345
  });
340
346
  });
347
+
348
+ describe("usePopupManager window-stack signals", () => {
349
+ it("derived signals track the live window band (page chrome's yield rule)", () => {
350
+ const m = usePopupManager();
351
+ const win = m.register("modal", true, "Window");
352
+ expect(hasLiveWindow.value).toBe(true);
353
+ expect(topWindowZ.value).toBe(win.zIndex);
354
+ // Tooltip-band entries are not windows: they must not flip the signals.
355
+ const tip = m.register("tooltip", false);
356
+ expect(hasLiveWindow.value).toBe(true);
357
+ expect(topWindowZ.value).toBe(win.zIndex);
358
+ m.unregister(tip.id);
359
+ m.unregister(win.id);
360
+ expect(hasLiveWindow.value).toBe(false);
361
+ expect(topWindowZ.value).toBeNull();
362
+ });
363
+
364
+ it("only window-band entries count: anchored dropdowns no, docked sheets yes", () => {
365
+ const m = usePopupManager();
366
+ const dd = m.register("dropdown", false);
367
+ expect(hasLiveWindow.value).toBe(false);
368
+ m.unregister(dd.id);
369
+ const sheet = m.register("dropdown", true, "Sheet", true);
370
+ expect(hasLiveWindow.value).toBe(true);
371
+ expect(topWindowZ.value).toBe(sheet.zIndex);
372
+ m.unregister(sheet.id);
373
+ expect(hasLiveWindow.value).toBe(false);
374
+ });
375
+
376
+ it("topWindowZ follows the topmost of stacked windows", () => {
377
+ const m = usePopupManager();
378
+ const low = m.register("modal", true, "Low");
379
+ const high = m.register("modal", true, "High");
380
+ expect(topWindowZ.value).toBe(high.zIndex);
381
+ expect(topWindowZ.value).toBeGreaterThan(low.zIndex);
382
+ m.unregister(high.id);
383
+ expect(topWindowZ.value).toBe(low.zIndex);
384
+ m.unregister(low.id);
385
+ });
386
+ });
@@ -1,4 +1,4 @@
1
- import { readonly, ref } from "vue";
1
+ import { computed, readonly, ref } from "vue";
2
2
 
3
3
  import { reportHkRuntime, type HkRuntimeHandle } from "./registry";
4
4
 
@@ -70,6 +70,14 @@ function effectiveBand(kind: PopupKind, blocking: boolean): number {
70
70
  return POPUP_Z_BANDS[kind];
71
71
  }
72
72
 
73
+ /** True when a live entry is a WINDOW (blocks the page like one):
74
+ * modal/drawer by kind, or a dropdown-kind surface docked as a
75
+ * blocking mobile sheet. Page chrome (dials, floating pads) uses
76
+ * this to yield the screen to whatever window is on top. */
77
+ export function isWindowEntry(kind: PopupKind, blocking: boolean): boolean {
78
+ return effectiveBand(kind, blocking) === POPUP_Z_BANDS.modal;
79
+ }
80
+
73
81
  /** In-band stacking step. Even numbers leave the odd slot free for the
74
82
  * +1 content/panel layer each overlay puts above its own root. */
75
83
  export const POPUP_Z_STEP = 2;
@@ -132,6 +140,27 @@ function uid(): string {
132
140
  }
133
141
 
134
142
  const registry = ref<Map<string, PopupEntry>>(new Map());
143
+
144
+ // Derived window-stack signals (reactive over the live registry):
145
+ // whether ANY window is open, and the topmost window's z slot. Stack
146
+ // priority for dial chrome rides these — a window's own dial replaces
147
+ // the page's while it is topmost; close the window and the page's
148
+ // dial returns.
149
+ export const hasLiveWindow = computed<boolean>(() => {
150
+ for (const entry of registry.value.values()) {
151
+ if (isWindowEntry(entry.kind, entry.blocking)) return true;
152
+ }
153
+ return false;
154
+ });
155
+
156
+ export const topWindowZ = computed<number | null>(() => {
157
+ let top: number | null = null;
158
+ for (const entry of registry.value.values()) {
159
+ if (!isWindowEntry(entry.kind, entry.blocking)) continue;
160
+ if (top === null || entry.zIndex > top) top = entry.zIndex;
161
+ }
162
+ return top;
163
+ });
135
164
  let scrollLockCount = 0;
136
165
 
137
166
  // Runtime-registry reporting (the "context of contexts"). Lazy: the