feat(*): one task editor, vim navigation and undo
This commit is contained in:
1 parent
0873cbb366
commit
83ca32d98e
37 files changed
+4043
-505
No files matched your search
@@ -0,0 +1,126 @@
|
||||
<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)}>
|
||||
{#each shown as { item, ranges }, 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>
|
||||
Reference in new issue
Block a user