Files
Lumotia/src/lib/components/SettingsGroup.svelte
Jake 3a27031eba feat(settings-group): add optional icon prop and tighten title/description rhythm
Title bumped to font-semibold so it scans first; description gets tracking-wide
plus mt-1 for cleaner rhythm. Chevron alignment shifted from mt-0.5 to mt-1 to
match the new spacing. Optional `icon` prop renders a Lucide component between
the chevron and the title block at 16x16, text-tertiary, no colour change on
open. Layout is preserved exactly when no icon is passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 10:07:39 +01:00

87 lines
3.0 KiB
Svelte

<script lang="ts">
// Phase 9. Progressive-disclosure wrapper around the native <details>
// element. The chevron animates on open via a CSS transform; height
// animation uses interpolate-size where the engine supports it
// (modern Chromium / WebKit), and falls back to instant open/close
// elsewhere. prefers-reduced-motion disables both.
import { ChevronRight } from "lucide-svelte";
import type { Component } from "svelte";
interface Props {
title: string;
description?: string;
open?: boolean;
onopen?: () => void;
icon?: Component<{ size?: number; class?: string; "aria-hidden"?: boolean | "true" | "false" }>;
children?: import("svelte").Snippet;
}
let { title, description = "", open = false, onopen, icon: Icon, children }: Props = $props();
// Fires once on the first transition from closed → open. Used by
// sections that lazy-load expensive data (e.g. TTS voice enumeration).
// Plain let (not $state) — the flag is mutated only by the toggle
// handler and never has to be reactive in the template.
let hasOpened = false;
let detailsEl: HTMLDetailsElement | null = $state(null);
function handleToggle(event: Event) {
const el = event.currentTarget as HTMLDetailsElement | null;
if (el?.open && !hasOpened) {
hasOpened = true;
onopen?.();
}
}
// Push prop changes to the DOM. The native <details> open attribute
// is mutated by user interaction outside Svelte's reactive graph;
// when the parent later changes the open prop (for example because a
// search filter expanded a different group), $effect force-syncs.
// User-driven toggles where the prop value did not change keep their
// local state because $effect only re-runs on prop diff.
$effect(() => {
if (detailsEl && detailsEl.open !== open) detailsEl.open = open;
});
</script>
<details bind:this={detailsEl} {open} ontoggle={handleToggle} class="settings-group border-t border-border-subtle">
<summary
class="flex items-start gap-2 cursor-pointer list-none py-3 px-1 rounded hover:bg-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<ChevronRight
class="chevron size-4 mt-1 shrink-0 text-text-tertiary"
aria-hidden="true"
/>
{#if Icon}
<Icon size={16} class="mt-1 shrink-0 text-text-tertiary" aria-hidden="true" />
{/if}
<div class="flex-1">
<p class="text-sm text-text font-semibold">{title}</p>
{#if description}
<p class="text-[11px] text-text-tertiary tracking-wide mt-1">{description}</p>
{/if}
</div>
</summary>
<div class="pl-6 pr-2 pb-3">
{@render children?.()}
</div>
</details>
<style>
.settings-group :global(summary)::-webkit-details-marker {
display: none;
}
:global(.chevron) {
transition: transform 180ms ease;
}
details[open] > summary :global(.chevron) {
transform: rotate(90deg);
}
@media (prefers-reduced-motion: reduce) {
:global(.chevron) {
transition: none;
}
}
</style>