diff options
Diffstat (limited to 'src/features/views/ui')
| -rw-r--r-- | src/features/views/ui/ViewSidebar.svelte | 170 | ||||
| -rw-r--r-- | src/features/views/ui/ViewSidebar.test.ts | 76 |
2 files changed, 122 insertions, 124 deletions
diff --git a/src/features/views/ui/ViewSidebar.svelte b/src/features/views/ui/ViewSidebar.svelte index e4a3ee6..e9e8682 100644 --- a/src/features/views/ui/ViewSidebar.svelte +++ b/src/features/views/ui/ViewSidebar.svelte @@ -1,97 +1,95 @@ <script lang="ts"> - import { type Snippet, untrack } from "svelte"; - import { - addPanel, - initialPanels, - type PanelsState, - removePanel, - selectKind, - } from "../logic/panels"; + import { type Snippet, untrack } from "svelte"; + import { + addPanel, + initialPanels, + type PanelsState, + removePanel, + selectKind, + } from "../logic/panels"; - interface ViewKind { - readonly id: string; - readonly label: string; - } + interface ViewKind { + readonly id: string; + readonly label: string; + } - let { - kinds, - content, - initial, - onChange, - }: { - /** The view kinds offered in every panel's dropdown. */ - kinds: readonly ViewKind[]; - /** Renders a panel body for the given (non-null) view-kind id. */ - content: Snippet<[string]>; - /** Optional seed of panel kinds; defaults to one panel of the first kind. */ - initial?: readonly (string | null)[]; - /** Called whenever the panel layout changes (add/remove/select). */ - onChange?: (kinds: readonly (string | null)[]) => void; - } = $props(); + let { + kinds, + content, + initial, + onChange, + }: { + /** The view kinds offered in every panel's dropdown. */ + kinds: readonly ViewKind[]; + /** Renders a panel body for the given (non-null) view-kind id. */ + content: Snippet<[string]>; + /** Optional seed of panel kinds; defaults to one panel of the first kind. */ + initial?: readonly (string | null)[]; + /** Called whenever the panel layout changes (add/remove/select). */ + onChange?: (kinds: readonly (string | null)[]) => void; + } = $props(); - // Local UI composition state, owned by this unit and folded through the pure - // reducer — never reached from elsewhere (no ambient store). Seeded ONCE from - // the props (untrack makes that one-time read explicit, not reactive). - let state = $state<PanelsState>( - untrack(() => initialPanels(initial ?? [kinds[0]?.id ?? null])), - ); + // Local UI composition state, owned by this unit and folded through the pure + // reducer — never reached from elsewhere (no ambient store). Seeded ONCE from + // the props (untrack makes that one-time read explicit, not reactive). + let state = $state<PanelsState>(untrack(() => initialPanels(initial ?? [kinds[0]?.id ?? null]))); - function notify(): void { - onChange?.(state.panels.map((p) => p.kind)); - } + function notify(): void { + onChange?.(state.panels.map((p) => p.kind)); + } </script> <div class="flex min-h-0 flex-col gap-2"> - {#each state.panels as panel, idx (panel.id)} - <div class="flex flex-col rounded-lg bg-base-200 p-3"> - <div class="flex items-center gap-1"> - <select - class="select select-bordered select-sm flex-1" - aria-label="Select a view" - value={panel.kind ?? ""} - onchange={(e) => { - const v = e.currentTarget.value; - state = selectKind(state, panel.id, v === "" ? null : v); - notify(); - }} - > - <option value="" disabled>Select a view</option> - {#each kinds as kind (kind.id)} - <option value={kind.id}>{kind.label}</option> - {/each} - </select> - {#if idx > 0} - <button - type="button" - class="btn btn-square btn-ghost btn-sm shrink-0" - aria-label="Remove view" - onclick={() => { - state = removePanel(state, panel.id); - notify(); - }} - > - ✕ - </button> - {/if} - </div> + {#each state.panels as panel, idx (panel.id)} + <div class="flex flex-col rounded-lg bg-base-200 p-3"> + <div class="flex items-center gap-1"> + <select + class="select select-bordered select-sm flex-1" + aria-label="Select a view" + value={panel.kind ?? ""} + onchange={(e) => { + const v = e.currentTarget.value; + state = selectKind(state, panel.id, v === "" ? null : v); + notify(); + }} + > + <option value="" disabled>Select a view</option> + {#each kinds as kind (kind.id)} + <option value={kind.id}>{kind.label}</option> + {/each} + </select> + {#if idx > 0} + <button + type="button" + class="btn btn-square btn-ghost btn-sm shrink-0" + aria-label="Remove view" + onclick={() => { + state = removePanel(state, panel.id); + notify(); + }} + > + ✕ + </button> + {/if} + </div> - {#if panel.kind !== null} - <div class="mt-2"> - {@render content(panel.kind)} - </div> - {/if} - </div> - {/each} + {#if panel.kind !== null} + <div class="mt-2"> + {@render content(panel.kind)} + </div> + {/if} + </div> + {/each} - <button - type="button" - class="btn w-full border-none bg-base-200 text-lg hover:bg-base-300" - aria-label="Add view" - onclick={() => { - state = addPanel(state); - notify(); - }} - > - + - </button> + <button + type="button" + class="btn w-full border-none bg-base-200 text-lg hover:bg-base-300" + aria-label="Add view" + onclick={() => { + state = addPanel(state); + notify(); + }} + > + + + </button> </div> diff --git a/src/features/views/ui/ViewSidebar.test.ts b/src/features/views/ui/ViewSidebar.test.ts index 8a0049c..0618506 100644 --- a/src/features/views/ui/ViewSidebar.test.ts +++ b/src/features/views/ui/ViewSidebar.test.ts @@ -5,54 +5,54 @@ import { describe, expect, it } from "vitest"; import ViewSidebar from "./ViewSidebar.svelte"; const kinds = [ - { id: "surfaces", label: "Surfaces" }, - { id: "tasks", label: "Tasks" }, + { id: "surfaces", label: "Surfaces" }, + { id: "tasks", label: "Tasks" }, ]; // A raw snippet that echoes the kind it was rendered for, so tests can assert // which view-kind content each panel shows. const content = createRawSnippet<[string]>((kind) => ({ - render: () => `<div data-testid="view-content">kind:${kind()}</div>`, + render: () => `<div data-testid="view-content">kind:${kind()}</div>`, })); describe("ViewSidebar", () => { - it("opens one panel seeded with the first kind and renders its content", () => { - render(ViewSidebar, { props: { kinds, content } }); - expect(screen.getAllByRole("combobox")).toHaveLength(1); - expect(screen.getByTestId("view-content")).toHaveTextContent("kind:surfaces"); - }); + it("opens one panel seeded with the first kind and renders its content", () => { + render(ViewSidebar, { props: { kinds, content } }); + expect(screen.getAllByRole("combobox")).toHaveLength(1); + expect(screen.getByTestId("view-content")).toHaveTextContent("kind:surfaces"); + }); - it("the first panel has no remove button", () => { - render(ViewSidebar, { props: { kinds, content } }); - expect(screen.queryByRole("button", { name: "Remove view" })).toBeNull(); - }); + it("the first panel has no remove button", () => { + render(ViewSidebar, { props: { kinds, content } }); + expect(screen.queryByRole("button", { name: "Remove view" })).toBeNull(); + }); - it("the add button appends a new empty panel", async () => { - const user = userEvent.setup(); - render(ViewSidebar, { props: { kinds, content } }); - await user.click(screen.getByRole("button", { name: "Add view" })); - expect(screen.getAllByRole("combobox")).toHaveLength(2); - // the new panel is empty → only the first panel renders content - expect(screen.getAllByTestId("view-content")).toHaveLength(1); - }); + it("the add button appends a new empty panel", async () => { + const user = userEvent.setup(); + render(ViewSidebar, { props: { kinds, content } }); + await user.click(screen.getByRole("button", { name: "Add view" })); + expect(screen.getAllByRole("combobox")).toHaveLength(2); + // the new panel is empty → only the first panel renders content + expect(screen.getAllByTestId("view-content")).toHaveLength(1); + }); - it("non-first panels can be removed", async () => { - const user = userEvent.setup(); - render(ViewSidebar, { props: { kinds, content } }); - await user.click(screen.getByRole("button", { name: "Add view" })); - const removeButtons = screen.getAllByRole("button", { name: "Remove view" }); - expect(removeButtons).toHaveLength(1); - const target = removeButtons[0]; - if (target === undefined) throw new Error("expected a remove button"); - await user.click(target); - expect(screen.getAllByRole("combobox")).toHaveLength(1); - }); + it("non-first panels can be removed", async () => { + const user = userEvent.setup(); + render(ViewSidebar, { props: { kinds, content } }); + await user.click(screen.getByRole("button", { name: "Add view" })); + const removeButtons = screen.getAllByRole("button", { name: "Remove view" }); + expect(removeButtons).toHaveLength(1); + const target = removeButtons[0]; + if (target === undefined) throw new Error("expected a remove button"); + await user.click(target); + expect(screen.getAllByRole("combobox")).toHaveLength(1); + }); - it("selecting a kind renders that kind's content", async () => { - const user = userEvent.setup(); - render(ViewSidebar, { props: { kinds, content, initial: [null] } }); - expect(screen.queryByTestId("view-content")).toBeNull(); - await user.selectOptions(screen.getByRole("combobox"), "tasks"); - expect(screen.getByTestId("view-content")).toHaveTextContent("kind:tasks"); - }); + it("selecting a kind renders that kind's content", async () => { + const user = userEvent.setup(); + render(ViewSidebar, { props: { kinds, content, initial: [null] } }); + expect(screen.queryByTestId("view-content")).toBeNull(); + await user.selectOptions(screen.getByRole("combobox"), "tasks"); + expect(screen.getByTestId("view-content")).toHaveTextContent("kind:tasks"); + }); }); |
