feat(admin-ui): add responsive layout state
This commit is contained in:
1 parent
63ce8797bc
commit
0b106a3dd2
2 files changed
+182
No files matched your search
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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