feat(admin-ui): add responsive layout state

This commit is contained in:
duanshuwen
2026-08-26 20:30:18 +08:00
parent 63ce8797bc
commit 0b106a3dd2
2 changed files with 182 additions and 0 deletions

View File

@@ -0,0 +1,79 @@
import { createPinia, setActivePinia } from "pinia";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useLayoutStore } from "./layout";
describe("layout store", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it.each([
[991, "mobile"],
[992, "desktop"],
[993, "desktop"],
] as const)("uses the strict mobile breakpoint at %spx", (width, expectedDevice) => {
setActivePinia(createPinia());
const store = useLayoutStore();
store.syncViewport(width);
expect(store.device).toBe(expectedDevice);
expect(store.sidebarOpened).toBe(expectedDevice === "desktop");
});
it("is safe to use without window", () => {
setActivePinia(createPinia());
const store = useLayoutStore();
expect(() => store.syncViewport()).not.toThrow();
expect(() => store.startViewportWatcher()).not.toThrow();
expect(store.device).toBe("desktop");
});
it("registers one resize watcher, updates on resize, and unsubscribes idempotently", () => {
const addEventListener = vi.fn();
const removeEventListener = vi.fn();
const browserWindow = { innerWidth: 993, addEventListener, removeEventListener };
vi.stubGlobal("window", browserWindow);
setActivePinia(createPinia());
const store = useLayoutStore();
const stop = store.startViewportWatcher();
const sameStop = store.startViewportWatcher();
const resizeHandler = addEventListener.mock.calls[0][1] as () => void;
expect(sameStop).toBe(stop);
expect(addEventListener).toHaveBeenCalledTimes(1);
expect(store.device).toBe("desktop");
browserWindow.innerWidth = 991;
resizeHandler();
expect(store.device).toBe("mobile");
expect(store.sidebarOpened).toBe(false);
stop();
stop();
expect(removeEventListener).toHaveBeenCalledTimes(1);
browserWindow.innerWidth = 993;
resizeHandler();
expect(store.device).toBe("mobile");
});
it("controls the sidebar and animation flag independently", () => {
setActivePinia(createPinia());
const store = useLayoutStore();
store.toggleSidebar({ withoutAnimation: true });
expect(store.sidebarOpened).toBe(false);
expect(store.withoutAnimation).toBe(true);
store.openSidebar({ withoutAnimation: false });
expect(store.sidebarOpened).toBe(true);
expect(store.withoutAnimation).toBe(false);
store.closeSidebar();
expect(store.sidebarOpened).toBe(false);
expect(store.withoutAnimation).toBe(false);
});
});

View File

@@ -0,0 +1,103 @@
import { ref } from "vue";
import { defineStore } from "pinia";
export type DeviceType = "desktop" | "mobile";
export type SidebarToggleOptions = {
withoutAnimation?: boolean;
};
const MOBILE_BREAKPOINT = 992;
const noopUnsubscribe = () => undefined;
export const useLayoutStore = defineStore("layout", () => {
const device = ref<DeviceType>("desktop");
const sidebarOpened = ref(true);
const settingsDrawerOpen = ref(false);
const withoutAnimation = ref(false);
let stopViewportWatcher: (() => void) | null = null;
function applyToggleOptions(options: SidebarToggleOptions = {}) {
if (options.withoutAnimation !== undefined) {
withoutAnimation.value = options.withoutAnimation;
}
}
function syncViewport(width?: number) {
const viewportWidth = width ?? (typeof window === "undefined" ? 1024 : window.innerWidth);
const nextDevice: DeviceType = viewportWidth < MOBILE_BREAKPOINT ? "mobile" : "desktop";
device.value = nextDevice;
if (nextDevice === "mobile") {
sidebarOpened.value = false;
}
}
function toggleSidebar(options?: SidebarToggleOptions) {
applyToggleOptions(options);
sidebarOpened.value = !sidebarOpened.value;
}
function openSidebar(options?: SidebarToggleOptions) {
applyToggleOptions(options);
sidebarOpened.value = true;
}
function closeSidebar(options?: SidebarToggleOptions) {
applyToggleOptions(options);
sidebarOpened.value = false;
}
function openSettings() {
settingsDrawerOpen.value = true;
}
function closeSettings() {
settingsDrawerOpen.value = false;
}
function startViewportWatcher() {
if (typeof window === "undefined") {
return noopUnsubscribe;
}
if (stopViewportWatcher) {
return stopViewportWatcher;
}
const currentWindow = window;
let active = true;
function handleResize() {
if (!active) return;
syncViewport(currentWindow.innerWidth);
}
syncViewport(currentWindow.innerWidth);
currentWindow.addEventListener("resize", handleResize);
function unsubscribe() {
if (!active) return;
active = false;
currentWindow.removeEventListener("resize", handleResize);
if (stopViewportWatcher === unsubscribe) {
stopViewportWatcher = null;
}
}
stopViewportWatcher = unsubscribe;
return unsubscribe;
}
return {
device,
sidebarOpened,
settingsDrawerOpen,
withoutAnimation,
syncViewport,
toggleSidebar,
openSidebar,
closeSidebar,
openSettings,
closeSettings,
startViewportWatcher,
};
});