Files
beaver-calendar/src/ui/option-list.svelte
T
hh b70331f7bd fix(ui): panels stay above the keyboard, the day two offers share is listed once
The date field would not open at all on some days: on a Friday "Friday" and
"In a week" resolve to the same day, and the option list was keyed by the day
it writes, so Svelte tore it down with each_key_duplicate. On a desktop this
took the whole view with it - the same bug, not two. Duplicate offers are
dropped (the more specific name wins) and the list is keyed by position, so
no caller can tear it down that way again.

Panels are placed against the visual viewport rather than the window, which
a phone's keyboard shrinks without resizing anything: they scroll inside what
is left, re-place as the keyboard animates in, and come back into view when
the keyboard covers the very row they hang off - visible beats staying next
to the anchor. The quick look centres the same way.

Its actions are thumb-sized where the pointer is coarse, with the keycaps
hidden - there is no keyboard to press them on - and it gains "no date" and a
close button, since a phone has no Escape. Both menus gain a plain "Edit"
beside "Edit in Tasks"; it was only ever reachable by pressing e.
2026-09-05 00:22:07 +02:00

129 lines
3.6 KiB
Svelte

<script lang="ts" module>
import type { Component } from "svelte";
export interface Option {
color?: string;
hint?: string;
icon?: Component;
label: string;
/** A coloured dot, used where an icon would be noise. */
dot?: string;
value: string;
}
</script>
<script lang="ts">
import Check from "@lucide/svelte/icons/check";
import { pieces, rank } from "../lib/fuzzy";
import { cn } from "../lib/utils";
interface Props {
class?: string;
empty?: string;
onchoose: (option: Option) => void;
options: Option[];
query?: string;
value?: string | null;
}
let {
options,
query = "",
value = null,
onchoose,
empty = "Nothing found",
class: className,
}: Props = $props();
// null means "wherever the current value is", so a list that was never
// touched commits back to itself instead of jumping to the first row.
let active = $state<number | null>(null);
let seen = $state<string | null>(null);
let box = $state<HTMLElement | null>(null);
const shown = $derived(rank(query, options, (option) => option.label));
const index = $derived.by(() => {
if (shown.length === 0) return 0;
if (active !== null) return Math.min(active, shown.length - 1);
const at = shown.findIndex((hit) => hit.item.value === value);
return at === -1 ? 0 : at;
});
$effect(() => {
if (query !== seen) {
seen = query;
active = null;
}
});
$effect(() => {
void index;
box
?.querySelector<HTMLElement>("[data-active='true']")
?.scrollIntoView({ block: "nearest" });
});
export function move(delta: number): void {
if (shown.length === 0) return;
// Wrapping keeps a long hold on ⌃n from dead-ending at the last match.
active = (index + delta + shown.length) % shown.length;
}
export function commit(): boolean {
const option = shown[index]?.item;
if (!option) return false;
onchoose(option);
return true;
}
export function highlighted(): Option | null {
return shown[index]?.item ?? null;
}
</script>
<div bind:this={box} class={cn("max-h-56 overflow-y-auto", className)}>
<!-- Keyed by position, not by value: a caller that offers the same value
twice should read oddly, never tear the list down. -->
{#each shown as { item, ranges }, at (`${at}:${item.value}`)}
<button
class={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm outline-none transition-colors",
at === index && "bg-muted",
)}
data-active={at === index}
onclick={() => onchoose(item)}
onmouseenter={() => {
active = at;
}}
type="button"
>
{#if item.dot}
<span
aria-hidden="true"
class="size-1.5 shrink-0 rounded-full"
style="background: {item.dot}"
></span>
{:else if item.icon}
<item.icon
aria-hidden="true"
class="size-4 shrink-0"
style={item.color ? `color: ${item.color}` : undefined}
/>
{/if}
<span class="min-w-0 flex-1 truncate">
{#each pieces(item.label, ranges) as piece, part (part)}<span
class={piece.hit ? "text-signal" : undefined}>{piece.text}</span
>{/each}
</span>
{#if item.hint}
<span class="shrink-0 text-muted-foreground text-xs">{item.hint}</span>
{/if}
{#if item.value === value}
<Check aria-hidden="true" class="size-3.5 shrink-0 text-signal" />
{/if}
</button>
{:else}
<p class="px-2 py-3 text-center text-muted-foreground text-xs">{empty}</p>
{/each}
</div>