{"name":"custom-select","files":[{"path":"ui/CustomSelect.svelte","content":"<script lang=\"ts\" module>\n\texport interface Option<V extends string = string> {\n\t\tvalue: V;\n\t\tlabel: string;\n\t\t/** Secondary line under the label. */\n\t\tdescription?: string;\n\t\tdisabled?: boolean;\n\t}\n</script>\n\n<script lang=\"ts\" generics=\"T extends string\">\n\timport type { Snippet } from 'svelte';\n\timport { tick } from 'svelte';\n\timport { Search01Icon, Tick02Icon, UnfoldMoreIcon } from '@hugeicons/core-free-icons';\n\timport Icon from './Icon.svelte';\n\timport { announce, isApple } from './announce';\n\timport { anchored } from './floating';\n\timport { glide } from './glide';\n\timport { scrollEdges } from './scroll-edges';\n\n\tinterface Props {\n\t\tlabel: string;\n\t\toptions: Option<T>[];\n\t\tvalue?: T | '';\n\t\tplaceholder?: string;\n\t\thint?: string;\n\t\terror?: string;\n\t\t/** Form field name: a hidden native select carries the value, so forms, autofill and required work. */\n\t\tname?: string;\n\t\trequired?: boolean;\n\t\tdisabled?: boolean;\n\t\t/** Custom rendering for each option (icons, avatars). Gets the option and whether it's selected. */\n\t\titem?: Snippet<[Option<T>, boolean]>;\n\t\t/** A search box at the top of the list, for long lists. */\n\t\tsearchable?: boolean;\n\t\t/** Names the search box. */\n\t\tsearchLabel?: string;\n\t\t/** Shown when the search matches nothing. */\n\t\tempty?: string;\n\t\tclass?: string;\n\t}\n\n\tlet {\n\t\tlabel,\n\t\toptions,\n\t\tvalue = $bindable(''),\n\t\tplaceholder = 'Choose…',\n\t\thint,\n\t\terror,\n\t\tname,\n\t\trequired = false,\n\t\tdisabled = false,\n\t\titem,\n\t\tsearchable = false,\n\t\tsearchLabel = 'Search',\n\t\tempty = 'No matches',\n\t\tclass: className\n\t}: Props = $props();\n\n\tconst id = $props.id();\n\tlet button: HTMLButtonElement;\n\tlet popup: HTMLDivElement;\n\tlet list: HTMLDivElement;\n\tlet search = $state<HTMLInputElement>();\n\tlet query = $state('');\n\tlet highlight: HTMLSpanElement;\n\t// Pointer moves glide the highlight; keyboard moves snap it.\n\tlet viaPointer = false;\n\tlet open = $state(false);\n\tlet active = $state(-1);\n\n\tconst selected = $derived(options.find((o) => o.value === value));\n\t// Accent- and case-insensitive, so \"cordoba\" finds Córdoba.\n\tconst fold = (s: string) => s.normalize('NFD').replace(/\\p{M}/gu, '').toLowerCase();\n\tconst visible = $derived(\n\t\tnew Set(\n\t\t\toptions\n\t\t\t\t.map((_, i) => i)\n\t\t\t\t.filter((i) => {\n\t\t\t\t\t// Names and descriptions both count: \"cordoba\" finds everyone who worked there.\n\t\t\t\t\tconst q = fold(query.trim());\n\t\t\t\t\tconst o = options[i];\n\t\t\t\t\treturn !searchable || !q || fold(`${o.label} ${o.description ?? ''}`).includes(q);\n\t\t\t\t})\n\t\t)\n\t);\n\tconst enabled = $derived([...visible].filter((i) => !options[i].disabled));\n\tconst optionId = (i: number) => `${id}-opt-${i}`;\n\tconst describedby = $derived(\n\t\t[hint && `${id}-hint`, error && `${id}-error`].filter(Boolean).join(' ') || undefined\n\t);\n\n\tconst float = anchored(\n\t\t() => button,\n\t\t() => popup,\n\t\t() => (open = false)\n\t);\n\t$effect(() => {\n\t\tif (!open) {\n\t\t\t// Closing from the search box: back to the button, not lost to the page.\n\t\t\tif (popup.contains(document.activeElement)) button.focus();\n\t\t\tfloat.close();\n\t\t\treturn;\n\t\t}\n\t\t// At least as wide as the trigger, so the list reads as the control's own.\n\t\tpopup.style.minInlineSize = `${button.offsetWidth}px`;\n\t\tfloat.open();\n\t\tif (searchable) tick().then(() => search?.focus());\n\t});\n\t$effect(() => () => float.destroy());\n\n\t// The highlight follows the active option; hidden while closed so it never slides in from a stale spot.\n\t$effect(() => {\n\t\tconst item =\n\t\t\topen && active >= 0\n\t\t\t\t? list.querySelector<HTMLElement>(`#${CSS.escape(optionId(active))}`)\n\t\t\t\t: null;\n\t\tglide(highlight, item, viaPointer);\n\t});\n\n\t// Keep the highlighted option in view inside the list.\n\t$effect(() => {\n\t\tif (open && active >= 0)\n\t\t\tlist.querySelector(`#${CSS.escape(optionId(active))}`)?.scrollIntoView({ block: 'nearest' });\n\t});\n\n\t// VoiceOver ignores aria-activedescendant, so on Apple devices the highlighted option is spoken.\n\t$effect(() => {\n\t\tconst o = options[active];\n\t\tif (open && o && isApple()) announce(o.label);\n\t});\n\n\tfunction show(at: number) {\n\t\tquery = '';\n\t\tactive = at;\n\t\topen = true;\n\t}\n\tfunction choose(i: number) {\n\t\tif (!options[i] || options[i].disabled) return;\n\t\tvalue = options[i].value;\n\t\tif (isApple()) announce(`${options[i].label}, selected`);\n\t\topen = false;\n\t}\n\t/** Moves the highlight among enabled options, clamped at the ends (as native selects do). */\n\tfunction step(by: number) {\n\t\tif (!enabled.length) return;\n\t\tconst pos = enabled.indexOf(active);\n\t\tconst next = pos === -1 ? (by > 0 ? 0 : enabled.length - 1) : pos + by;\n\t\tactive = enabled[Math.max(0, Math.min(enabled.length - 1, next))];\n\t}\n\n\t// Typeahead: letters typed within half a second build one search, so \"sam\" reaches Samarkand.\n\tlet typed = '';\n\tlet typedAt = 0;\n\tfunction typeahead(key: string) {\n\t\tconst now = performance.now();\n\t\ttyped = now - typedAt > 500 ? key : typed + key;\n\t\ttypedAt = now;\n\t\tconst q = typed.toLowerCase();\n\t\tconst start = enabled.indexOf(active);\n\t\t// A single repeated letter cycles through matches; a longer search stays on the current match.\n\t\tconst from = q.length === 1 ? start + 1 : Math.max(start, 0);\n\t\tconst order = [...enabled.slice(from), ...enabled.slice(0, from)];\n\t\tconst hit = order.find((i) => options[i].label.toLowerCase().startsWith(q));\n\t\tif (hit !== undefined) {\n\t\t\tif (!open) show(hit);\n\t\t\telse active = hit;\n\t\t}\n\t}\n\n\tfunction onkeydown(e: KeyboardEvent) {\n\t\tviaPointer = false;\n\t\t// In the search box, Home/End, Space and letters edit the text instead.\n\t\tconst inSearch = e.currentTarget === search;\n\t\tconst current = selected ? options.indexOf(selected) : -1;\n\t\tif (!open) {\n\t\t\tif (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(e.key)) {\n\t\t\t\te.preventDefault();\n\t\t\t\tshow(current >= 0 ? current : e.key === 'ArrowUp' ? enabled.at(-1)! : enabled[0]);\n\t\t\t} else if (e.key === 'Home' || e.key === 'End') {\n\t\t\t\te.preventDefault();\n\t\t\t\tshow(e.key === 'Home' ? enabled[0] : enabled.at(-1)!);\n\t\t\t} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {\n\t\t\t\ttypeahead(e.key);\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\t\tswitch (e.key) {\n\t\t\tcase 'ArrowDown':\n\t\t\t\te.preventDefault();\n\t\t\t\tstep(1);\n\t\t\t\tbreak;\n\t\t\tcase 'ArrowUp':\n\t\t\t\te.preventDefault();\n\t\t\t\tif (e.altKey) choose(active);\n\t\t\t\telse step(-1);\n\t\t\t\tbreak;\n\t\t\tcase 'PageDown':\n\t\t\t\te.preventDefault();\n\t\t\t\tstep(10);\n\t\t\t\tbreak;\n\t\t\tcase 'PageUp':\n\t\t\t\te.preventDefault();\n\t\t\t\tstep(-10);\n\t\t\t\tbreak;\n\t\t\tcase 'Home':\n\t\t\t\tif (inSearch) break;\n\t\t\t\te.preventDefault();\n\t\t\t\tactive = enabled[0];\n\t\t\t\tbreak;\n\t\t\tcase 'End':\n\t\t\t\tif (inSearch) break;\n\t\t\t\te.preventDefault();\n\t\t\t\tactive = enabled.at(-1)!;\n\t\t\t\tbreak;\n\t\t\tcase 'Enter':\n\t\t\t\te.preventDefault();\n\t\t\t\tchoose(active);\n\t\t\t\tbreak;\n\t\t\tcase ' ':\n\t\t\t\tif (inSearch) break;\n\t\t\t\te.preventDefault();\n\t\t\t\t// Mid-search, Space is part of the text (\"al jabr\"); otherwise it chooses.\n\t\t\t\tif (performance.now() - typedAt < 500) typeahead(' ');\n\t\t\t\telse choose(active);\n\t\t\t\tbreak;\n\t\t\tcase 'Escape':\n\t\t\t\te.preventDefault();\n\t\t\t\topen = false;\n\t\t\t\tbreak;\n\t\t\tcase 'Tab':\n\t\t\t\t// Tab picks the highlighted option and moves on, like a native select.\n\t\t\t\tchoose(active);\n\t\t\t\tbreak;\n\t\t\tdefault:\n\t\t\t\tif (!inSearch && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey)\n\t\t\t\t\ttypeahead(e.key);\n\t\t}\n\t}\n</script>\n\n<div class={['field', className]}>\n\t<!-- Focus only: a label click would otherwise also click (open) the button. -->\n\t<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->\n\t<label id=\"{id}-label\" for=\"{id}-button\" onclick={(e) => (e.preventDefault(), button.focus())}>\n\t\t{label}\n\t</label>\n\t<button\n\t\tbind:this={button}\n\t\ttype=\"button\"\n\t\tid=\"{id}-button\"\n\t\trole=\"combobox\"\n\t\taria-labelledby=\"{id}-label\"\n\t\taria-haspopup=\"listbox\"\n\t\taria-expanded={open}\n\t\taria-controls=\"{id}-list\"\n\t\taria-activedescendant={open && active >= 0 ? optionId(active) : undefined}\n\t\taria-invalid={error ? true : undefined}\n\t\taria-describedby={describedby}\n\t\t{disabled}\n\t\tclass={['trigger', !selected && 'placeholder']}\n\t\tonclick={() =>\n\t\t\topen ? (open = false) : show(selected ? options.indexOf(selected) : enabled[0])}\n\t\t{onkeydown}\n\t\tonblur={(e) => {\n\t\t\t// Focus left for good (not into the list): close without changing anything.\n\t\t\tif (!popup.contains(e.relatedTarget as Node | null)) open = false;\n\t\t}}\n\t>\n\t\t<span class=\"value\">{selected?.label ?? placeholder}</span>\n\t\t<span class=\"chev\" aria-hidden=\"true\"><Icon icon={UnfoldMoreIcon} size={16} /></span>\n\t</button>\n\t{#if name}\n\t\t<!-- React Aria's HiddenSelect: a real field for forms, autofill and required, out of sight. -->\n\t\t<select\n\t\t\tclass=\"sr-only\"\n\t\t\ttabindex=\"-1\"\n\t\t\taria-hidden=\"true\"\n\t\t\t{name}\n\t\t\t{required}\n\t\t\t{disabled}\n\t\t\tbind:value\n\t\t\tonfocus={() => button.focus()}\n\t\t>\n\t\t\t<option value=\"\"></option>\n\t\t\t{#each options as o (o.value)}<option value={o.value}>{o.label}</option>{/each}\n\t\t</select>\n\t{/if}\n\n\t<!-- The popup: an optional search box above the list. Presses inside never take focus from the\n\t     button (or the search box), except in the search box itself. -->\n\t<!-- svelte-ignore a11y_no_static_element_interactions -->\n\t<div\n\t\tbind:this={popup}\n\t\tpopover=\"manual\"\n\t\tclass=\"popup\"\n\t\tonpointerdown={(e) => e.target !== search && e.preventDefault()}\n\t>\n\t\t{#if searchable}\n\t\t\t<div class=\"search\">\n\t\t\t\t<Icon icon={Search01Icon} size={16} />\n\t\t\t\t<input\n\t\t\t\t\tbind:this={search}\n\t\t\t\t\ttype=\"text\"\n\t\t\t\t\trole=\"combobox\"\n\t\t\t\t\taria-label={searchLabel}\n\t\t\t\t\taria-autocomplete=\"list\"\n\t\t\t\t\taria-expanded={open}\n\t\t\t\t\taria-controls=\"{id}-list\"\n\t\t\t\t\taria-activedescendant={open && active >= 0 ? optionId(active) : undefined}\n\t\t\t\t\tautocomplete=\"off\"\n\t\t\t\t\tspellcheck=\"false\"\n\t\t\t\t\tvalue={query}\n\t\t\t\t\toninput={(e) => {\n\t\t\t\t\t\tquery = e.currentTarget.value;\n\t\t\t\t\t\tactive = enabled[0] ?? -1;\n\t\t\t\t\t}}\n\t\t\t\t\t{onkeydown}\n\t\t\t\t\tonblur={(e) => {\n\t\t\t\t\t\tconst to = e.relatedTarget as Node | null;\n\t\t\t\t\t\tif (!popup.contains(to) && to !== button) open = false;\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</div>\n\t\t{/if}\n\t\t<!-- Focus never enters the list: the combobox keeps it and points at the highlighted option.\n\t\t     No tabindex, and presses anywhere inside (scrollbar too) are kept from taking focus. -->\n\t\t<div\n\t\t\tbind:this={list}\n\t\t\tid=\"{id}-list\"\n\t\t\trole=\"listbox\"\n\t\t\taria-labelledby=\"{id}-label\"\n\t\t\t{@attach scrollEdges}\n\t\t\tdata-fade-over\n\t\t\tclass=\"list\"\n\t\t>\n\t\t\t<span class=\"highlight\" aria-hidden=\"true\" bind:this={highlight}></span>\n\t\t\t{#each options as o, i (o.value)}\n\t\t\t\t{@const isSelected = o.value === value}\n\t\t\t\t{#if visible.has(i)}\n\t\t\t\t\t<!-- svelte-ignore a11y_click_events_have_key_events -->\n\t\t\t\t\t<div\n\t\t\t\t\t\tid={optionId(i)}\n\t\t\t\t\t\trole=\"option\"\n\t\t\t\t\t\ttabindex=\"-1\"\n\t\t\t\t\t\taria-selected={isSelected}\n\t\t\t\t\t\taria-disabled={o.disabled || undefined}\n\t\t\t\t\t\tclass={['option', i === active && 'active']}\n\t\t\t\t\t\tonpointermove={() => {\n\t\t\t\t\t\t\tviaPointer = true;\n\t\t\t\t\t\t\tif (!o.disabled) active = i;\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tonclick={() => choose(i)}\n\t\t\t\t\t>\n\t\t\t\t\t\t<span class=\"text\">\n\t\t\t\t\t\t\t{#if item}{@render item(o, isSelected)}{:else}\n\t\t\t\t\t\t\t\t<span class=\"label\">{o.label}</span>\n\t\t\t\t\t\t\t\t{#if o.description}<span class=\"description\">{o.description}</span>{/if}\n\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<span class=\"tick\" aria-hidden=\"true\">\n\t\t\t\t\t\t\t{#if isSelected}<Icon icon={Tick02Icon} size={16} />{/if}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</div>\n\t\t\t\t{/if}\n\t\t\t{/each}\n\t\t</div>\n\n\t\t{#if searchable && query.trim() && !enabled.length}<p class=\"none\">{empty}</p>{/if}\n\t\t{#if searchable}<span class=\"sr-only\" role=\"status\"\n\t\t\t\t>{query.trim()\n\t\t\t\t\t? `${enabled.length} ${enabled.length === 1 ? 'result' : 'results'}`\n\t\t\t\t\t: ''}</span\n\t\t\t>{/if}\n\t</div>\n\n\t{#if hint}<p id=\"{id}-hint\" class=\"hint\">{hint}</p>{/if}\n\t{#if error}<p id=\"{id}-error\" class=\"error\">{error}</p>{/if}\n</div>\n\n<style>\n\t.field {\n\t\tdisplay: grid;\n\t\tgap: 0.25rem;\n\t\tfont: 0.9375rem/1.5 var(--ui-font);\n\t\tcolor: var(--ui-fg);\n\t}\n\tlabel {\n\t\tfont-weight: 500;\n\t\tcursor: default;\n\t}\n\t/* Same box as Input and the native Select. */\n\t.trigger {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tgap: 0.5rem;\n\t\tbox-sizing: border-box;\n\t\tinline-size: 100%;\n\t\tmin-block-size: 2.5rem;\n\t\tmargin: 0;\n\t\tpadding: 0.375rem 0.75rem;\n\t\tborder: 1px solid var(--ui-field-line);\n\t\tborder-radius: var(--ui-radius-control);\n\t\tbackground: var(--ui-surface);\n\t\tcolor: inherit;\n\t\tfont: inherit;\n\t\tfont-size: max(1rem, 16px);\n\t\ttext-align: start;\n\t\tcursor: pointer;\n\t}\n\t@media (pointer: coarse) {\n\t\t.trigger {\n\t\t\tmin-block-size: 2.75rem;\n\t\t}\n\t}\n\t.trigger:focus-visible {\n\t\toutline: var(--ui-ring-width) solid var(--ui-ring);\n\t\toutline-offset: var(--ui-ring-offset);\n\t}\n\t.trigger[aria-invalid='true'] {\n\t\tborder-color: var(--ui-danger);\n\t}\n\t.trigger[aria-invalid='true']:focus-visible {\n\t\toutline-color: var(--ui-danger);\n\t}\n\t.trigger:disabled {\n\t\tbackground: var(--ui-subtle);\n\t\topacity: 0.55;\n\t\tcursor: not-allowed;\n\t}\n\t.value {\n\t\tflex: 1;\n\t\toverflow: hidden;\n\t\ttext-overflow: ellipsis;\n\t\twhite-space: nowrap;\n\t}\n\t.placeholder .value {\n\t\tcolor: var(--ui-muted);\n\t}\n\t.chev {\n\t\tdisplay: grid;\n\t\tcolor: var(--ui-muted);\n\t}\n\n\t.popup {\n\t\tposition: fixed;\n\t\tinset: auto;\n\t\tflex-direction: column;\n\t\tmargin: 0;\n\t\tbox-sizing: border-box;\n\t\tmax-block-size: min(20rem, calc(100dvh - 16px));\n\t\tpadding: 0;\n\t\toverflow: hidden;\n\t\t/* Invisible normally; outlines the list in Windows High Contrast. */\n\t\tborder: 1px solid transparent;\n\t\tborder-radius: var(--ui-radius);\n\t\tbackground: var(--ui-surface-overlay);\n\t\tcolor: var(--ui-fg);\n\t\tfont: 0.9375rem/1.4 var(--ui-font);\n\t\tbox-shadow: var(--ui-shadow-overlay);\n\t}\n\t.popup:popover-open {\n\t\tdisplay: flex;\n\t}\n\t/* Nothing matched: no empty list box above the message. */\n\t.list:not(:has([role='option'])) {\n\t\tdisplay: none;\n\t}\n\t.list {\n\t\tposition: relative;\n\t\tflex: 1;\n\t\tmin-block-size: 0;\n\t\toverflow: auto;\n\t\toverscroll-behavior: contain;\n\t\tpadding: 0.25rem;\n\t}\n\t.search {\n\t\tdisplay: flex;\n\t\tflex: none;\n\t\talign-items: center;\n\t\tgap: 0.5rem;\n\t\tmargin: 0.375rem 0.375rem 0;\n\t\tpadding: 0 0.625rem;\n\t\tborder-radius: calc(var(--ui-radius) - 0.125rem);\n\t\tbackground: var(--ui-subtle);\n\t\tcolor: var(--ui-muted);\n\t}\n\t.search:focus-within {\n\t\toutline: var(--ui-ring-width) solid var(--ui-ring);\n\t\toutline-offset: -2px;\n\t}\n\t.search input {\n\t\tflex: 1;\n\t\tmin-inline-size: 0;\n\t\tblock-size: 2.25rem;\n\t\tmargin: 0;\n\t\tpadding: 0;\n\t\tborder: 0;\n\t\toutline: none;\n\t\tbackground: none;\n\t\tcolor: var(--ui-fg);\n\t\tfont: inherit;\n\t\t/* 16px minimum: iOS Safari zooms into smaller inputs. */\n\t\tfont-size: max(1rem, 16px);\n\t}\n\t.none {\n\t\tmargin: 0;\n\t\tpadding: 1rem 0.75rem 1.25rem;\n\t\tcolor: var(--ui-muted);\n\t\tfont-size: 0.875rem;\n\t\ttext-align: center;\n\t}\n\t.option {\n\t\tdisplay: flex;\n\t\tbox-sizing: border-box;\n\t\talign-items: center;\n\t\tgap: 0.75rem;\n\t\tmin-block-size: 2.25rem;\n\t\tpadding: 0.375rem 0.625rem;\n\t\tborder-radius: calc(var(--ui-radius) - 0.25rem);\n\t\tcursor: pointer;\n\t}\n\t@media (pointer: coarse) {\n\t\t.option {\n\t\t\tmin-block-size: 2.75rem;\n\t\t}\n\t}\n\t/* One highlight glides between options (see glide.ts); options stay transparent above it. */\n\t.highlight {\n\t\tposition: absolute;\n\t\ttop: 0;\n\t\tleft: 0;\n\t\ttransform-origin: 0 0;\n\t\tborder-radius: calc(var(--ui-radius) - 0.25rem);\n\t\tbackground: var(--ui-subtle);\n\t\topacity: 0;\n\t\tpointer-events: none;\n\t\ttransition: opacity 120ms ease;\n\t}\n\t.option {\n\t\tposition: relative;\n\t}\n\t.option[aria-disabled='true'] {\n\t\topacity: 0.55;\n\t\tcursor: not-allowed;\n\t}\n\t.text {\n\t\tdisplay: grid;\n\t\tflex: 1;\n\t\tmin-inline-size: 0;\n\t}\n\t.description {\n\t\tcolor: var(--ui-muted);\n\t\tfont-size: 0.8125rem;\n\t}\n\t.tick {\n\t\tdisplay: grid;\n\t\tflex: none;\n\t\tinline-size: 1rem;\n\t\tcolor: var(--ui-accent);\n\t}\n\t.hint,\n\t.error {\n\t\tmargin: 0;\n\t\tfont-size: 0.8125rem;\n\t}\n\t.hint {\n\t\tcolor: var(--ui-muted);\n\t}\n\t.error {\n\t\tcolor: var(--ui-danger);\n\t}\n\t.sr-only {\n\t\tposition: absolute;\n\t\tinline-size: 1px;\n\t\tblock-size: 1px;\n\t\toverflow: hidden;\n\t\tclip-path: inset(50%);\n\t\twhite-space: nowrap;\n\t}\n\t@media (forced-colors: active) {\n\t\t.option.active {\n\t\t\toutline: 2px solid Highlight;\n\t\t}\n\t}\n</style>\n"},{"path":"ui/Icon.svelte","content":"<script lang=\"ts\">\n\timport type { SVGAttributes } from 'svelte/elements';\n\n\ttype IconNode = readonly (readonly [string, Readonly<Record<string, string | number>>])[];\n\n\tinterface Props extends SVGAttributes<SVGSVGElement> {\n\t\t/** Any icon from @hugeicons/core-free-icons. */\n\t\ticon: IconNode;\n\t\tsize?: number | string;\n\t\t/** Accessible name. Without it the icon is decorative and hidden from screen readers. */\n\t\tlabel?: string;\n\t\t/** Overrides the icon's stroke width (Hugeicons default 1.5). */\n\t\tstrokeWidth?: number;\n\t}\n\n\tlet { icon, size = 18, label, strokeWidth, ...rest }: Props = $props();\n\n\t// Rendered in markup, not onMount: icons are in the server HTML, so nothing pops in after hydration.\n\tconst kebab = (k: string) => k.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase());\n\tconst nodes = $derived(\n\t\ticon.map(([tag, attrs]) => ({\n\t\t\ttag,\n\t\t\tattrs: Object.fromEntries(\n\t\t\t\tObject.entries(attrs)\n\t\t\t\t\t.filter(([k]) => k !== 'key')\n\t\t\t\t\t.map(([k, v]) => [kebab(k), k === 'strokeWidth' && strokeWidth ? strokeWidth : v])\n\t\t\t)\n\t\t}))\n\t);\n</script>\n\n<svg\n\txmlns=\"http://www.w3.org/2000/svg\"\n\tviewBox=\"0 0 24 24\"\n\twidth={size}\n\theight={size}\n\tfill=\"none\"\n\trole={label ? 'img' : undefined}\n\taria-label={label}\n\taria-hidden={label ? undefined : 'true'}\n\tfocusable=\"false\"\n\t{...rest}\n>\n\t{#each nodes as { tag, attrs }, i (i)}\n\t\t<svelte:element this={tag} xmlns=\"http://www.w3.org/2000/svg\" {...attrs} />\n\t{/each}\n</svg>\n\n<style>\n\tsvg {\n\t\tflex: none;\n\t\tdisplay: block;\n\t}\n</style>\n"},{"path":"ui/announce.ts","content":"let region: HTMLElement | undefined;\n\n/**\n * Says something to screen readers without moving focus, through one shared live region\n * (React Aria keeps a single global announcer for the same reason: several regions compete).\n */\nexport function announce(text: string, politeness: 'polite' | 'assertive' = 'polite') {\n\tif (!region?.isConnected) {\n\t\tregion = document.createElement('div');\n\t\tregion.style.cssText =\n\t\t\t'position:absolute;inline-size:1px;block-size:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap';\n\t\tdocument.body.append(region);\n\t}\n\tregion.setAttribute('aria-live', politeness);\n\t// Cleared first, so saying the same thing twice is still announced.\n\tregion.textContent = '';\n\tsetTimeout(() => region && (region.textContent = text), 50);\n}\n\n/** VoiceOver (macOS and iOS) doesn't read aria-activedescendant changes; other screen readers do. */\nexport const isApple = () =>\n\ttypeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.platform);\n"},{"path":"ui/floating.ts","content":"import { ease, reduced } from './motion';\n\nexport type Side = 'top' | 'bottom' | 'left' | 'right';\n\nconst opposite: Record<Side, Side> = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' };\n\n/**\n * Positions a top-layer element (popover) beside its anchor. Prefers `side` and flips to the opposite\n * side when only that one fits; left/right drop below when neither fits. Above/below it aligns to the\n * anchor's start edge (or its end near the viewport edge; RTL mirrors both) or its center; left/right\n * it centers vertically. Always stays 8px\n * inside the viewport. Sets transform-origin to the point facing the anchor so scale animations grow out\n * of it, and exposes that point as --anchor-x / --anchor-y plus data-side for arrows.\n * Returns the side used, or null once the anchor has scrolled off-screen.\n */\nexport function place(\n\tanchor: HTMLElement,\n\tel: HTMLElement,\n\t{\n\t\tside = 'bottom',\n\t\talign = 'start',\n\t\tgap = 6\n\t}: { side?: Side; align?: 'start' | 'center' | 'top'; gap?: number } = {}\n): Side | null {\n\tconst t = anchor.getBoundingClientRect();\n\t// Layout size, not getBoundingClientRect: an in-flight scale animation must not skew placement.\n\tconst m = { width: el.offsetWidth, height: el.offsetHeight };\n\tconst edge = 8;\n\tconst vw = document.documentElement.clientWidth;\n\tconst vh = innerHeight;\n\tif (t.bottom < 0 || t.top > vh || t.right < 0 || t.left > vw) return null;\n\n\tconst room: Record<Side, number> = {\n\t\ttop: t.top - gap - edge,\n\t\tbottom: vh - t.bottom - gap - edge,\n\t\tleft: t.left - gap - edge,\n\t\tright: vw - t.right - gap - edge\n\t};\n\tconst needed = side === 'top' || side === 'bottom' ? m.height : m.width;\n\tlet s = needed > room[side] && room[opposite[side]] > room[side] ? opposite[side] : side;\n\t// Too wide for either side (a submenu on a phone): drop below its item, or above, rather than cover it.\n\tif ((s === 'left' || s === 'right') && m.width > room[s])\n\t\ts = room.bottom >= m.height || room.bottom >= room.top ? 'bottom' : 'top';\n\tconst clamp = (n: number, max: number) => Math.min(Math.max(edge, n), max - edge);\n\n\tlet x: number;\n\tlet y: number;\n\tif (s === 'top' || s === 'bottom') {\n\t\tif (align === 'center') x = t.left + t.width / 2 - m.width / 2;\n\t\telse {\n\t\t\tconst rtl = getComputedStyle(anchor).direction === 'rtl';\n\t\t\tconst start = rtl ? t.right - m.width : t.left;\n\t\t\tconst end = rtl ? t.left : t.right - m.width;\n\t\t\tconst fits = (n: number) => n >= edge && n + m.width <= vw - edge;\n\t\t\t// Neither edge fits (a phone): center on the anchor rather than pin to one side.\n\t\t\tx = fits(start) ? start : fits(end) ? end : t.left + t.width / 2 - m.width / 2;\n\t\t}\n\t\ty = s === 'top' ? t.top - gap - m.height : t.bottom + gap;\n\t} else {\n\t\tx = s === 'left' ? t.left - gap - m.width : t.right + gap;\n\t\t// top: its first row lines up with the anchor (a submenu beside its item); otherwise centered.\n\t\ty =\n\t\t\talign === 'top'\n\t\t\t\t? t.top - (parseFloat(getComputedStyle(el).paddingTop) || 0) - el.clientTop\n\t\t\t\t: t.top + t.height / 2 - m.height / 2;\n\t}\n\tx = clamp(x, vw - m.width);\n\ty = clamp(y, vh - m.height);\n\tel.style.left = `${x}px`;\n\tel.style.top = `${y}px`;\n\n\t// Where the anchor's center falls on the element: the scale origin, and where an arrow should point.\n\tconst ax = Math.min(Math.max(0, t.left + t.width / 2 - x), m.width);\n\tconst ay = Math.min(Math.max(0, t.top + t.height / 2 - y), m.height);\n\tconst origin = {\n\t\ttop: `${ax}px ${m.height}px`,\n\t\tbottom: `${ax}px 0`,\n\t\tleft: `${m.width}px ${ay}px`,\n\t\tright: `0 ${ay}px`\n\t};\n\tel.style.transformOrigin = origin[s];\n\tel.style.setProperty('--anchor-x', `${ax}px`);\n\tel.style.setProperty('--anchor-y', `${ay}px`);\n\tel.dataset.side = s;\n\treturn s;\n}\n\ntype PlaceOptions = {\n\tside?: Side;\n\talign?: 'start' | 'center' | 'top';\n\tgap?: number;\n};\n\n/** The rows inside a panel that stagger in: visible children, not aria-hidden decoration. */\nconst rows = (e: HTMLElement) =>\n\t[...e.children].filter((c): c is HTMLElement => !c.hasAttribute('aria-hidden')).slice(0, 12);\n\n/**\n * Shows and hides a top-layer element beside its anchor: grows out of it (transform + opacity only, so it\n * stays smooth on a busy page), follows scroll/resize, and calls onDismiss for an outside press or when the\n * anchor leaves the screen. Closing mid-open plays it backwards; reopening mid-close turns it around.\n */\nexport function anchored(\n\tanchor: () => HTMLElement,\n\tel: () => HTMLElement,\n\tonDismiss: () => void,\n\toptions: PlaceOptions = {}\n) {\n\tlet anim: Animation | undefined;\n\tlet contentAnims: Animation[] = [];\n\tlet closing = false;\n\tlet stop: (() => void) | undefined;\n\n\treturn {\n\t\t/** Returns the side it opened on. */\n\t\topen(): Side {\n\t\t\tconst a = anchor();\n\t\t\tconst e = el();\n\t\t\tconst reopening = anim?.playState === 'running' && closing;\n\t\t\tif (!e.matches(':popover-open')) e.showPopover();\n\t\t\tconst side = place(a, e, options) ?? options.side ?? 'bottom';\n\t\t\tconst reduce = reduced();\n\t\t\tif (reopening && anim) {\n\t\t\t\t// Turned around mid-close: it heads back out from wherever it is.\n\t\t\t\tanim.onfinish = null;\n\t\t\t\tanim.reverse();\n\t\t\t\tfor (const c of contentAnims) c.reverse();\n\t\t\t\tclosing = false;\n\t\t\t} else {\n\t\t\t\tanim?.cancel();\n\t\t\t\tfor (const c of contentAnims) c.cancel();\n\t\t\t\tcontentAnims = [];\n\t\t\t\tclosing = false;\n\t\t\t\tif (reduce) {\n\t\t\t\t\tanim = e.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 150, fill: 'both' });\n\t\t\t\t} else {\n\t\t\t\t\t// Rises 6px toward its anchor and settles from 94% on a spring; its rows follow a beat apart.\n\t\t\t\t\tconst [x, y] = { top: [0, 6], bottom: [0, -6], left: [6, 0], right: [-6, 0] }[side];\n\t\t\t\t\tanim = e.animate(\n\t\t\t\t\t\t[\n\t\t\t\t\t\t\t{ opacity: 0, translate: `${x}px ${y}px`, scale: 0.94 },\n\t\t\t\t\t\t\t{ opacity: 1, offset: 0.3 },\n\t\t\t\t\t\t\t{ opacity: 1, translate: '0 0', scale: 1 }\n\t\t\t\t\t\t],\n\t\t\t\t\t\t{ duration: 480, easing: ease.spring, fill: 'both' }\n\t\t\t\t\t);\n\t\t\t\t\t// Decorative layers (a hover highlight) keep their own opacity.\n\t\t\t\t\tcontentAnims = rows(e).map((c, i) =>\n\t\t\t\t\t\tc.animate([{ opacity: 0, translate: `${x / 2}px ${y / 2}px` }, {}], {\n\t\t\t\t\t\t\tduration: 240,\n\t\t\t\t\t\t\tdelay: 40 + Math.min(i, 8) * 20,\n\t\t\t\t\t\t\teasing: ease.enter,\n\t\t\t\t\t\t\tfill: 'backwards'\n\t\t\t\t\t\t})\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tstop?.();\n\t\t\t// Dismissed by a press that starts AND ends outside (React Aria's useInteractOutside): a touch\n\t\t\t// that turns into a scroll ends in pointercancel, not click, so scrolling the page never closes it.\n\t\t\tlet pressedOutside = false;\n\t\t\tconst down = (ev: PointerEvent) => {\n\t\t\t\tconst t = ev.target as Node;\n\t\t\t\tpressedOutside = !e.contains(t) && !a.contains(t);\n\t\t\t};\n\t\t\tconst outside = () => {\n\t\t\t\tif (!pressedOutside) return;\n\t\t\t\tpressedOutside = false;\n\t\t\t\tonDismiss();\n\t\t\t\t// Pressing empty page moved focus to <body>: put keyboard users back on the trigger.\n\t\t\t\tconst f = document.activeElement;\n\t\t\t\tif (!f || f === document.body || e.contains(f)) a.focus({ preventScroll: true });\n\t\t\t};\n\t\t\tconst follow = () => place(a, e, options) ?? onDismiss();\n\t\t\tdocument.addEventListener('pointerdown', down, true);\n\t\t\tdocument.addEventListener('click', outside, true);\n\t\t\taddEventListener('resize', follow);\n\t\t\taddEventListener('scroll', follow, true);\n\t\t\t// Content that changes size while open (a filtered list) is re-placed: above, it stays against the anchor.\n\t\t\t// Only re-places: it also fires once on observing, which must never close what just opened.\n\t\t\tconst resized = new ResizeObserver(() => place(a, e, options));\n\t\t\tresized.observe(e);\n\t\t\tstop = () => {\n\t\t\t\tresized.disconnect();\n\t\t\t\tdocument.removeEventListener('pointerdown', down, true);\n\t\t\t\tdocument.removeEventListener('click', outside, true);\n\t\t\t\tremoveEventListener('resize', follow);\n\t\t\t\tremoveEventListener('scroll', follow, true);\n\t\t\t};\n\t\t\treturn side;\n\t\t},\n\t\t/** instant: gone at once, no fade (a menu bar swapping one menu for the next). */\n\t\tclose(instant = false) {\n\t\t\tstop?.();\n\t\t\tstop = undefined;\n\t\t\tconst e = el();\n\t\t\tif (!e.matches(':popover-open')) return;\n\t\t\tif (instant || !anim) {\n\t\t\t\tanim?.cancel();\n\t\t\t\tfor (const c of contentAnims) c.cancel();\n\t\t\t\te.hidePopover();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\t// Mid-open: back the way it came. Settled: a short fade that shrinks 3% toward the anchor.\n\t\t\tclosing = true;\n\t\t\tconst hide = () => {\n\t\t\t\tclosing = false;\n\t\t\t\te.hidePopover();\n\t\t\t};\n\t\t\tfor (const c of contentAnims) c.cancel();\n\t\t\tcontentAnims = [];\n\t\t\tif (anim.playState === 'running') {\n\t\t\t\tanim.onfinish = hide;\n\t\t\t\tanim.reverse();\n\t\t\t\tanim.updatePlaybackRate(-2);\n\t\t\t} else {\n\t\t\t\tanim.cancel();\n\t\t\t\tanim = e.animate([{}, { opacity: 0, scale: 0.97 }], {\n\t\t\t\t\tduration: 130,\n\t\t\t\t\teasing: ease.standard,\n\t\t\t\t\tfill: 'both'\n\t\t\t\t});\n\t\t\t\tanim.onfinish = hide;\n\t\t\t}\n\t\t},\n\t\t/** Unmounting mid-close: cancel, so the finish callback never touches a removed element. */\n\t\tdestroy() {\n\t\t\tstop?.();\n\t\t\tanim?.cancel();\n\t\t}\n\t};\n}\n"},{"path":"ui/glide.ts","content":"import { ease, reduced } from './motion';\n/**\n * One highlight that glides to the active item (menus, listboxes, tabs, nav links) instead of each item\n * lighting up on its own. It moves and resizes with FLIP: jump to the final box, then animate a transform\n * from where it visually was, so only transform animates and an interrupted move continues smoothly.\n * Pointer moves glide; keyboard moves and first appearances snap. The highlight needs\n * `position: absolute; top: 0; left: 0; transform-origin: 0 0` and an opacity transition for fading.\n */\nexport function glide(\n\thighlight: HTMLElement,\n\titem: HTMLElement | null | undefined,\n\tanimate: boolean,\n\t/** ms; hover-speed by default, longer for deliberate moves like changing page. */\n\tduration = 160\n) {\n\tif (!item) {\n\t\thighlight.style.opacity = '0';\n\t\treturn;\n\t}\n\tconst appearing = highlight.style.opacity !== '1';\n\tconst was = new DOMMatrix(getComputedStyle(highlight).transform);\n\tconst from = {\n\t\tx: was.e,\n\t\ty: was.f,\n\t\tw: was.a * highlight.offsetWidth,\n\t\th: was.d * highlight.offsetHeight\n\t};\n\tfor (const a of highlight.getAnimations()) if (!(a instanceof CSSTransition)) a.cancel();\n\n\tconst to = { x: item.offsetLeft, y: item.offsetTop, w: item.offsetWidth, h: item.offsetHeight };\n\tconst end = `translate(${to.x}px, ${to.y}px)`;\n\thighlight.style.transform = end;\n\thighlight.style.inlineSize = `${to.w}px`;\n\thighlight.style.blockSize = `${to.h}px`;\n\thighlight.style.opacity = '1';\n\n\tif (!animate || appearing || !from.w || reduced()) return;\n\thighlight.animate(\n\t\t[\n\t\t\t{\n\t\t\t\ttransform: `translate(${from.x}px, ${from.y}px) scale(${from.w / to.w}, ${from.h / to.h})`\n\t\t\t},\n\t\t\t{ transform: end }\n\t\t],\n\t\t{ duration, easing: ease.standard }\n\t);\n}\n"},{"path":"ui/motion.ts","content":"import { flip } from 'svelte/animate';\nimport { cubicOut } from 'svelte/easing';\nimport { scale } from 'svelte/transition';\nimport { spring } from './spring';\n\n/** The house curves, for the Web Animations API (same as the --ui-ease-* tokens). */\nexport const ease = {\n\tenter: 'cubic-bezier(0.16, 1, 0.3, 1)',\n\tstandard: 'cubic-bezier(0.22, 1, 0.36, 1)',\n\texit: 'cubic-bezier(0.7, 0, 0.84, 0)',\n\tinOut: 'cubic-bezier(0.65, 0, 0.35, 1)',\n\tspring: spring(0.12)\n};\n\n/** People who ask their system for less motion get none. */\nexport const reduced = () =>\n\ttypeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;\n\n/** A duration, or 0 for reduced motion. */\nexport const ms = (n: number) => (reduced() ? 0 : n);\n\n/** `animate:reflow`: list items glide to their new places as others come, go or move. */\nexport const reflow: typeof flip = (node, rects, { duration = 200, ...rest } = {}) =>\n\tflip(node, rects, { easing: cubicOut, ...rest, duration: ms(Number(duration)) });\n\n/** `in:pop` / `out:pop`: an item joining or leaving a list grows in or shrinks away. */\nexport const pop: typeof scale = (node, { duration = 150, ...rest } = {}) =>\n\tscale(node, { start: 0.95, easing: cubicOut, ...rest, duration: ms(duration) });\n"},{"path":"ui/scroll-edges.ts","content":"import type { Attachment } from 'svelte/attachments';\n\n/**\n * Measures how much of a scroll area is hidden past each edge and sets --scroll-start / --scroll-end\n * (0 to 1, easing in over the first 24px). tokens.css turns them into fades: data-fade=\"y\" or \"x\" fades\n * the content out (areas with no background of their own); data-fade-over lays a surface-colored fade\n * over it (lists on a card, where a mask would also cut off the card's shadow).\n */\nexport const scrollEdges: Attachment<HTMLElement> = (el) => {\n\tconst update = () => {\n\t\tlet before: number;\n\t\tlet after: number;\n\t\tif (el.dataset.fade === 'x') {\n\t\t\tconst max = el.scrollWidth - el.clientWidth;\n\t\t\t// Right to left, scrollLeft runs from 0 down to -max; left/right here are physical.\n\t\t\tconst left = getComputedStyle(el).direction === 'rtl' ? max + el.scrollLeft : el.scrollLeft;\n\t\t\tbefore = left;\n\t\t\tafter = max - left;\n\t\t} else {\n\t\t\tbefore = el.scrollTop;\n\t\t\tafter = el.scrollHeight - el.clientHeight - el.scrollTop;\n\t\t}\n\t\tel.style.setProperty('--scroll-start', `${Math.min(1, Math.max(0, before / 24))}`);\n\t\tel.style.setProperty('--scroll-end', `${Math.min(1, Math.max(0, after / 24))}`);\n\t};\n\tupdate();\n\tel.addEventListener('scroll', update, { passive: true });\n\t// Size and content changes (a filtered list, a font loading) move the edges too.\n\tconst resized = new ResizeObserver(update);\n\tresized.observe(el);\n\tfor (const child of el.children) resized.observe(child);\n\tconst changed = new MutationObserver(update);\n\tchanged.observe(el, { childList: true, subtree: true });\n\treturn () => {\n\t\tel.removeEventListener('scroll', update);\n\t\tresized.disconnect();\n\t\tchanged.disconnect();\n\t};\n};\n"},{"path":"ui/spring.ts","content":"/** A damped spring's position at progress t (0 to 1): overshoots a little, then settles at 1. */\nexport function springAt(bounce: number) {\n\tconst zeta = 1 - bounce;\n\t// Frequency chosen so the motion has all but settled by t = 1.\n\tconst omega = 5 / zeta;\n\tconst damped = omega * Math.sqrt(1 - zeta * zeta);\n\treturn (t: number) =>\n\t\tt >= 1\n\t\t\t? 1\n\t\t\t: 1 -\n\t\t\t\tMath.exp(-zeta * omega * t) *\n\t\t\t\t\t(Math.cos(damped * t) + ((zeta * omega) / damped) * Math.sin(damped * t));\n}\n\n/**\n * The same spring as a CSS easing, sampled into linear(): it runs as a plain CSS transition, off the\n * main thread, and a transition interrupted part way picks up smoothly from where it is.\n */\nexport function spring(bounce = 0.2) {\n\tconst at = springAt(bounce);\n\treturn `linear(${Array.from({ length: 49 }, (_, i) => at(i / 48).toFixed(4)).join(', ')})`;\n}\n"}],"dependencies":{"@hugeicons/core-free-icons":"^4.3.5"}}