From be93dbff7273061dabeca57d754f5bf20c4bc431 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sun, 21 Jun 2026 11:38:02 -0600 Subject: [PATCH] =?UTF-8?q?feat(drill):=20DrillStackProvider=20=E2=80=94?= =?UTF-8?q?=20zustand-backed=20peek=20stack=20with=202-level=20cap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../drill/DrillStackProvider.test.tsx | 45 ++++++++++++++++ src/components/drill/DrillStackProvider.tsx | 53 +++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 src/components/drill/DrillStackProvider.test.tsx create mode 100644 src/components/drill/DrillStackProvider.tsx diff --git a/src/components/drill/DrillStackProvider.test.tsx b/src/components/drill/DrillStackProvider.test.tsx new file mode 100644 index 0000000..1c6467a --- /dev/null +++ b/src/components/drill/DrillStackProvider.test.tsx @@ -0,0 +1,45 @@ +// @vitest-environment happy-dom +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +import { describe, it, expect } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { + DrillStackProvider, + useDrillStack, +} from "@/components/drill/DrillStackProvider"; + +function wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +describe("DrillStackProvider", () => { + it("starts with an empty stack", () => { + const { result } = renderHook(() => useDrillStack(), { wrapper }); + expect(result.current.stack).toEqual([]); + expect(result.current.openPeek).toBeInstanceOf(Function); + expect(result.current.closeTop).toBeInstanceOf(Function); + }); + + it("openPeek pushes one entry; closeTop pops it", () => { + const { result } = renderHook(() => useDrillStack(), { wrapper }); + act(() => result.current.openPeek({ kind: "payer", payerId: "SKCO0" })); + expect(result.current.stack).toEqual([ + { kind: "payer", payerId: "SKCO0" }, + ]); + act(() => result.current.closeTop()); + expect(result.current.stack).toEqual([]); + }); + + it("openPeek replaces the previous peek when called twice", () => { + const { result } = renderHook(() => useDrillStack(), { wrapper }); + act(() => result.current.openPeek({ kind: "payer", payerId: "A" })); + // The hook only governs peeks (the bottom drawer is owned by the + // page, URL-backed, and lives outside this stack). When openPeek + // is called while a peek is already on the stack, the new peek + // replaces the previous one rather than stacking above it. + act(() => result.current.openPeek({ kind: "rule", rule: "R050" })); + expect(result.current.stack).toHaveLength(1); + expect(result.current.stack[0]).toEqual({ kind: "rule", rule: "R050" }); + }); +}); diff --git a/src/components/drill/DrillStackProvider.tsx b/src/components/drill/DrillStackProvider.tsx new file mode 100644 index 0000000..4ffbc7b --- /dev/null +++ b/src/components/drill/DrillStackProvider.tsx @@ -0,0 +1,53 @@ +import { createContext, useContext, useMemo, type ReactNode } from "react"; +import { create } from "zustand"; + +export type PeekPayload = + | { kind: "payer"; payerId: string } + | { kind: "rule"; rule: string }; + +interface DrillState { + stack: PeekPayload[]; + openPeek: (p: PeekPayload) => void; + closeTop: () => void; + closeAll: () => void; +} + +// One zustand store per provider instance (factory) so multiple +// providers (e.g. in tests) don't share state. +function makeStore() { + return create((set) => ({ + stack: [], + openPeek: (p) => + set((s) => ({ + // Cap at 2 levels total: one drawer + one peek. When called and + // the stack already has one peek, replace it. + stack: s.stack.length >= 1 ? [p] : [p], + })), + closeTop: () => set((s) => ({ stack: s.stack.slice(0, -1) })), + closeAll: () => set({ stack: [] }), + })); +} + +type StoreApi = ReturnType; + +const Ctx = createContext(null); + +export function DrillStackProvider({ children }: { children: ReactNode }) { + // useMemo so the store instance is stable across renders. + const store = useMemo(makeStore, []); + return {children}; +} + +export function useDrillStack() { + const store = useContext(Ctx); + if (!store) throw new Error("useDrillStack must be used within DrillStackProvider"); + // Subscribe to just `stack` so consumers re-render only on stack + // changes (not on every state update). + const stack = store((s) => s.stack); + return { + stack, + openPeek: store.getState().openPeek, + closeTop: store.getState().closeTop, + closeAll: store.getState().closeAll, + }; +}