summaryrefslogtreecommitdiffhomepage
path: root/src/features/views
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/views')
-rw-r--r--src/features/views/index.ts16
-rw-r--r--src/features/views/logic/panels.test.ts88
-rw-r--r--src/features/views/logic/panels.ts34
-rw-r--r--src/features/views/ui/ViewSidebar.svelte170
-rw-r--r--src/features/views/ui/ViewSidebar.test.ts76
5 files changed, 191 insertions, 193 deletions
diff --git a/src/features/views/index.ts b/src/features/views/index.ts
index c4e7f25..164241d 100644
--- a/src/features/views/index.ts
+++ b/src/features/views/index.ts
@@ -1,15 +1,15 @@
export {
- addPanel,
- initialPanels,
- type PanelsState,
- removePanel,
- selectKind,
- type ViewPanel,
+ addPanel,
+ initialPanels,
+ type PanelsState,
+ removePanel,
+ selectKind,
+ type ViewPanel,
} from "./logic/panels";
export { default as ViewSidebar } from "./ui/ViewSidebar.svelte";
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
export const manifest = {
- name: "views",
- description: "Sidebar view panels (dropdown picker + add / remove)",
+ name: "views",
+ description: "Sidebar view panels (dropdown picker + add / remove)",
} as const;
diff --git a/src/features/views/logic/panels.test.ts b/src/features/views/logic/panels.test.ts
index edd7d9e..aadbf5e 100644
--- a/src/features/views/logic/panels.test.ts
+++ b/src/features/views/logic/panels.test.ts
@@ -2,54 +2,54 @@ import { describe, expect, it } from "vitest";
import { addPanel, initialPanels, removePanel, selectKind } from "./panels";
describe("view panels reducer", () => {
- it("seeds one empty panel by default", () => {
- const s = initialPanels();
- expect(s.panels).toHaveLength(1);
- expect(s.panels[0]?.kind).toBeNull();
- });
+ it("seeds one empty panel by default", () => {
+ const s = initialPanels();
+ expect(s.panels).toHaveLength(1);
+ expect(s.panels[0]?.kind).toBeNull();
+ });
- it("seeds a panel per provided kind, in order, with unique ids", () => {
- const s = initialPanels(["surfaces", null]);
- expect(s.panels.map((p) => p.kind)).toEqual(["surfaces", null]);
- expect(new Set(s.panels.map((p) => p.id)).size).toBe(2);
- });
+ it("seeds a panel per provided kind, in order, with unique ids", () => {
+ const s = initialPanels(["surfaces", null]);
+ expect(s.panels.map((p) => p.kind)).toEqual(["surfaces", null]);
+ expect(new Set(s.panels.map((p) => p.id)).size).toBe(2);
+ });
- it("addPanel appends an empty panel with a fresh id", () => {
- const seed = initialPanels(["surfaces"]);
- const s = addPanel(seed);
- expect(s.panels).toHaveLength(2);
- expect(s.panels[1]?.kind).toBeNull();
- expect(s.panels[1]?.id).not.toBe(s.panels[0]?.id);
- });
+ it("addPanel appends an empty panel with a fresh id", () => {
+ const seed = initialPanels(["surfaces"]);
+ const s = addPanel(seed);
+ expect(s.panels).toHaveLength(2);
+ expect(s.panels[1]?.kind).toBeNull();
+ expect(s.panels[1]?.id).not.toBe(s.panels[0]?.id);
+ });
- it("addPanel can seed a kind", () => {
- const s = addPanel(initialPanels([null]), "surfaces");
- expect(s.panels[1]?.kind).toBe("surfaces");
- });
+ it("addPanel can seed a kind", () => {
+ const s = addPanel(initialPanels([null]), "surfaces");
+ expect(s.panels[1]?.kind).toBe("surfaces");
+ });
- it("removePanel drops the matching id only", () => {
- const seed = initialPanels(["surfaces", null]);
- const firstId = seed.panels[0]?.id ?? -1;
- const s = removePanel(seed, firstId);
- expect(s.panels).toHaveLength(1);
- expect(s.panels[0]?.kind).toBeNull();
- });
+ it("removePanel drops the matching id only", () => {
+ const seed = initialPanels(["surfaces", null]);
+ const firstId = seed.panels[0]?.id ?? -1;
+ const s = removePanel(seed, firstId);
+ expect(s.panels).toHaveLength(1);
+ expect(s.panels[0]?.kind).toBeNull();
+ });
- it("selectKind updates only the targeted panel", () => {
- const seed = initialPanels([null, null]);
- const targetId = seed.panels[1]?.id ?? -1;
- const s = selectKind(seed, targetId, "surfaces");
- expect(s.panels[0]?.kind).toBeNull();
- expect(s.panels[1]?.kind).toBe("surfaces");
- });
+ it("selectKind updates only the targeted panel", () => {
+ const seed = initialPanels([null, null]);
+ const targetId = seed.panels[1]?.id ?? -1;
+ const s = selectKind(seed, targetId, "surfaces");
+ expect(s.panels[0]?.kind).toBeNull();
+ expect(s.panels[1]?.kind).toBe("surfaces");
+ });
- it("is pure — never mutates the input state", () => {
- const seed = initialPanels(["surfaces"]);
- const snapshot = JSON.stringify(seed);
- const id = seed.panels[0]?.id ?? -1;
- addPanel(seed);
- removePanel(seed, id);
- selectKind(seed, id, null);
- expect(JSON.stringify(seed)).toBe(snapshot);
- });
+ it("is pure — never mutates the input state", () => {
+ const seed = initialPanels(["surfaces"]);
+ const snapshot = JSON.stringify(seed);
+ const id = seed.panels[0]?.id ?? -1;
+ addPanel(seed);
+ removePanel(seed, id);
+ selectKind(seed, id, null);
+ expect(JSON.stringify(seed)).toBe(snapshot);
+ });
});
diff --git a/src/features/views/logic/panels.ts b/src/features/views/logic/panels.ts
index 38c28fb..fa22be6 100644
--- a/src/features/views/logic/panels.ts
+++ b/src/features/views/logic/panels.ts
@@ -10,14 +10,14 @@
*/
export interface ViewPanel {
- readonly id: number;
- /** Selected view-kind id, or `null` while the panel still reads "Select a view". */
- readonly kind: string | null;
+ readonly id: number;
+ /** Selected view-kind id, or `null` while the panel still reads "Select a view". */
+ readonly kind: string | null;
}
export interface PanelsState {
- readonly panels: readonly ViewPanel[];
- readonly nextId: number;
+ readonly panels: readonly ViewPanel[];
+ readonly nextId: number;
}
/**
@@ -25,25 +25,25 @@ export interface PanelsState {
* a single preset panel, or `[null]` for one empty "Select a view" panel.
*/
export function initialPanels(kinds: readonly (string | null)[] = [null]): PanelsState {
- let nextId = 0;
- const panels = kinds.map((kind) => ({ id: nextId++, kind }));
- return { panels, nextId };
+ let nextId = 0;
+ const panels = kinds.map((kind) => ({ id: nextId++, kind }));
+ return { panels, nextId };
}
export function addPanel(state: PanelsState, kind: string | null = null): PanelsState {
- return {
- panels: [...state.panels, { id: state.nextId, kind }],
- nextId: state.nextId + 1,
- };
+ return {
+ panels: [...state.panels, { id: state.nextId, kind }],
+ nextId: state.nextId + 1,
+ };
}
export function removePanel(state: PanelsState, id: number): PanelsState {
- return { ...state, panels: state.panels.filter((p) => p.id !== id) };
+ return { ...state, panels: state.panels.filter((p) => p.id !== id) };
}
export function selectKind(state: PanelsState, id: number, kind: string | null): PanelsState {
- return {
- ...state,
- panels: state.panels.map((p) => (p.id === id ? { ...p, kind } : p)),
- };
+ return {
+ ...state,
+ panels: state.panels.map((p) => (p.id === id ? { ...p, kind } : p)),
+ };
}
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");
+ });
});