Add schedule page with block editor and proclamation browser

This commit is contained in:
NeilClack
2026-09-11 18:16:06 -05:00
parent 545fb22910
commit 5f4116b8e8
9 changed files with 1280 additions and 0 deletions
@@ -0,0 +1,353 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { SubmitFunction } from '@sveltejs/kit';
import ProclamationBrowser from './ProclamationBrowser.svelte';
import { MINUTE_STEP, formatTime, validateBlock, type BlockSibling } from '$lib/schedule-utils';
type BrowserCategory = { id: number; name: string };
type BrowserGroup = { id: number; name: string };
type BrowserProclamation = {
id: string;
name: string;
categoryId: number;
groupId: number | null;
};
let {
open = false,
mode = 'create',
blockId,
initialStartMinutes = 9 * 60,
initialEndMinutes = 10 * 60,
initialProclamationIds = [],
categories = [],
groups = [],
proclamations = [],
existingBlocks = [],
createAction = '?/createBlock',
updateAction = '?/updateBlock',
deleteAction = '?/deleteBlock',
onClose,
onSaved
}: {
open?: boolean;
mode?: 'create' | 'edit';
blockId?: string;
initialStartMinutes?: number;
initialEndMinutes?: number;
initialProclamationIds?: string[];
categories?: BrowserCategory[];
groups?: BrowserGroup[];
proclamations?: BrowserProclamation[];
existingBlocks?: BlockSibling[];
createAction?: string;
updateAction?: string;
deleteAction?: string;
onClose?: () => void;
onSaved?: () => void;
} = $props();
let step = $state<'details' | 'browse'>('details');
let startHour = $state('9');
let startMinute = $state('0');
let endHour = $state('10');
let endMinute = $state('0');
let selectedIds = $state<string[]>([]);
let error = $state('');
const hourOptions = Array.from({ length: 24 }, (_, i) => String(i));
function minuteOptions(current: string): string[] {
const base = Array.from({ length: 60 / MINUTE_STEP }, (_, i) => String(i * MINUTE_STEP));
if (base.includes(current)) return base;
return [...base, current].sort((a, b) => Number(a) - Number(b));
}
// Re-seed the fields whenever the modal is (re)opened.
$effect(() => {
if (!open) return;
startHour = String(Math.floor(initialStartMinutes / 60));
startMinute = String(initialStartMinutes % 60);
endHour = String(Math.floor(initialEndMinutes / 60));
endMinute = String(initialEndMinutes % 60);
selectedIds = [...initialProclamationIds];
error = '';
step = 'details';
});
const startMinutes = $derived((Number(startHour) * 60 + Number(startMinute)) | 0);
const endMinutes = $derived((Number(endHour) * 60 + Number(endMinute)) | 0);
const siblings = $derived(existingBlocks.filter((block) => block.id !== blockId));
const nameById = $derived(new Map(proclamations.map((p) => [p.id, p.name])));
const selectedTitles = $derived(
selectedIds
.map((id) => nameById.get(id))
.filter((name): name is string => typeof name === 'string')
);
function validate(): string | null {
const result = validateBlock(
{ startMinutes, endMinutes, proclamationIds: selectedIds },
siblings,
{ isCreate: mode === 'create' }
);
return result.ok ? null : result.error;
}
const handleSubmit: SubmitFunction = ({ formData, cancel }) => {
const intent = formData.get('intent');
if (intent !== 'delete') {
const validationError = validate();
if (validationError) {
error = validationError;
cancel();
return;
}
}
error = '';
return async ({ result, update }) => {
if (result.type === 'failure') {
const data = result.data as { error?: string } | undefined;
error = data?.error ?? 'Unable to save block.';
await update({ reset: false });
return;
}
if (result.type === 'error') {
error = 'Unable to save block.';
return;
}
await update();
onSaved?.();
};
};
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose?.();
}
</script>
{#if open}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 p-4"
onkeydown={handleKeydown}
role="presentation"
>
<button
type="button"
aria-label="Close dialog"
class="absolute inset-0 h-full w-full cursor-default"
onclick={() => onClose?.()}
></button>
<div
role="dialog"
aria-modal="true"
aria-label={mode === 'edit' ? 'Edit block' : 'Create block'}
class="relative z-10 flex max-h-[90vh] w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl dark:border-slate-800 dark:bg-slate-900"
>
<div
class="flex shrink-0 items-center justify-between gap-3 border-b border-slate-200 px-6 py-4 dark:border-slate-800"
>
<h2 class="text-lg font-bold text-slate-900 dark:text-slate-100">
{step === 'browse'
? 'Select Proclamations'
: mode === 'edit'
? 'Edit Block'
: 'Create Block'}
</h2>
<button
type="button"
onclick={() => onClose?.()}
aria-label="Close"
class="rounded-md p-1.5 text-2xl leading-none text-slate-400 transition hover:bg-slate-100 hover:text-red-600 dark:text-slate-500 dark:hover:bg-slate-800"
>
&times;
</button>
</div>
<form
method="POST"
action={mode === 'edit' ? updateAction : createAction}
use:enhance={handleSubmit}
class="flex min-h-0 flex-1 flex-col"
>
{#if mode === 'edit' && blockId}
<input type="hidden" name="id" value={blockId} />
{/if}
<input type="hidden" name="startMinutes" value={startMinutes} />
<input type="hidden" name="endMinutes" value={endMinutes} />
<input type="hidden" name="proclamationIds" value={JSON.stringify(selectedIds)} />
<div class="min-h-0 flex-1 overflow-y-auto px-6 py-5">
{#if error}
<div
class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600 dark:border-red-900 dark:bg-red-950/50 dark:text-red-400"
role="alert"
>
{error}
</div>
{/if}
{#if step === 'details'}
<div class="grid gap-6 md:grid-cols-[minmax(0,1fr)_16rem]">
<div class="flex flex-col gap-4">
<div>
<span class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
>Start time</span
>
<div class="flex items-center gap-2">
<select
aria-label="Start hour"
onchange={(e) => (startHour = e.currentTarget.value)}
class="rounded-md border border-slate-300 bg-white px-2 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
>
{#each hourOptions as hour (hour)}
<option value={hour} selected={startHour === hour}>
{hour.padStart(2, '0')}
</option>
{/each}
</select>
<span class="text-slate-400">:</span>
<select
aria-label="Start minute"
onchange={(e) => (startMinute = e.currentTarget.value)}
class="rounded-md border border-slate-300 bg-white px-2 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
>
{#each minuteOptions(startMinute) as minute (minute)}
<option value={minute} selected={startMinute === minute}>
{minute.padStart(2, '0')}
</option>
{/each}
</select>
</div>
</div>
<div>
<span class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
>End time</span
>
<div class="flex items-center gap-2">
<select
aria-label="End hour"
onchange={(e) => (endHour = e.currentTarget.value)}
class="rounded-md border border-slate-300 bg-white px-2 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
>
{#each hourOptions as hour (hour)}
<option value={hour} selected={endHour === hour}>
{hour.padStart(2, '0')}
</option>
{/each}
</select>
<span class="text-slate-400">:</span>
<select
aria-label="End minute"
onchange={(e) => (endMinute = e.currentTarget.value)}
class="rounded-md border border-slate-300 bg-white px-2 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
>
{#each minuteOptions(endMinute) as minute (minute)}
<option value={minute} selected={endMinute === minute}>
{minute.padStart(2, '0')}
</option>
{/each}
</select>
</div>
</div>
<div>
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-medium text-slate-700 dark:text-slate-300">
Proclamations ({selectedTitles.length})
</span>
<button
type="button"
onclick={() => (step = 'browse')}
class="rounded-md border border-indigo-200 bg-indigo-50 px-3 py-1.5 text-sm font-medium text-indigo-700 transition hover:bg-indigo-100 dark:border-indigo-900 dark:bg-indigo-950/50 dark:text-indigo-300"
>
Browse proclamations
</button>
</div>
</div>
</div>
<div
class="rounded-lg border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-950"
>
<h3 class="text-sm font-semibold text-slate-800 dark:text-slate-200">Review</h3>
<p class="mt-2 text-sm text-slate-600 dark:text-slate-400">
{formatTime(startMinutes)} – {formatTime(endMinutes)}
</p>
{#if selectedTitles.length === 0}
<p class="mt-2 text-sm text-slate-500 dark:text-slate-400">
No proclamations selected.
</p>
{:else}
<ul class="mt-2 space-y-1">
{#each selectedTitles as title (title)}
<li class="text-sm text-slate-700 dark:text-slate-300">{title}</li>
{/each}
</ul>
{/if}
</div>
</div>
{:else}
<ProclamationBrowser
{categories}
{groups}
{proclamations}
{selectedIds}
onChange={(ids) => (selectedIds = ids)}
/>
{/if}
</div>
<div
class="flex shrink-0 items-center justify-between gap-3 border-t border-slate-200 px-6 py-4 dark:border-slate-800"
>
<div>
{#if step === 'browse'}
<button
type="button"
onclick={() => (step = 'details')}
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
>
Back
</button>
{:else if mode === 'edit' && blockId}
<button
type="submit"
name="intent"
value="delete"
formaction={deleteAction}
class="rounded-md bg-red-50 px-3 py-1.5 text-sm font-medium text-red-600 ring-1 ring-red-200 transition ring-inset hover:bg-red-600 hover:text-white dark:bg-red-950/50 dark:text-red-400 dark:ring-red-900"
>
Delete
</button>
{/if}
</div>
<div class="flex items-center gap-3">
<button
type="button"
onclick={() => onClose?.()}
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
>
Cancel
</button>
{#if step === 'details'}
<button
type="submit"
name="intent"
value="save"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
>
{mode === 'edit' ? 'Save Block' : 'Create Block'}
</button>
{/if}
</div>
</div>
</form>
</div>
</div>
{/if}
@@ -0,0 +1,110 @@
import { page } from 'vitest/browser';
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
const { submitRef } = vi.hoisted(() => ({
submitRef: {
current: null as unknown as (opts: { formData: FormData; cancel?: () => void }) => unknown
}
}));
vi.mock('$app/forms', () => ({
enhance: (_node: unknown, submit: unknown) => {
submitRef.current = submit as typeof submitRef.current;
return () => {};
}
}));
import BlockModal from './BlockModal.svelte';
const categories = [{ id: 1, name: 'Physical' }];
const groups = [{ id: 1, name: 'Prayers' }];
const proclamations = [
{ id: 'p1', name: 'Peace', categoryId: 1, groupId: 1 },
{ id: 'p2', name: 'Strength', categoryId: 1, groupId: 1 }
];
function mount(overrides: Record<string, unknown> = {}) {
return render(BlockModal, {
open: true,
mode: 'create',
categories,
groups,
proclamations,
existingBlocks: [],
initialStartMinutes: 6 * 60,
initialEndMinutes: 8 * 60,
initialProclamationIds: ['p1'],
onClose: () => {},
onSaved: () => {},
...overrides
});
}
function submit() {
const form = document.querySelector('form') as HTMLFormElement;
return submitRef.current({ formData: new FormData(form), cancel: () => {} });
}
describe('BlockModal', () => {
it('uses separate hour and minute selects for start and end', async () => {
mount();
await expect.element(page.getByRole('combobox', { name: /^Start hour$/ })).toBeInTheDocument();
await expect
.element(page.getByRole('combobox', { name: /^Start minute$/ }))
.toBeInTheDocument();
await expect.element(page.getByRole('combobox', { name: /^End hour$/ })).toBeInTheDocument();
await expect.element(page.getByRole('combobox', { name: /^End minute$/ })).toBeInTheDocument();
});
it('pre-fills the selects from the initial times', async () => {
mount({ initialStartMinutes: 7 * 60 + 30, initialEndMinutes: 9 * 60 + 15 });
await expect.element(page.getByRole('combobox', { name: /^Start hour$/ })).toHaveValue('7');
await expect.element(page.getByRole('combobox', { name: /^Start minute$/ })).toHaveValue('30');
await expect.element(page.getByRole('combobox', { name: /^End hour$/ })).toHaveValue('9');
await expect.element(page.getByRole('combobox', { name: /^End minute$/ })).toHaveValue('15');
});
it('shows the review times and selected proclamation titles', async () => {
mount();
await expect.element(page.getByText('6:00 AM – 8:00 AM')).toBeInTheDocument();
await expect.element(page.getByText('Peace')).toBeInTheDocument();
});
it('rejects an overlapping time range with the specific error', async () => {
mount({
existingBlocks: [{ id: 'b1', startMinutes: 6 * 60, endMinutes: 8 * 60 }],
initialStartMinutes: 7 * 60,
initialEndMinutes: 9 * 60
});
await page.getByRole('combobox', { name: /^Start hour$/ }).element();
submit();
await expect
.element(page.getByText(/This overlaps with your 6:00 AM–8:00 AM block\./))
.toBeInTheDocument();
});
it('requires at least one proclamation', async () => {
mount({ initialProclamationIds: [] });
await page.getByRole('combobox', { name: /^Start hour$/ }).element();
submit();
await expect.element(page.getByText('Select at least one proclamation.')).toBeInTheDocument();
});
it('does not show an error for a valid selection', async () => {
mount();
await page.getByRole('combobox', { name: /^Start hour$/ }).element();
const result = submit();
expect(typeof result).toBe('function');
await expect.element(page.getByRole('alert')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,147 @@
<script lang="ts">
import { MINUTES_PER_DAY, SNAP_MINUTES, formatTime, snapMinutes } from '$lib/schedule-utils';
type ScheduleBlockView = {
id: string;
startMinutes: number;
endMinutes: number;
proclamations: { id: string; name: string }[];
};
let {
blocks = [],
onCreate,
onEdit
}: {
blocks?: ScheduleBlockView[];
onCreate?: (startMinutes: number, endMinutes: number) => void;
onEdit?: (block: ScheduleBlockView) => void;
} = $props();
const hourMarks = Array.from({ length: 24 }, (_, i) => i);
const defaultDuration = 60;
let track = $state<HTMLDivElement>();
let drag = $state<{ anchor: number; current: number } | null>(null);
function minutesFromEvent(event: PointerEvent): number {
if (!track) return 0;
const rect = track.getBoundingClientRect();
const ratio = (event.clientY - rect.top) / rect.height;
return Math.max(0, Math.min(MINUTES_PER_DAY, snapMinutes(ratio * MINUTES_PER_DAY)));
}
function handlePointerDown(event: PointerEvent) {
if (event.pointerType === 'mouse' && event.button !== 0) return;
const minutes = minutesFromEvent(event);
drag = { anchor: minutes, current: minutes };
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
event.preventDefault();
}
function handlePointerMove(event: PointerEvent) {
if (!drag) return;
drag = { anchor: drag.anchor, current: minutesFromEvent(event) };
}
function handlePointerUp(event: PointerEvent) {
if (!drag) return;
const { anchor, current } = drag;
drag = null;
(event.currentTarget as HTMLElement).releasePointerCapture?.(event.pointerId);
const start = Math.min(anchor, current);
const end = Math.max(anchor, current);
if (end - start >= SNAP_MINUTES) {
onCreate?.(start, end);
return;
}
// A simple click creates a default-length block starting at that slot.
const clickStart = Math.min(start, MINUTES_PER_DAY - defaultDuration);
const clickEnd = Math.max(
defaultDuration,
Math.min(MINUTES_PER_DAY, clickStart + defaultDuration)
);
onCreate?.(Math.max(0, clickStart), clickEnd);
}
const preview = $derived.by(() => {
if (!drag) return null;
const start = Math.min(drag.anchor, drag.current);
const end = Math.max(drag.anchor, drag.current);
return {
top: (start / MINUTES_PER_DAY) * 100,
height: ((end - start) / MINUTES_PER_DAY) * 100
};
});
</script>
<div class="flex gap-2">
<div class="relative h-[720px] w-14 shrink-0" aria-hidden="true">
{#each hourMarks as hour (hour)}
<div
class="absolute right-1 -translate-y-1/2 text-xs text-slate-400 dark:text-slate-500"
style={`top:${(hour / 24) * 100}%`}
>
{String(hour).padStart(2, '0')}:00
</div>
{/each}
</div>
<div
bind:this={track}
role="presentation"
class="relative h-[720px] flex-1 cursor-pointer overflow-hidden rounded-lg border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900"
style="touch-action: none;"
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
onpointercancel={() => (drag = null)}
>
{#each hourMarks as hour (hour)}
<div
class="pointer-events-none absolute inset-x-0 border-t border-slate-100 dark:border-slate-800"
style={`top:${(hour / 24) * 100}%`}
></div>
{/each}
{#if preview}
<div
class="pointer-events-none absolute inset-x-1 rounded-md bg-indigo-200/60 dark:bg-indigo-700/40"
style={`top:${preview.top}%;height:${preview.height}%`}
></div>
{/if}
{#each blocks as block (block.id)}
{@const top = (block.startMinutes / MINUTES_PER_DAY) * 100}
{@const height = ((block.endMinutes - block.startMinutes) / MINUTES_PER_DAY) * 100}
<button
type="button"
onpointerdown={(event) => event.stopPropagation()}
onclick={(event) => {
event.stopPropagation();
onEdit?.(block);
}}
aria-label={`Block ${formatTime(block.startMinutes)} to ${formatTime(block.endMinutes)}`}
class="absolute inset-x-1 overflow-hidden rounded-md border border-indigo-300 bg-indigo-50 px-2 py-1 text-left text-xs text-indigo-900 shadow-sm transition hover:bg-indigo-100 dark:border-indigo-800 dark:bg-indigo-950/60 dark:text-indigo-200 dark:hover:bg-indigo-900"
style={`top:${top}%;height:${height}%`}
>
<span class="block font-semibold">
{formatTime(block.startMinutes)} – {formatTime(block.endMinutes)}
</span>
<span class="block truncate">
{block.proclamations.map((p) => p.name).join(', ') || 'No proclamations'}
</span>
</button>
{/each}
{#if blocks.length === 0}
<p
class="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 px-4 text-center text-sm text-slate-400 dark:text-slate-500"
>
Click or drag to add a block.
</p>
{/if}
</div>
</div>
@@ -0,0 +1,211 @@
<script lang="ts">
type BrowserCategory = { id: number; name: string };
type BrowserGroup = { id: number; name: string };
type BrowserProclamation = {
id: string;
name: string;
categoryId: number;
groupId: number | null;
};
let {
categories = [],
groups = [],
proclamations = [],
selectedIds = [],
onChange
}: {
categories: BrowserCategory[];
groups: BrowserGroup[];
proclamations: BrowserProclamation[];
selectedIds: string[];
onChange?: (ids: string[]) => void;
} = $props();
// Categories start collapsed. Groups are pre-expanded, so we track the
// smaller set of groups the user has explicitly collapsed.
let openCategoryIds = $state<number[]>([]);
let closedGroupIds = $state<number[]>([]);
const selectedSet = $derived(new Set(selectedIds));
const nameById = $derived(new Map(proclamations.map((p) => [p.id, p.name])));
const selectedTitles = $derived(
selectedIds
.map((id) => nameById.get(id))
.filter((name): name is string => typeof name === 'string')
);
type GroupView = { group: BrowserGroup; items: BrowserProclamation[] };
type CategoryView = {
category: BrowserCategory;
groups: GroupView[];
ungrouped: BrowserProclamation[];
};
const view = $derived.by<CategoryView[]>(() =>
categories.map((category) => {
const inCategory = proclamations.filter((p) => p.categoryId === category.id);
const groupViews = groups
.map((group) => ({
group,
items: inCategory.filter((p) => p.groupId === group.id)
}))
.filter((groupView) => groupView.items.length > 0);
const ungrouped = inCategory.filter((p) => p.groupId === null || p.groupId === undefined);
return { category, groups: groupViews, ungrouped };
})
);
function toggleCategory(id: number) {
openCategoryIds = openCategoryIds.includes(id)
? openCategoryIds.filter((value) => value !== id)
: [...openCategoryIds, id];
}
function toggleGroup(id: number) {
closedGroupIds = closedGroupIds.includes(id)
? closedGroupIds.filter((value) => value !== id)
: [...closedGroupIds, id];
}
function toggleProclamation(id: string) {
const next = selectedSet.has(id)
? selectedIds.filter((value) => value !== id)
: [...selectedIds, id];
onChange?.(next);
}
</script>
<div class="grid gap-4 md:grid-cols-[minmax(0,1fr)_16rem]">
<div
class="max-h-96 overflow-y-auto rounded-lg border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900"
>
{#if categories.length === 0}
<p class="p-4 text-sm text-slate-500 dark:text-slate-400">No categories available.</p>
{:else}
<ul class="divide-y divide-slate-100 dark:divide-slate-800">
{#each view as categoryView (categoryView.category.id)}
{@const isCategoryOpen = openCategoryIds.includes(categoryView.category.id)}
<li>
<button
type="button"
aria-expanded={isCategoryOpen}
onclick={() => toggleCategory(categoryView.category.id)}
class="flex w-full items-center justify-between gap-2 px-4 py-3 text-left text-sm font-semibold text-slate-800 transition hover:bg-slate-50 dark:text-slate-200 dark:hover:bg-slate-800"
>
<span>{categoryView.category.name}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="shrink-0 transition-transform {isCategoryOpen ? 'rotate-90' : ''}"
aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg
>
</button>
{#if isCategoryOpen}
<div class="px-4 pb-3">
{#if categoryView.groups.length === 0 && categoryView.ungrouped.length === 0}
<p class="py-2 text-sm text-slate-500 dark:text-slate-400">
No proclamations in this category.
</p>
{/if}
{#each categoryView.groups as groupView (groupView.group.id)}
{@const isGroupOpen = !closedGroupIds.includes(groupView.group.id)}
<div class="mt-2">
<button
type="button"
aria-expanded={isGroupOpen}
onclick={() => toggleGroup(groupView.group.id)}
class="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-xs font-semibold tracking-wide text-slate-500 uppercase transition hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800"
>
<span>{groupView.group.name}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="shrink-0 transition-transform {isGroupOpen ? 'rotate-180' : ''}"
aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg
>
</button>
{#if isGroupOpen}
<ul class="mt-1 space-y-0.5">
{#each groupView.items as proclamation (proclamation.id)}
<li>
<label
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm text-slate-700 transition hover:bg-indigo-50 dark:text-slate-300 dark:hover:bg-indigo-950"
>
<input
type="checkbox"
checked={selectedSet.has(proclamation.id)}
onchange={() => toggleProclamation(proclamation.id)}
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 dark:border-slate-600"
/>
<span>{proclamation.name}</span>
</label>
</li>
{/each}
</ul>
{/if}
</div>
{/each}
{#if categoryView.ungrouped.length > 0}
<div class="mt-3 border-t border-slate-100 pt-2 dark:border-slate-800">
<ul class="space-y-0.5">
{#each categoryView.ungrouped as proclamation (proclamation.id)}
<li>
<label
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm text-slate-700 transition hover:bg-indigo-50 dark:text-slate-300 dark:hover:bg-indigo-950"
>
<input
type="checkbox"
checked={selectedSet.has(proclamation.id)}
onchange={() => toggleProclamation(proclamation.id)}
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 dark:border-slate-600"
/>
<span>{proclamation.name}</span>
</label>
</li>
{/each}
</ul>
</div>
{/if}
</div>
{/if}
</li>
{/each}
</ul>
{/if}
</div>
<div
class="rounded-lg border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-950"
>
<h3 class="text-sm font-semibold text-slate-800 dark:text-slate-200">
Selected ({selectedTitles.length})
</h3>
{#if selectedTitles.length === 0}
<p class="mt-2 text-sm text-slate-500 dark:text-slate-400">No proclamations selected.</p>
{:else}
<ul class="mt-2 space-y-1">
{#each selectedTitles as title, index (selectedIds[index])}
<li class="text-sm text-slate-700 dark:text-slate-300">{title}</li>
{/each}
</ul>
{/if}
</div>
</div>
@@ -0,0 +1,107 @@
import { page } from 'vitest/browser';
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import ProclamationBrowser from './ProclamationBrowser.svelte';
const categories = [
{ id: 1, name: 'Physical' },
{ id: 2, name: 'Mental' },
{ id: 3, name: 'Empty' }
];
const groups = [
{ id: 1, name: 'Prayers' },
{ id: 2, name: 'Scripture' }
];
const proclamations = [
{ id: 'p1', name: 'Healing', categoryId: 1, groupId: 1 },
{ id: 'p2', name: 'Strength', categoryId: 1, groupId: 1 },
{ id: 'p3', name: 'Focus', categoryId: 1, groupId: null },
{ id: 'p4', name: 'Calm', categoryId: 2, groupId: 2 }
];
function mount(overrides: Record<string, unknown> = {}) {
return render(ProclamationBrowser, {
categories,
groups,
proclamations,
selectedIds: [],
onChange: () => {},
...overrides
});
}
async function click(locator: { element: () => HTMLElement | SVGElement }) {
const el = locator.element() as HTMLElement;
el.click();
}
describe('ProclamationBrowser', () => {
it('collapses categories by default', async () => {
mount();
await expect.element(page.getByRole('button', { name: 'Physical' })).toBeInTheDocument();
await expect.element(page.getByRole('checkbox', { name: 'Healing' })).not.toBeInTheDocument();
});
it('reveals pre-expanded groups and their proclamations when a category opens', async () => {
mount();
await click(page.getByRole('button', { name: 'Physical' }));
await expect.element(page.getByText('Prayers')).toBeInTheDocument();
await expect.element(page.getByRole('checkbox', { name: 'Healing' })).toBeInTheDocument();
await expect.element(page.getByRole('checkbox', { name: 'Strength' })).toBeInTheDocument();
});
it('hides groups that have no proclamations in the category', async () => {
mount();
await click(page.getByRole('button', { name: 'Physical' }));
await expect.element(page.getByText('Scripture')).not.toBeInTheDocument();
});
it('lists ungrouped proclamations below the groups', async () => {
mount();
await click(page.getByRole('button', { name: 'Physical' }));
await expect.element(page.getByRole('checkbox', { name: 'Focus' })).toBeInTheDocument();
});
it('reports toggled selections through onChange', async () => {
const onChange = vi.fn();
mount({ selectedIds: ['p1'], onChange });
await click(page.getByRole('button', { name: 'Physical' }));
await click(page.getByRole('checkbox', { name: 'Strength' }));
expect(onChange).toHaveBeenCalledWith(['p1', 'p2']);
await click(page.getByRole('checkbox', { name: 'Healing' }));
expect(onChange).toHaveBeenCalledWith([]);
});
it('shows selected titles in the summary and keeps selection across collapse', async () => {
mount({ selectedIds: ['p1'] });
await expect.element(page.getByText('Selected (1)')).toBeInTheDocument();
await click(page.getByRole('button', { name: 'Physical' }));
const checkbox = (await page
.getByRole('checkbox', { name: 'Healing' })
.element()) as HTMLInputElement;
expect(checkbox.checked).toBe(true);
await click(page.getByRole('button', { name: 'Physical' }));
await expect.element(page.getByRole('checkbox', { name: 'Healing' })).not.toBeInTheDocument();
await click(page.getByRole('button', { name: 'Physical' }));
const reopened = (await page
.getByRole('checkbox', { name: 'Healing' })
.element()) as HTMLInputElement;
expect(reopened.checked).toBe(true);
});
});