@celestia-island/hikari 0.55.233 → 0.55.234

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.233",
3
+ "version": "0.55.234",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -39,8 +39,10 @@ export const HkAdminShell = defineComponent({
39
39
  sidebarWidth: { type: String, default: "224px" },
40
40
  /** Desktop icon-rail capability, forwarded to HkFaceShell (see its
41
41
  * prop doc): opt in here and the admin sidebar gains the circular
42
- * hover-reveal toggle, the one-icon rail and the overlay peek
43
- * drawer. Declared on the wrapper on purpose — the same
42
+ * hover-reveal toggle STRADDLING the sidebar's content-facing border,
43
+ * the one-icon rail and the overlay peek drawer (with the peek
44
+ * suppressed while the pointer rests on the toggle, and a pinned
45
+ * panel on expand). Declared on the wrapper on purpose — the same
44
46
  * honest-forwarding rule as contentMode (a stray attr must not
45
47
  * reach the inner shell's declared prop through fallthrough). */
46
48
  sidebarCollapsible: { type: Boolean, default: false },
@@ -368,3 +368,211 @@ describe("HkAdminShell forwarding (source pin)", () => {
368
368
  expect(container.querySelector(".hk-sidebar-rail-toggle button")).toBeTruthy();
369
369
  });
370
370
  });
371
+ /** A pointermove with explicit viewport coordinates (the suppression
372
+ * wiring is purely geometric — see HkSidebarRailToggle's helper). */
373
+ function pointerMove(x: number, y: number): MouseEvent {
374
+ const e = new MouseEvent("pointermove", { bubbles: true });
375
+ Object.defineProperty(e, "clientX", { value: x });
376
+ Object.defineProperty(e, "clientY", { value: y });
377
+ return e;
378
+ }
379
+
380
+ /** A pointerenter carrying viewport coordinates — the host evaluates the
381
+ * toggle containment on ENTER, before it arms the dwell. */
382
+ function pointerEnterAt(x: number, y: number): MouseEvent {
383
+ const e = new MouseEvent("pointerenter", { bubbles: true });
384
+ Object.defineProperty(e, "clientX", { value: x });
385
+ Object.defineProperty(e, "clientY", { value: y });
386
+ return e;
387
+ }
388
+
389
+ /** A transitionend carrying the property name (happy-dom has no
390
+ * TransitionEvent; the host handler filters on `width`). */
391
+ function widthTransitionEnd(): Event {
392
+ const e = new Event("transitionend");
393
+ Object.defineProperty(e, "propertyName", { value: "width" });
394
+ return e;
395
+ }
396
+
397
+ /** Pin the toggle wrapper's box — happy-dom lays nothing out. */
398
+ function stubToggleRect(container: HTMLElement, left: number, top: number, size = 24) {
399
+ const el = container.querySelector(".hk-sidebar-rail-toggle") as HTMLElement;
400
+ el.getBoundingClientRect = () =>
401
+ ({
402
+ left,
403
+ top,
404
+ right: left + size,
405
+ bottom: top + size,
406
+ width: size,
407
+ height: size,
408
+ x: left,
409
+ y: top,
410
+ toJSON: () => ({}),
411
+ }) as DOMRect;
412
+ }
413
+
414
+ describe("HkFaceShell rail chrome: toggle placement and the clip wrapper", () => {
415
+ it("keeps the toggle a DIRECT child of the panel and the slot inside the clip", () => {
416
+ const c = mountShell({ sidebarCollapsible: true });
417
+ const panel = c.panel();
418
+ const clip = panel.querySelector(":scope > .hk-sidebar-rail-clip") as HTMLElement | null;
419
+ const toggle = panel.querySelector(":scope > .hk-sidebar-rail-toggle") as HTMLElement | null;
420
+
421
+ expect(clip, "the slot's content clips inside the wrapper").toBeTruthy();
422
+ expect(toggle, "the toggle is a direct child of the panel").toBeTruthy();
423
+ expect(clip!.contains(toggle!), "the toggle is never inside the clip").toBe(false);
424
+ expect(clip!.querySelector(".sidebar-mock"), "the sidebar slot renders in the clip").toBeTruthy();
425
+ });
426
+
427
+ it("does not touch the legacy sidebar path (no clip wrapper, no rail classes)", () => {
428
+ const c = mountShell();
429
+ expect(c.container.querySelector(".hk-sidebar-rail-clip")).toBeFalsy();
430
+ expect(c.container.querySelector(".hk-sidebar-rail-panel")).toBeFalsy();
431
+ expect(c.container.querySelector(".sidebar-mock")).toBeTruthy();
432
+ });
433
+ });
434
+
435
+ describe("HkFaceShell pin window: expand must not replay the slide", () => {
436
+ beforeEach(() => {
437
+ vi.useFakeTimers();
438
+ });
439
+
440
+ it("pins on a rail -> expanded flip and clears on the host width transition", async () => {
441
+ const c = mountShell({ sidebarCollapsible: true });
442
+ c.toggle().click();
443
+ await vi.advanceTimersByTimeAsync(0);
444
+ expect(c.aside().hasAttribute("data-pinning"), "collapsing never pins").toBe(false);
445
+
446
+ c.toggle().click();
447
+ await vi.advanceTimersByTimeAsync(0);
448
+ expect(c.aside().hasAttribute("data-pinning"), "the expand window is pinned").toBe(true);
449
+ // The panel is already at its final width — only the host animates.
450
+ expect(c.panel().style.width).toBe("224px");
451
+ expect(c.aside().style.width).toBe("224px");
452
+
453
+ c.aside().dispatchEvent(widthTransitionEnd());
454
+ await vi.advanceTimersByTimeAsync(0);
455
+ expect(c.aside().hasAttribute("data-pinning")).toBe(false);
456
+ });
457
+
458
+ it("clears the pin window by the fallback when no transition fires", async () => {
459
+ const c = mountShell({ sidebarCollapsible: true });
460
+ c.toggle().click();
461
+ await vi.advanceTimersByTimeAsync(0);
462
+ c.toggle().click();
463
+ await vi.advanceTimersByTimeAsync(0);
464
+ expect(c.aside().hasAttribute("data-pinning")).toBe(true);
465
+
466
+ await vi.advanceTimersByTimeAsync(400);
467
+ expect(c.aside().hasAttribute("data-pinning")).toBe(false);
468
+ });
469
+
470
+ it("collapsing closes an open pin window immediately", async () => {
471
+ const c = mountShell({ sidebarCollapsible: true });
472
+ c.toggle().click();
473
+ await vi.advanceTimersByTimeAsync(0);
474
+ c.toggle().click();
475
+ await vi.advanceTimersByTimeAsync(0);
476
+ expect(c.aside().hasAttribute("data-pinning")).toBe(true);
477
+
478
+ c.toggle().click();
479
+ await vi.advanceTimersByTimeAsync(0);
480
+ expect(c.aside().hasAttribute("data-pinning")).toBe(false);
481
+ expect(c.aside().style.width).toBe("56px");
482
+ });
483
+
484
+ it("a child transitionend never ends the window (filtered to the host's width)", async () => {
485
+ const c = mountShell({ sidebarCollapsible: true });
486
+ c.toggle().click();
487
+ await vi.advanceTimersByTimeAsync(0);
488
+ c.toggle().click();
489
+ await vi.advanceTimersByTimeAsync(0);
490
+
491
+ c.panel().dispatchEvent(widthTransitionEnd());
492
+ await vi.advanceTimersByTimeAsync(0);
493
+ expect(c.aside().hasAttribute("data-pinning")).toBe(true);
494
+ });
495
+ });
496
+
497
+ describe("HkFaceShell hover suppression: a pointer on the toggle never peeks", () => {
498
+ beforeEach(() => {
499
+ vi.useFakeTimers();
500
+ });
501
+
502
+ it("suppresses the dwell while the pointer rests on the toggle, re-arms when it moves off", async () => {
503
+ const c = mountShell({ sidebarCollapsible: true });
504
+ c.toggle().click();
505
+ await vi.advanceTimersByTimeAsync(0);
506
+ stubToggleRect(c.container, 100, 200);
507
+
508
+ c.aside().dispatchEvent(POINTER_ENTER());
509
+ c.aside().dispatchEvent(pointerMove(110, 210));
510
+ await vi.advanceTimersByTimeAsync(400 * 4);
511
+ expect(c.aside().hasAttribute("data-peeking"), "parked on the toggle: no peek").toBe(false);
512
+ expect(c.slotParams[c.slotParams.length - 1]!.collapsed).toBe(true);
513
+
514
+ c.aside().dispatchEvent(pointerMove(400, 500));
515
+ await vi.advanceTimersByTimeAsync(400);
516
+ expect(c.aside().hasAttribute("data-peeking"), "off the toggle the peek arms normally").toBe(true);
517
+ expect(c.slotParams[c.slotParams.length - 1]!.peeking).toBe(true);
518
+ });
519
+
520
+ it("evaluates suppression on ENTER: crossing the boundary onto the toggle arms nothing", async () => {
521
+ const c = mountShell({ sidebarCollapsible: true });
522
+ c.toggle().click();
523
+ await vi.advanceTimersByTimeAsync(0);
524
+ stubToggleRect(c.container, 100, 200);
525
+
526
+ c.aside().dispatchEvent(pointerEnterAt(110, 210));
527
+ await vi.advanceTimersByTimeAsync(400 * 4);
528
+ expect(c.aside().hasAttribute("data-peeking"), "enter on the toggle arms nothing").toBe(false);
529
+ expect(c.slotParams[c.slotParams.length - 1]!.collapsed).toBe(true);
530
+ });
531
+
532
+ it("evaluates suppression on ENTER: entering elsewhere on the rail still peeks", async () => {
533
+ const c = mountShell({ sidebarCollapsible: true });
534
+ c.toggle().click();
535
+ await vi.advanceTimersByTimeAsync(0);
536
+ stubToggleRect(c.container, 100, 200);
537
+
538
+ c.aside().dispatchEvent(pointerEnterAt(400, 500));
539
+ await vi.advanceTimersByTimeAsync(399);
540
+ expect(c.aside().hasAttribute("data-peeking"), "one tick before the dwell").toBe(false);
541
+ await vi.advanceTimersByTimeAsync(1);
542
+ expect(c.aside().hasAttribute("data-peeking"), "off the toggle the enter arms the dwell").toBe(true);
543
+ });
544
+
545
+ it("ignores a pointerleave whose relatedTarget is still inside the host", async () => {
546
+ const c = mountShell({ sidebarCollapsible: true });
547
+ c.toggle().click();
548
+ await vi.advanceTimersByTimeAsync(0);
549
+ c.aside().dispatchEvent(POINTER_ENTER());
550
+ await vi.advanceTimersByTimeAsync(400);
551
+ expect(c.aside().hasAttribute("data-peeking")).toBe(true);
552
+
553
+ const churn = POINTER_LEAVE();
554
+ Object.defineProperty(churn, "relatedTarget", { value: c.container.querySelector(".sidebar-mock") });
555
+ c.aside().dispatchEvent(churn);
556
+ await vi.advanceTimersByTimeAsync(150 * 4);
557
+ expect(c.aside().hasAttribute("data-peeking"), "DOM churn is not a leave").toBe(true);
558
+
559
+ c.aside().dispatchEvent(POINTER_LEAVE());
560
+ await vi.advanceTimersByTimeAsync(150);
561
+ expect(c.aside().hasAttribute("data-peeking")).toBe(false);
562
+ });
563
+
564
+ it("clicking the toggle during an open peek still pins the sidebar expanded", async () => {
565
+ const c = mountShell({ sidebarCollapsible: true });
566
+ c.toggle().click();
567
+ await vi.advanceTimersByTimeAsync(0);
568
+ c.aside().dispatchEvent(POINTER_ENTER());
569
+ await vi.advanceTimersByTimeAsync(400);
570
+ expect(c.aside().hasAttribute("data-peeking")).toBe(true);
571
+
572
+ c.toggle().click();
573
+ await vi.advanceTimersByTimeAsync(0);
574
+ expect(c.aside().hasAttribute("data-peeking")).toBe(false);
575
+ expect(c.aside().style.width).toBe("224px");
576
+ expect(c.aside().hasAttribute("data-pinning"), "the peek pin is a rail -> expanded flip").toBe(true);
577
+ });
578
+ });
@@ -2,7 +2,7 @@ import { computed, defineComponent, ref, watch, type PropType } from "vue";
2
2
  import { HkCrossfade, HDrawer, HScrollContainer, useBreakpoint } from "@celestia-island/hikari";
3
3
  import { useI18n } from "../i18n/context";
4
4
  import { useSidebarRail } from "../composables/useSidebarRail";
5
- import HkSidebarRailToggle from "./HkSidebarRailToggle";
5
+ import HkSidebarRailToggle, { isPointerOverRailToggle } from "./HkSidebarRailToggle";
6
6
 
7
7
  /** CSS-wide keywords. A shorthand rebuilt from one of them is NOT a
8
8
  * padding (`inherit 0 inherit` is rejected by every engine, measured in
@@ -171,13 +171,22 @@ export const HkFaceShell = defineComponent({
171
171
  sidebarCollapsed: { type: Boolean, default: false },
172
172
  sidebarWidth: { type: String, default: "224px" },
173
173
  /** OPT-IN icon-rail capability for the desktop sidebar (2026-10-30):
174
- * mounts the hover-reveal circular chevron toggle on the sidebar's
175
- * content-facing edge, lets a click collapse the sidebar to a one-
176
- * icon rail, and — while collapsed — opens a hover-peek drawer that
177
- * slides the full sidebar OVER the content as an overlay (the
178
- * layout keeps the rail width; the content area never shifts).
179
- * Timing lives in useSidebarRail (dwell, grace, Escape); geometry in
180
- * HkSidebarRail.scss (.hk-sidebar-rail-host/-panel/-toggle).
174
+ * mounts the hover-reveal circular chevron toggle STRADDLING the
175
+ * sidebar's content-facing border (its center on the border line,
176
+ * half the circle over the content), lets a click collapse the
177
+ * sidebar to a one-icon rail, and — while collapsed — opens a
178
+ * hover-peek drawer that slides the full sidebar OVER the content as
179
+ * an overlay (the layout keeps the rail width; the content area never
180
+ * shifts).
181
+ *
182
+ * Two behaviors ride the same opt-in: a pointer resting on the toggle
183
+ * SUPPRESSES the peek dwell (it sits on the rail's hover surface and
184
+ * would otherwise open the drawer out from under the cursor), and a
185
+ * rail -> expanded flip PINNS the panel at its final width for the
186
+ * transition window, so only the content area animates instead of the
187
+ * sidebar re-sliding. Timing lives in useSidebarRail (dwell, grace,
188
+ * suppression, Escape, pinning); geometry in HkSidebarRail.scss
189
+ * (.hk-sidebar-rail-host/-panel/-clip/-toggle).
181
190
  * False (default) renders the sidebar exactly as before. Below
182
191
  * mobileBreakpoint nothing changes — the mobile drawer stays the
183
192
  * only nav surface. */
@@ -291,10 +300,15 @@ export const HkFaceShell = defineComponent({
291
300
  emit("update:sidebarRailStyle", v ? "rail" : "expanded");
292
301
  },
293
302
  });
303
+ // The host REGION element (the rail <aside>) — the machine uses it to
304
+ // tell a real pointerleave from DOM churn during the peek/width
305
+ // transitions.
306
+ const railHostRef = ref<HTMLElement>();
294
307
  const rail = useSidebarRail({
295
308
  collapsed: railCollapsed,
296
309
  peekDelay: () => props.sidebarPeekDelay,
297
310
  peekGrace: () => props.sidebarPeekGrace,
311
+ host: () => railHostRef.value,
298
312
  });
299
313
  const railActive = computed(() => props.sidebarCollapsible && railCollapsed.value);
300
314
  // The LAYOUT width — the core contract of the peek: rail width while
@@ -327,6 +341,33 @@ export const HkFaceShell = defineComponent({
327
341
  if (rail.onKeydown(e)) e.stopPropagation();
328
342
  };
329
343
 
344
+ /** A pointer parked on the toggle must NOT arm the peek dwell — the
345
+ * toggle straddles the rail's hover surface, so the drawer would open
346
+ * under the resting cursor and jump the button away from it (the
347
+ * reported twitch). Containment is GEOMETRIC: before the reveal the
348
+ * toggle is opacity:0 / pointer-events:none, so event.target cannot
349
+ * report it.
350
+ *
351
+ * ENTER evaluates it too, and BEFORE the arm: a pointer that crosses
352
+ * the boundary and stops exactly on the toggle (or a synthetic enter
353
+ * with no following move) must not arm the dwell. */
354
+ const onSidebarHostPointerEnter = (e: PointerEvent) => {
355
+ rail.setPeekSuppressed(isPointerOverRailToggle(railHostRef.value, e.clientX, e.clientY));
356
+ rail.onPointerEnter();
357
+ };
358
+ const onSidebarHostPointerMove = (e: PointerEvent) => {
359
+ rail.setPeekSuppressed(isPointerOverRailToggle(railHostRef.value, e.clientX, e.clientY));
360
+ };
361
+
362
+ /** The pin window ends with the HOST's own width transition; child
363
+ * transitionend events (the panel, a nested HkSidebar) bubble here
364
+ * too, so filter to this element's width. */
365
+ const onSidebarHostTransitionEnd = (e: TransitionEvent) => {
366
+ if (e.target !== e.currentTarget) return;
367
+ if (e.propertyName !== "width") return;
368
+ rail.endPinning();
369
+ };
370
+
330
371
  const contentStyle = computed(() => ({
331
372
  padding: isDesktop.value || !props.contentBleedOnMobile
332
373
  ? props.contentPadding
@@ -392,12 +433,20 @@ export const HkFaceShell = defineComponent({
392
433
  {isDesktop.value && !props.sidebarCollapsed && slots.sidebar && (
393
434
  props.sidebarCollapsible ? (
394
435
  <aside
436
+ ref={railHostRef}
395
437
  class="hk-sidebar-rail-host"
396
438
  data-rail={railActive.value || undefined}
397
439
  data-peeking={rail.peeking.value || undefined}
440
+ // Transient rail -> expanded window (useSidebarRail.pinning):
441
+ // the panel and any nested sidebar snap to their final width
442
+ // while THIS host's width transition plays — expanding must
443
+ // not replay the sidebar slide.
444
+ data-pinning={rail.pinning.value || undefined}
398
445
  style={{ width: hostSidebarWidth.value, flexShrink: 0 }}
399
- onPointerenter={rail.onPointerEnter}
446
+ onPointerenter={onSidebarHostPointerEnter}
400
447
  onPointerleave={rail.onPointerLeave}
448
+ onPointermove={onSidebarHostPointerMove}
449
+ onTransitionend={onSidebarHostTransitionEnd}
401
450
  onKeydown={onSidebarHostKeydown}
402
451
  >
403
452
  {/* The visual panel — absolutely positioned over the host
@@ -413,11 +462,18 @@ export const HkFaceShell = defineComponent({
413
462
  background: "rgb(var(--color-surface))",
414
463
  }}
415
464
  >
416
- {slots.sidebar({
417
- collapsed: railActive.value && !rail.peeking.value,
418
- onNavigate: onSidebarNavigate,
419
- peeking: rail.peeking.value,
420
- })}
465
+ {/* The panel no longer clips (the toggle straddles its
466
+ border), so the slot's content clips inside this
467
+ wrapper — full rows stay inside rail width while the
468
+ panel width animates. The toggle stays a DIRECT child
469
+ of the panel: it must never be clipped. */}
470
+ <div class="hk-sidebar-rail-clip">
471
+ {slots.sidebar({
472
+ collapsed: railActive.value && !rail.peeking.value,
473
+ onNavigate: onSidebarNavigate,
474
+ peeking: rail.peeking.value,
475
+ })}
476
+ </div>
421
477
  <HkSidebarRailToggle expanded={!railActive.value} onToggle={rail.toggle} />
422
478
  </div>
423
479
  </aside>