@celestia-island/hikari 0.55.239 → 0.55.241
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/HkAboutModal.scss +2 -1
- package/src/components/HkAlert.scss +3 -2
- package/src/components/HkAvatar.scss +3 -1
- package/src/components/HkBadge.scss +2 -2
- package/src/components/HkBlockingToast.scss +6 -6
- package/src/components/HkBoard.scss +3 -1
- package/src/components/HkBoard3D.scss +3 -1
- package/src/components/HkBreadcrumb.scss +2 -1
- package/src/components/HkButton.scss +3 -2
- package/src/components/HkCard.scss +2 -1
- package/src/components/HkCheckbox.scss +3 -1
- package/src/components/HkColorPicker.scss +5 -3
- package/src/components/HkConfirmDialog.scss +3 -2
- package/src/components/HkCopyBadge.test.tsx +1 -1
- package/src/components/HkDatePicker.scss +3 -2
- package/src/components/HkDateTimePicker.scss +2 -1
- package/src/components/HkDiffViewer.scss +6 -6
- package/src/components/HkDockBar.scss +4 -2
- package/src/components/HkDockBar.test.tsx +1 -1
- package/src/components/HkDraggableGrid.scss +4 -3
- package/src/components/HkDraggableList.scss +4 -3
- package/src/components/HkDrawer.scss +5 -4
- package/src/components/HkEmptyState.scss +3 -1
- package/src/components/HkEmptyState.surface.contract.test.ts +5 -2
- package/src/components/HkErrorLanding.scss +3 -1
- package/src/components/HkFab.scss +5 -2
- package/src/components/HkFaceShell.rail.test.tsx +285 -4
- package/src/components/HkFaceShell.tsx +14 -0
- package/src/components/HkImageLightbox.scss +3 -1
- package/src/components/HkImageViewer.scss +3 -1
- package/src/components/HkJsonTree.scss +3 -3
- package/src/components/HkLoadingVeil.scss +4 -2
- package/src/components/HkLogWindow.scss +2 -2
- package/src/components/HkLogo.scss +3 -1
- package/src/components/HkMarkdownRenderer.scss +3 -3
- package/src/components/HkMediaControlBar.scss +4 -2
- package/src/components/HkMediaSlider.scss +3 -1
- package/src/components/HkMenu.rail.test.tsx +29 -0
- package/src/components/HkMenu.tsx +62 -17
- package/src/components/HkMenuPanel.scss +4 -0
- package/src/components/HkMenuSection.test.tsx +191 -0
- package/src/components/HkMenuSection.tsx +95 -0
- package/src/components/HkMessageBox.scss +3 -2
- package/src/components/HkMinimap.scss +5 -3
- package/src/components/HkModal.scss +10 -11
- package/src/components/HkModal.sheetmorph.test.ts +8 -6
- package/src/components/HkModalBreadcrumb.blur.test.ts +3 -3
- package/src/components/HkModalBreadcrumb.scss +6 -4
- package/src/components/HkNavItem.scss +2 -1
- package/src/components/HkNodeCanvas.scss +3 -1
- package/src/components/HkOtpInput.scss +2 -1
- package/src/components/HkPasswordSurface.scss +4 -3
- package/src/components/HkPillToggleGroup.scss +13 -18
- package/src/components/HkPopover.scss +5 -6
- package/src/components/HkProgressRing.scss +3 -1
- package/src/components/HkRadio.scss +3 -1
- package/src/components/HkScrollContainer.fade.contract.test.ts +7 -2
- package/src/components/HkScrollContainer.scss +23 -46
- package/src/components/HkScrollPin.scss +3 -1
- package/src/components/HkSelect.scss +4 -5
- package/src/components/HkSelectionGrid.scss +3 -1
- package/src/components/HkSelectionWaterfall.scss +3 -1
- package/src/components/HkSidebar.rail.test.tsx +105 -3
- package/src/components/HkSidebar.scss +4 -1
- package/src/components/HkSidebar.tsx +15 -0
- package/src/components/HkSidebarRailSwap.tsx +9 -3
- package/src/components/HkSlider.scss +3 -1
- package/src/components/HkSpinner.scss +5 -2
- package/src/components/HkSwitch.scss +3 -1
- package/src/components/HkTable.interactions.test.tsx +4 -2
- package/src/components/HkTable.scss +8 -8
- package/src/components/HkTag.scss +3 -1
- package/src/components/HkTagInput.scss +5 -6
- package/src/components/HkTagInput.test.tsx +5 -4
- package/src/components/HkTemplateField.scss +22 -7
- package/src/components/HkTemplateField.styles.test.ts +50 -2
- package/src/components/HkTextarea.scss +3 -1
- package/src/components/HkThemeToggle.scss +2 -2
- package/src/components/HkTitleBar.scss +4 -3
- package/src/components/HkToast.scss +5 -4
- package/src/components/HkTreemapStage.scss +3 -1
- package/src/components/HkZoomToolbar.scss +3 -2
- package/src/components/_menu-item.contract.test.ts +450 -0
- package/src/components/_menu-item.scss +98 -4
- package/src/components/window-close.scss +4 -2
- package/src/composables/useMenuGroupState.ts +90 -0
- package/src/index.ts +1 -0
- package/src/styles/admin-tokens.scss +21 -3
- package/src/styles/polyfill.contract.test.ts +294 -0
- package/src/styles/polyfill.scss +110 -0
- package/src/styles/selection.scss +4 -4
- package/src/theme/_field.scss +3 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { createApp, defineComponent, h, ref } from "vue";
|
|
2
|
+
import { createApp, defineComponent, h, ref, type VNode } from "vue";
|
|
3
3
|
|
|
4
4
|
// Same stub shape as HkFaceShell.test.tsx: HDrawer teleports and
|
|
5
5
|
// animates; the desktop rail contract is testable without the overlay
|
|
@@ -33,6 +33,7 @@ vi.mock("@celestia-island/hikari", async (importOriginal) => {
|
|
|
33
33
|
|
|
34
34
|
import { HkFaceShell } from "./HkFaceShell";
|
|
35
35
|
import { HkAdminShell } from "./HkAdminShell";
|
|
36
|
+
import HkMenu, { type HkMenuItem } from "./HkMenu";
|
|
36
37
|
|
|
37
38
|
/**
|
|
38
39
|
* HkFaceShell desktop rail + peek integration (2026-10-30). What this
|
|
@@ -75,11 +76,19 @@ interface RailMount {
|
|
|
75
76
|
main(): HTMLElement;
|
|
76
77
|
toggle(): HTMLButtonElement;
|
|
77
78
|
slotParams: SlotParams[];
|
|
79
|
+
headerParams: Array<Record<string, unknown>>;
|
|
80
|
+
/** Open the mobile nav drawer through the header's onOpenDrawer (the
|
|
81
|
+
* sanctioned host channel — the hamburger lives in the header slot). */
|
|
82
|
+
openDrawer(): void;
|
|
78
83
|
emitted: string[];
|
|
79
84
|
}
|
|
80
85
|
|
|
81
|
-
function mountShell(
|
|
86
|
+
function mountShell(
|
|
87
|
+
props: Record<string, unknown> = {},
|
|
88
|
+
sidebarSlot?: (params: SlotParams) => VNode,
|
|
89
|
+
): RailMount {
|
|
82
90
|
const slotParams: SlotParams[] = [];
|
|
91
|
+
const headerParams: Array<Record<string, unknown>> = [];
|
|
83
92
|
const emitted: string[] = [];
|
|
84
93
|
const container = document.createElement("div");
|
|
85
94
|
document.body.appendChild(container);
|
|
@@ -100,10 +109,13 @@ function mountShell(props: Record<string, unknown> = {}): RailMount {
|
|
|
100
109
|
railStyle.value = v as "expanded" | "rail";
|
|
101
110
|
},
|
|
102
111
|
}, {
|
|
103
|
-
header: () =>
|
|
112
|
+
header: (params: Record<string, unknown>) => {
|
|
113
|
+
headerParams.push(params);
|
|
114
|
+
return null;
|
|
115
|
+
},
|
|
104
116
|
sidebar: (params: SlotParams) => {
|
|
105
117
|
slotParams.push(params);
|
|
106
|
-
return h("div", { class: "sidebar-mock" }, "NAV");
|
|
118
|
+
return sidebarSlot?.(params) ?? h("div", { class: "sidebar-mock" }, "NAV");
|
|
107
119
|
},
|
|
108
120
|
content: () => h("div", { class: "content-mock" }, "CONTENT"),
|
|
109
121
|
});
|
|
@@ -121,6 +133,8 @@ function mountShell(props: Record<string, unknown> = {}): RailMount {
|
|
|
121
133
|
toggle: () =>
|
|
122
134
|
container.querySelector(".hk-sidebar-rail-toggle button") as HTMLButtonElement,
|
|
123
135
|
slotParams,
|
|
136
|
+
headerParams,
|
|
137
|
+
openDrawer: () => (headerParams[headerParams.length - 1]!.onOpenDrawer as () => void)(),
|
|
124
138
|
emitted,
|
|
125
139
|
};
|
|
126
140
|
}
|
|
@@ -130,6 +144,11 @@ afterEach(() => {
|
|
|
130
144
|
app.unmount();
|
|
131
145
|
container.remove();
|
|
132
146
|
}
|
|
147
|
+
// HkMenu's rail rows ride HkTooltip (teleported to the overlay root).
|
|
148
|
+
document.querySelectorAll("#hk-overlay-root, .hk-tooltip-popup").forEach((n) => n.remove());
|
|
149
|
+
// The drawer-path tests below resize the window to the mobile side;
|
|
150
|
+
// every other case here assumes the desktop width.
|
|
151
|
+
window.innerWidth = 1024;
|
|
133
152
|
vi.useRealTimers();
|
|
134
153
|
});
|
|
135
154
|
|
|
@@ -611,3 +630,265 @@ describe("HkFaceShell hover suppression: a pointer on the toggle never peeks", (
|
|
|
611
630
|
expect(c.aside().hasAttribute("data-pinning"), "the peek pin is a rail -> expanded flip").toBe(true);
|
|
612
631
|
});
|
|
613
632
|
});
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* The menu group fold vs the swap's GENERATIONS (#43, known-live-issues).
|
|
636
|
+
*
|
|
637
|
+
* HkSidebarRailSwap mounts each density as a FRESH generation — the
|
|
638
|
+
* tab-panel contract — so an HkMenu inside the sidebar slot is a NEW
|
|
639
|
+
* component instance in every generation. HkMenu's own docs still promise
|
|
640
|
+
* "a group collapsed in the rail is still collapsed after a peek/expand
|
|
641
|
+
* (and vice versa)"; 0.55.239 broke that promise by keeping the fold in
|
|
642
|
+
* instance-local refs, measured on chest (same chest source: 2 rows /
|
|
643
|
+
* aria-expanded=false on 0.55.236, 5 rows / true on 0.55.239).
|
|
644
|
+
*
|
|
645
|
+
* The fix is a stable fold store provided ABOVE the swap: every
|
|
646
|
+
* generation injects the same instance, so the user's word survives an
|
|
647
|
+
* arbitrary number of remounts. These are the tests that go red when the
|
|
648
|
+
* store stops being provided above the swap (or HkMenu stops reading it).
|
|
649
|
+
*/
|
|
650
|
+
const GROUP_ITEMS: HkMenuItem[] = [
|
|
651
|
+
{ key: "home", label: "Home" },
|
|
652
|
+
{
|
|
653
|
+
key: "admin",
|
|
654
|
+
label: "Administration",
|
|
655
|
+
children: [
|
|
656
|
+
{ key: "users", label: "Users" },
|
|
657
|
+
{ key: "audit", label: "Audit log" },
|
|
658
|
+
],
|
|
659
|
+
},
|
|
660
|
+
];
|
|
661
|
+
|
|
662
|
+
/** The ACTIVE generation, selected by the key it renders under: the
|
|
663
|
+
* leaving copy is frozen at its last render (it still shows the OLD
|
|
664
|
+
* fold), so asserting against it would pass for the wrong reason. */
|
|
665
|
+
function generation(container: HTMLElement, key: "rail" | "full"): HTMLElement {
|
|
666
|
+
const gen = container.querySelector<HTMLElement>(
|
|
667
|
+
`.hk-crossfade-item[data-crossfade-key="${key}"]`,
|
|
668
|
+
);
|
|
669
|
+
expect(gen, `the ${key} generation renders`).toBeTruthy();
|
|
670
|
+
return gen!;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** The group header inside a generation — the rail and full forms share
|
|
674
|
+
* the same class + aria-expanded contract. */
|
|
675
|
+
function groupToggle(root: HTMLElement): HTMLButtonElement {
|
|
676
|
+
const row = root.querySelector<HTMLButtonElement>("button.hk-menu-sidebar-group-toggle");
|
|
677
|
+
expect(row, "the menu's group header renders").toBeTruthy();
|
|
678
|
+
return row!;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** Let a dissolve settle (past HkCrossfade's 450ms watchdog), then hand
|
|
682
|
+
* back the generation that should now be the only live one. */
|
|
683
|
+
async function settle(container: HTMLElement, key: "rail" | "full"): Promise<HTMLElement> {
|
|
684
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
685
|
+
return generation(container, key);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
describe("rail group fold survives the crossfade generations (#43)", () => {
|
|
689
|
+
beforeEach(() => {
|
|
690
|
+
vi.useFakeTimers();
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
it("keeps a group collapsed in the rail through a peek AND a full expand", async () => {
|
|
694
|
+
const c = mountShell({ sidebarCollapsible: true }, () =>
|
|
695
|
+
h(HkMenu, {
|
|
696
|
+
variant: "sidebar",
|
|
697
|
+
items: GROUP_ITEMS,
|
|
698
|
+
open: false,
|
|
699
|
+
defaultExpandGroups: true,
|
|
700
|
+
}),
|
|
701
|
+
);
|
|
702
|
+
c.toggle().click();
|
|
703
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
704
|
+
|
|
705
|
+
const railGen = await settle(c.container, "rail");
|
|
706
|
+
expect(
|
|
707
|
+
groupToggle(railGen).getAttribute("aria-expanded"),
|
|
708
|
+
"table-of-contents default: every group starts open",
|
|
709
|
+
).toBe("true");
|
|
710
|
+
groupToggle(railGen).click();
|
|
711
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
712
|
+
expect(groupToggle(railGen).getAttribute("aria-expanded")).toBe("false");
|
|
713
|
+
expect(railGen.textContent, "the closed group lists no children").not.toContain("Audit log");
|
|
714
|
+
|
|
715
|
+
// Peek: the slot is handed FULL rows again — a brand-new HkMenu.
|
|
716
|
+
c.aside().dispatchEvent(POINTER_ENTER());
|
|
717
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
718
|
+
const peekGen = await settle(c.container, "full");
|
|
719
|
+
expect(c.aside().hasAttribute("data-peeking"), "the peek is open").toBe(true);
|
|
720
|
+
expect(
|
|
721
|
+
groupToggle(peekGen).getAttribute("aria-expanded"),
|
|
722
|
+
"the fold crosses into the peek's fresh generation",
|
|
723
|
+
).toBe("false");
|
|
724
|
+
expect(peekGen.textContent, "and the peek lists no children either").not.toContain("Audit log");
|
|
725
|
+
|
|
726
|
+
// Leave -> grace -> the rail generation mounts a THIRD time, closed.
|
|
727
|
+
c.aside().dispatchEvent(POINTER_LEAVE());
|
|
728
|
+
await vi.advanceTimersByTimeAsync(150);
|
|
729
|
+
const backGen = await settle(c.container, "rail");
|
|
730
|
+
expect(c.aside().hasAttribute("data-peeking")).toBe(false);
|
|
731
|
+
expect(
|
|
732
|
+
groupToggle(backGen).getAttribute("aria-expanded"),
|
|
733
|
+
"the fold survives the return leg too",
|
|
734
|
+
).toBe("false");
|
|
735
|
+
|
|
736
|
+
// A full EXPAND (not a peek) reads the same fold.
|
|
737
|
+
c.toggle().click();
|
|
738
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
739
|
+
const expandedGen = await settle(c.container, "full");
|
|
740
|
+
expect(
|
|
741
|
+
groupToggle(expandedGen).getAttribute("aria-expanded"),
|
|
742
|
+
"and the expanded sidebar inherits it",
|
|
743
|
+
).toBe("false");
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
it("does not let the active row's auto-expansion resurrect a rail-collapsed group", async () => {
|
|
747
|
+
// The chest shape: the group holding the active row auto-opens on every
|
|
748
|
+
// FRESH mount (the auto seed), which is exactly what brought the
|
|
749
|
+
// collapsed group back. The user's word must outrank the re-seed.
|
|
750
|
+
const c = mountShell({ sidebarCollapsible: true }, () =>
|
|
751
|
+
h(HkMenu, {
|
|
752
|
+
variant: "sidebar",
|
|
753
|
+
items: GROUP_ITEMS,
|
|
754
|
+
open: false,
|
|
755
|
+
activeKey: "users",
|
|
756
|
+
}),
|
|
757
|
+
);
|
|
758
|
+
c.toggle().click();
|
|
759
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
760
|
+
|
|
761
|
+
const railGen = await settle(c.container, "rail");
|
|
762
|
+
expect(
|
|
763
|
+
groupToggle(railGen).getAttribute("aria-expanded"),
|
|
764
|
+
"the active row auto-opens its group",
|
|
765
|
+
).toBe("true");
|
|
766
|
+
groupToggle(railGen).click();
|
|
767
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
768
|
+
expect(groupToggle(railGen).getAttribute("aria-expanded")).toBe("false");
|
|
769
|
+
|
|
770
|
+
c.aside().dispatchEvent(POINTER_ENTER());
|
|
771
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
772
|
+
const peekGen = await settle(c.container, "full");
|
|
773
|
+
expect(
|
|
774
|
+
groupToggle(peekGen).getAttribute("aria-expanded"),
|
|
775
|
+
"the fresh generation re-seeds its auto set but keeps the user's word",
|
|
776
|
+
).toBe("false");
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
it("shows NO expanded flash mid-dissolve, and never patches the leaving copy", async () => {
|
|
780
|
+
// MEASURED contract, not an inference. happy-dom completes a CSS
|
|
781
|
+
// transition with no duration instantly, so the leaving copy would be
|
|
782
|
+
// gone before it could be inspected; freezing rAF holds the leave open
|
|
783
|
+
// exactly the way a real browser holds it for --duration-normal (the
|
|
784
|
+
// same technique the swap test above uses).
|
|
785
|
+
const c = mountShell({ sidebarCollapsible: true }, () =>
|
|
786
|
+
h(HkMenu, {
|
|
787
|
+
variant: "sidebar",
|
|
788
|
+
items: GROUP_ITEMS,
|
|
789
|
+
open: false,
|
|
790
|
+
defaultExpandGroups: true,
|
|
791
|
+
}),
|
|
792
|
+
);
|
|
793
|
+
c.toggle().click();
|
|
794
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
795
|
+
const railGen = await settle(c.container, "rail");
|
|
796
|
+
groupToggle(railGen).click();
|
|
797
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
798
|
+
expect(groupToggle(railGen).getAttribute("aria-expanded")).toBe("false");
|
|
799
|
+
|
|
800
|
+
vi.stubGlobal("requestAnimationFrame", () => 0);
|
|
801
|
+
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
802
|
+
c.aside().dispatchEvent(POINTER_ENTER());
|
|
803
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
804
|
+
|
|
805
|
+
const copiesNow = () => Array.from(c.container.querySelectorAll<HTMLElement>(".hk-crossfade-item"));
|
|
806
|
+
expect(
|
|
807
|
+
copiesNow().map((el) => el.dataset.crossfadeKey),
|
|
808
|
+
"both generations share the swap cell during the dissolve",
|
|
809
|
+
).toEqual(["rail", "full"]);
|
|
810
|
+
expect(
|
|
811
|
+
copiesNow().map((el) => groupToggle(el).getAttribute("aria-expanded")),
|
|
812
|
+
"neither copy shows the group re-opened",
|
|
813
|
+
).toEqual(["false", "false"]);
|
|
814
|
+
|
|
815
|
+
// Now write the shared store DURING the dissolve (a toggle in the live
|
|
816
|
+
// copy). The live copy re-renders; the leaving one keeps the DOM of its
|
|
817
|
+
// last render — the frozen copy is out of the patch path, which is what
|
|
818
|
+
// makes the dissolve safe. Reading the frozen copy after the write is
|
|
819
|
+
// the measurement of that claim.
|
|
820
|
+
const live = copiesNow()[1]!;
|
|
821
|
+
groupToggle(live).click();
|
|
822
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
823
|
+
const afterWrite = copiesNow().map((el) => ({
|
|
824
|
+
key: el.dataset.crossfadeKey,
|
|
825
|
+
expanded: groupToggle(el).getAttribute("aria-expanded"),
|
|
826
|
+
}));
|
|
827
|
+
expect(afterWrite).toEqual([
|
|
828
|
+
{ key: "rail", expanded: "false" },
|
|
829
|
+
{ key: "full", expanded: "true" },
|
|
830
|
+
]);
|
|
831
|
+
vi.unstubAllGlobals();
|
|
832
|
+
|
|
833
|
+
// The open group is the shared word: the next rail generation after the
|
|
834
|
+
// peek closes shows it open (nothing was silently reverted mid-swap).
|
|
835
|
+
c.aside().dispatchEvent(POINTER_LEAVE());
|
|
836
|
+
await vi.advanceTimersByTimeAsync(150);
|
|
837
|
+
const backGen = await settle(c.container, "rail");
|
|
838
|
+
expect(groupToggle(backGen).getAttribute("aria-expanded")).toBe("true");
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
it("shares the fold with the mobile drawer's menu (the provide sits above BOTH slots)", async () => {
|
|
842
|
+
// The rail is not the only slot consumer: under the breakpoint the same
|
|
843
|
+
// sidebar slot renders inside the drawer. The store is provided from
|
|
844
|
+
// HkFaceShell's own setup, so the drawer's fresh HkMenu instance must
|
|
845
|
+
// read the desktop rail's fold too — otherwise the rail would be fixed
|
|
846
|
+
// while mobile silently loses it. (provide/inject follows the component
|
|
847
|
+
// tree, so the real HDrawer's teleport does not change the provider —
|
|
848
|
+
// this stub renders inline and still pins the channel.)
|
|
849
|
+
const c = mountShell({ sidebarCollapsible: true }, () =>
|
|
850
|
+
h(HkMenu, {
|
|
851
|
+
variant: "sidebar",
|
|
852
|
+
items: GROUP_ITEMS,
|
|
853
|
+
open: false,
|
|
854
|
+
defaultExpandGroups: true,
|
|
855
|
+
}),
|
|
856
|
+
);
|
|
857
|
+
c.toggle().click();
|
|
858
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
859
|
+
const railGen = await settle(c.container, "rail");
|
|
860
|
+
groupToggle(railGen).click();
|
|
861
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
862
|
+
expect(groupToggle(railGen).getAttribute("aria-expanded"), "collapsed in the rail").toBe("false");
|
|
863
|
+
|
|
864
|
+
// Phone width: the desktop sidebar unmounts, the drawer becomes the nav.
|
|
865
|
+
window.innerWidth = 800;
|
|
866
|
+
window.dispatchEvent(new Event("resize"));
|
|
867
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
868
|
+
expect(c.aside(), "no desktop rail under the breakpoint").toBeFalsy();
|
|
869
|
+
|
|
870
|
+
c.openDrawer();
|
|
871
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
872
|
+
const drawerBody = c.container.querySelector(".drawer-stub-body") as HTMLElement;
|
|
873
|
+
expect(drawerBody, "the drawer renders the sidebar slot").toBeTruthy();
|
|
874
|
+
expect(
|
|
875
|
+
groupToggle(drawerBody).getAttribute("aria-expanded"),
|
|
876
|
+
"the drawer's menu reads the SAME fold",
|
|
877
|
+
).toBe("false");
|
|
878
|
+
|
|
879
|
+
// And the other direction: opening it in the drawer is what the desktop
|
|
880
|
+
// sidebar shows when the window grows back.
|
|
881
|
+
groupToggle(drawerBody).click();
|
|
882
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
883
|
+
expect(groupToggle(drawerBody).getAttribute("aria-expanded")).toBe("true");
|
|
884
|
+
|
|
885
|
+
window.innerWidth = 1024;
|
|
886
|
+
window.dispatchEvent(new Event("resize"));
|
|
887
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
888
|
+
const desktopGen = await settle(c.container, "rail");
|
|
889
|
+
expect(
|
|
890
|
+
groupToggle(desktopGen).getAttribute("aria-expanded"),
|
|
891
|
+
"the rail inherits the drawer's word",
|
|
892
|
+
).toBe("true");
|
|
893
|
+
});
|
|
894
|
+
});
|
|
@@ -2,6 +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 { provideMenuGroupState } from "../composables/useMenuGroupState";
|
|
5
6
|
import HkSidebarRailSwap from "./HkSidebarRailSwap";
|
|
6
7
|
import HkSidebarRailToggle, { isPointerOverRailToggle } from "./HkSidebarRailToggle";
|
|
7
8
|
|
|
@@ -280,6 +281,19 @@ export const HkFaceShell = defineComponent({
|
|
|
280
281
|
const isDesktop = computed(() => viewportWidth.value >= props.mobileBreakpoint);
|
|
281
282
|
const sidebarOpen = ref(false);
|
|
282
283
|
|
|
284
|
+
// ── sidebar menu GROUP fold, stable across the rail crossfade ─────
|
|
285
|
+
// The rail swap mounts each density as a FRESH generation, so an
|
|
286
|
+
// HkMenu in the sidebar slot is a new instance per generation and its
|
|
287
|
+
// instance-local group fold would be lost on every flip (a group
|
|
288
|
+
// collapsed in the rail came back expanded after a peek/expand —
|
|
289
|
+
// known-live-issues #43). Providing the fold store HERE — one level
|
|
290
|
+
// above the swap — makes every generation inject the SAME instance,
|
|
291
|
+
// so the user's word survives the remounts without a new prop and
|
|
292
|
+
// without any host-side change. Unconditional: the store is inert
|
|
293
|
+
// until an HkMenu reads it, and the mobile drawer's slot must see the
|
|
294
|
+
// same one as the desktop sidebar.
|
|
295
|
+
provideMenuGroupState();
|
|
296
|
+
|
|
283
297
|
// ── desktop icon rail + hover peek (sidebarCollapsible) ──────────
|
|
284
298
|
// SEMI-CONTROLLED rail state: a local session-only ref is the live
|
|
285
299
|
// state, so the toggle works with NO channel bound at all (the
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
// every wheel/pointer gesture dead because their surface had no area).
|
|
18
18
|
// ------
|
|
19
19
|
|
|
20
|
+
@use "../styles/polyfill" as *;
|
|
21
|
+
|
|
20
22
|
.hk-modal-content.hk-image-lightbox {
|
|
21
23
|
width: min(96vw, 80rem);
|
|
22
24
|
height: min(90vh, 56rem);
|
|
@@ -101,7 +103,7 @@
|
|
|
101
103
|
top: var(--space-12, 0.75rem);
|
|
102
104
|
right: var(--space-12, 0.75rem);
|
|
103
105
|
z-index: 2;
|
|
104
|
-
backdrop-filter
|
|
106
|
+
@include backdrop-filter(blur(6px));
|
|
105
107
|
|
|
106
108
|
&:hover,
|
|
107
109
|
&:focus-visible {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
@use "../styles/polyfill" as *;
|
|
2
|
+
|
|
1
3
|
.hk-image-viewer {
|
|
2
4
|
position: relative;
|
|
3
5
|
width: 100%;
|
|
@@ -7,7 +9,7 @@
|
|
|
7
9
|
background: rgb(0 0 0 / 45%);
|
|
8
10
|
border-radius: var(--radius-sm);
|
|
9
11
|
touch-action: none;
|
|
10
|
-
user-select
|
|
12
|
+
@include user-select(none);
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
// The transform glide sits behind the .is-animatable gate the component
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
// Use theme variables with namespace to avoid conflicts
|
|
7
7
|
@use "./hikari-vars" as vars;
|
|
8
|
+
@use "../styles/polyfill" as *;
|
|
8
9
|
|
|
9
10
|
.s-tool-json-tree {
|
|
10
11
|
--jt-toggle: 14px;
|
|
@@ -16,7 +17,7 @@
|
|
|
16
17
|
background: rgb(var(--color-surface) / 0.5);
|
|
17
18
|
border-radius: var(--radius-sm, 4px);
|
|
18
19
|
border: 1px solid var(--border-faint, rgb(var(--color-border) / 10%));
|
|
19
|
-
user-select
|
|
20
|
+
@include user-select(none);
|
|
20
21
|
max-height: 320px;
|
|
21
22
|
overflow: auto;
|
|
22
23
|
|
|
@@ -48,8 +49,7 @@
|
|
|
48
49
|
/* Tree rows are content — values users copy out of a payload — while
|
|
49
50
|
* the pane chrome above stays denied (L2 selection policy, 2026-10-29;
|
|
50
51
|
* the copy-tree affordance remains the bulk path). */
|
|
51
|
-
|
|
52
|
-
user-select: text;
|
|
52
|
+
@include user-select(text);
|
|
53
53
|
/* Baseline alignment keeps the small type hints (boolean/number/string)
|
|
54
54
|
on the value's text baseline instead of floating above it. */
|
|
55
55
|
display: flex;
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
// The z-index sits above the toast band (--hk-z-toast) so the veil is
|
|
3
3
|
// the topmost painted surface while active; pointer-events: all on the
|
|
4
4
|
// root swallows every interaction beneath (the blocking contract).
|
|
5
|
+
|
|
6
|
+
@use "../styles/polyfill" as *;
|
|
7
|
+
|
|
5
8
|
.hk-loading-veil {
|
|
6
9
|
position: fixed;
|
|
7
10
|
inset: 0;
|
|
@@ -12,8 +15,7 @@
|
|
|
12
15
|
// Semi-transparent backdrop with a slight blur: the page beneath
|
|
13
16
|
// stays readable (orientation) but clearly inactive.
|
|
14
17
|
background: rgb(var(--color-background, 255 255 255) / 0.55);
|
|
15
|
-
backdrop-filter
|
|
16
|
-
-webkit-backdrop-filter: blur(6px);
|
|
18
|
+
@include backdrop-filter(blur(6px));
|
|
17
19
|
pointer-events: all;
|
|
18
20
|
animation: hk-loading-veil-in 150ms ease-out;
|
|
19
21
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Use theme variables with namespace to avoid conflicts
|
|
2
2
|
@use "./hikari-vars" as vars;
|
|
3
|
+
@use "../styles/polyfill" as *;
|
|
3
4
|
|
|
4
5
|
.s-log-viewer {
|
|
5
6
|
display: flex;
|
|
@@ -121,8 +122,7 @@
|
|
|
121
122
|
* select per-fragment, not just via the toolbar copy-all) — re-enable
|
|
122
123
|
* under the L2 selection policy (2026-10-29); the toolbar chrome
|
|
123
124
|
* around them stays denied. */
|
|
124
|
-
|
|
125
|
-
user-select: text;
|
|
125
|
+
@include user-select(text);
|
|
126
126
|
padding: var(--space-2, 0.125rem) var(--space-4, 0.25rem);
|
|
127
127
|
font-family: var(--font-mono, vars.$hikari-font-family-mono);
|
|
128
128
|
font-size: var(--text-xs, 0.75rem);
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
@use "../styles/polyfill" as *;
|
|
2
|
+
|
|
1
3
|
.hk-logo {
|
|
2
4
|
display: inline-flex;
|
|
3
5
|
align-items: center;
|
|
@@ -6,7 +8,7 @@
|
|
|
6
8
|
border-radius: var(--radius-md, 8px);
|
|
7
9
|
overflow: hidden;
|
|
8
10
|
flex-shrink: 0;
|
|
9
|
-
user-select
|
|
11
|
+
@include user-select(none);
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
a.hk-logo {
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// Use theme variables with namespace to avoid conflicts
|
|
2
2
|
@use "./hikari-vars" as vars;
|
|
3
|
+
@use "../styles/polyfill" as *;
|
|
3
4
|
|
|
4
5
|
.hk-markdown {
|
|
5
6
|
/* Rendered prose is content by definition — the whole subtree selects
|
|
6
7
|
* again under the L2 selection policy (2026-10-29); its code blocks
|
|
7
8
|
* are covered twice over (whitelist tier 3). */
|
|
8
|
-
|
|
9
|
-
user-select: text;
|
|
9
|
+
@include user-select(text);
|
|
10
10
|
font-size: var(--text-base);
|
|
11
11
|
line-height: 1.7;
|
|
12
12
|
color: var(--hi-color-text-primary, #1e1e1e);
|
|
@@ -204,6 +204,6 @@
|
|
|
204
204
|
align-items: center;
|
|
205
205
|
justify-content: center;
|
|
206
206
|
background: color-mix(in srgb, var(--hi-color-surface, #ffffff) 70%, transparent);
|
|
207
|
-
backdrop-filter
|
|
207
|
+
@include backdrop-filter(blur(4px));
|
|
208
208
|
z-index: 10;
|
|
209
209
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
@use "../styles/polyfill" as *;
|
|
2
|
+
|
|
1
3
|
.hk-media-controls {
|
|
2
4
|
display: flex;
|
|
3
5
|
align-items: center;
|
|
4
6
|
gap: var(--space-8);
|
|
5
7
|
padding: var(--space-6) var(--space-10);
|
|
6
8
|
background: rgb(var(--color-surface) / 0.85);
|
|
7
|
-
backdrop-filter
|
|
9
|
+
@include backdrop-filter(blur(var(--blur-sm)));
|
|
8
10
|
border-top: 1px solid var(--border-faint);
|
|
9
11
|
color: rgb(var(--color-text));
|
|
10
12
|
}
|
|
@@ -34,7 +36,7 @@
|
|
|
34
36
|
font-variant-numeric: tabular-nums;
|
|
35
37
|
color: rgb(var(--color-muted));
|
|
36
38
|
white-space: nowrap;
|
|
37
|
-
user-select
|
|
39
|
+
@include user-select(none);
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
.hk-media-volume {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
@use "../styles/polyfill" as *;
|
|
2
|
+
|
|
1
3
|
.hk-media-slider {
|
|
2
4
|
position: relative;
|
|
3
5
|
height: 6px;
|
|
@@ -5,7 +7,7 @@
|
|
|
5
7
|
background: rgb(var(--color-muted) / 22%);
|
|
6
8
|
cursor: pointer;
|
|
7
9
|
touch-action: none;
|
|
8
|
-
user-select
|
|
10
|
+
@include user-select(none);
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
.hk-media-slider[data-size="sm"] {
|
|
@@ -250,6 +250,35 @@ describe("HkMenu sidebar icon rail", () => {
|
|
|
250
250
|
expect(groupRows(c)[0]!.getAttribute("aria-expanded")).toBe("false");
|
|
251
251
|
});
|
|
252
252
|
|
|
253
|
+
it("keeps the fold INSTANCE-LOCAL when no shell provides one (standalone menus)", async () => {
|
|
254
|
+
// The other half of the #43 contract. HkFaceShell provides the fold
|
|
255
|
+
// store above its rail crossfade so a swap's fresh generations share
|
|
256
|
+
// it; a standalone menu has no shell above it and must keep the
|
|
257
|
+
// instance-local sets it has always had. A module-level singleton
|
|
258
|
+
// store would silently make two menus on one page share fold keys —
|
|
259
|
+
// that regression is what this pin catches.
|
|
260
|
+
const a = mountMenu(ITEMS, { collapsed: true });
|
|
261
|
+
const b = mountMenu(ITEMS, { collapsed: true });
|
|
262
|
+
expect(groupRows(a)[0]!.getAttribute("aria-expanded")).toBe("false");
|
|
263
|
+
expect(groupRows(b)[0]!.getAttribute("aria-expanded")).toBe("false");
|
|
264
|
+
|
|
265
|
+
groupRows(a)[0]!.click();
|
|
266
|
+
await nextTick();
|
|
267
|
+
expect(groupRows(a)[0]!.getAttribute("aria-expanded"), "menu A opens its group").toBe("true");
|
|
268
|
+
expect(
|
|
269
|
+
groupRows(b)[0]!.getAttribute("aria-expanded"),
|
|
270
|
+
"menu B keeps its own fold — nothing is shared without a provider",
|
|
271
|
+
).toBe("false");
|
|
272
|
+
|
|
273
|
+
// And the standalone flip-in-place contract still holds: the rail and
|
|
274
|
+
// the expanded sidebar are the SAME instance here, so the fold carries
|
|
275
|
+
// exactly as it did before the shared store existed.
|
|
276
|
+
groupRows(b)[0]!.click();
|
|
277
|
+
await nextTick();
|
|
278
|
+
expect(groupRows(b)[0]!.getAttribute("aria-expanded")).toBe("true");
|
|
279
|
+
expect(groupRows(a)[0]!.getAttribute("aria-expanded"), "A is still open, B now open").toBe("true");
|
|
280
|
+
});
|
|
281
|
+
|
|
253
282
|
it("recurses into nested groups — 2nd/3rd-level destinations stay reachable", async () => {
|
|
254
283
|
const nested: HkMenuItem[] = [
|
|
255
284
|
{
|