feat: zustand store for overlay state
This commit is contained in:
60
src/lib/store.test.ts
Normal file
60
src/lib/store.test.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { useOverlayStore } from "./store";
|
||||||
|
|
||||||
|
const initial = useOverlayStore.getState();
|
||||||
|
|
||||||
|
describe("useOverlayStore", () => {
|
||||||
|
beforeEach(() => useOverlayStore.setState(initial, true));
|
||||||
|
|
||||||
|
it("starts with sensible defaults", () => {
|
||||||
|
const s = useOverlayStore.getState();
|
||||||
|
expect(s.url).toBe("");
|
||||||
|
expect(s.opacity).toBe(1.0);
|
||||||
|
expect(s.alwaysOnTop).toBe(true);
|
||||||
|
expect(s.clickThrough).toBe(false);
|
||||||
|
expect(s.isOpen).toBe(false);
|
||||||
|
expect(s.urlError).toBe(null);
|
||||||
|
expect(s.hotkeyError).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setUrl validates and stores both url and urlError", () => {
|
||||||
|
useOverlayStore.getState().setUrl("twitch.tv");
|
||||||
|
expect(useOverlayStore.getState().url).toBe("twitch.tv");
|
||||||
|
expect(useOverlayStore.getState().urlError).toBe(null);
|
||||||
|
|
||||||
|
useOverlayStore.getState().setUrl("javascript:alert(1)");
|
||||||
|
expect(useOverlayStore.getState().urlError).toMatch(/http and https/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setOpacity clamps to 0.1..1.0", () => {
|
||||||
|
useOverlayStore.getState().setOpacity(0.05);
|
||||||
|
expect(useOverlayStore.getState().opacity).toBe(0.1);
|
||||||
|
useOverlayStore.getState().setOpacity(2);
|
||||||
|
expect(useOverlayStore.getState().opacity).toBe(1.0);
|
||||||
|
useOverlayStore.getState().setOpacity(0.5);
|
||||||
|
expect(useOverlayStore.getState().opacity).toBe(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setAlwaysOnTop and setClickThrough mutate flags", () => {
|
||||||
|
useOverlayStore.getState().setAlwaysOnTop(false);
|
||||||
|
expect(useOverlayStore.getState().alwaysOnTop).toBe(false);
|
||||||
|
useOverlayStore.getState().setClickThrough(true);
|
||||||
|
expect(useOverlayStore.getState().clickThrough).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hydrate replaces persisted fields and clears errors", () => {
|
||||||
|
useOverlayStore.getState().setUrl("javascript:alert(1)");
|
||||||
|
useOverlayStore.getState().hydrate({
|
||||||
|
url: "https://example.com",
|
||||||
|
opacity: 0.6,
|
||||||
|
alwaysOnTop: false,
|
||||||
|
clickThrough: true,
|
||||||
|
});
|
||||||
|
const s = useOverlayStore.getState();
|
||||||
|
expect(s.url).toBe("https://example.com");
|
||||||
|
expect(s.opacity).toBe(0.6);
|
||||||
|
expect(s.alwaysOnTop).toBe(false);
|
||||||
|
expect(s.clickThrough).toBe(true);
|
||||||
|
expect(s.urlError).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
46
src/lib/store.ts
Normal file
46
src/lib/store.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type { OverlayActions, OverlayPersistedState, OverlayState } from "../types/overlay";
|
||||||
|
import { validateUrl } from "./url";
|
||||||
|
|
||||||
|
const clamp = (v: number, min: number, max: number): number =>
|
||||||
|
Math.min(max, Math.max(min, v));
|
||||||
|
|
||||||
|
export const useOverlayStore = create<OverlayState & OverlayActions>()((set) => ({
|
||||||
|
url: "",
|
||||||
|
opacity: 1.0,
|
||||||
|
alwaysOnTop: true,
|
||||||
|
clickThrough: false,
|
||||||
|
isOpen: false,
|
||||||
|
urlError: null,
|
||||||
|
hotkeyError: null,
|
||||||
|
|
||||||
|
setUrl: (raw) => {
|
||||||
|
const result = validateUrl(raw);
|
||||||
|
set({
|
||||||
|
url: raw,
|
||||||
|
urlError: result.ok ? null : raw.trim() === "" ? null : result.error,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setOpacity: (v) => set({ opacity: clamp(v, 0.1, 1.0) }),
|
||||||
|
|
||||||
|
setAlwaysOnTop: (v) => set({ alwaysOnTop: v }),
|
||||||
|
|
||||||
|
setClickThrough: (v) => set({ clickThrough: v }),
|
||||||
|
|
||||||
|
_setClickThroughFromBackend: (v) =>
|
||||||
|
set((s) => (s.clickThrough === v ? s : { clickThrough: v })),
|
||||||
|
|
||||||
|
setHotkeyError: (msg) => set({ hotkeyError: msg }),
|
||||||
|
|
||||||
|
setIsOpen: (v) => set({ isOpen: v }),
|
||||||
|
|
||||||
|
hydrate: (p: OverlayPersistedState) =>
|
||||||
|
set({
|
||||||
|
url: p.url,
|
||||||
|
opacity: clamp(p.opacity, 0.1, 1.0),
|
||||||
|
alwaysOnTop: p.alwaysOnTop,
|
||||||
|
clickThrough: p.clickThrough,
|
||||||
|
urlError: null,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user