feat(admin-ui): add responsive layout state
This commit is contained in:
79
WonderQ-Admin-UI-Vue/src/stores/layout.test.ts
Normal file
79
WonderQ-Admin-UI-Vue/src/stores/layout.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
103
WonderQ-Admin-UI-Vue/src/stores/layout.ts
Normal file
103
WonderQ-Admin-UI-Vue/src/stores/layout.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user