feat(editor,quick-entry): page editor with splits, links, attachments and one-line quick entry

This commit is contained in:
hh committed 2026-10-16 03:25:02 +00:00
1 parent 135a2d9328
commit 7e19684cff
22 files changed
+4067 -10

No files matched your search

@@ -0,0 +1,63 @@
<script lang="ts">
import type { HTMLInputAttributes } from "svelte/elements";
import { formatNumber } from "#lib/format.js";
import { evalAmount, isExpression } from "./parse.js";
import { INPUT } from "./styles.js";
let {
value = $bindable(""),
ref = $bindable(null),
currency = "",
decimals = 2,
invalid = false,
class: className = "",
...rest
}: Omit<HTMLInputAttributes, "value"> & {
value?: string;
ref?: HTMLInputElement | null;
currency?: string;
decimals?: number;
invalid?: boolean;
} = $props();
const result = $derived(evalAmount(value));
const preview = $derived(
isExpression(value) && result !== null
? `= ${formatNumber(result, decimals)}`
: ""
);
function normalise() {
if (result !== null && result > 0) {
value = result.toFixed(decimals);
}
}
</script>
<div class="relative {className}">
<input
bind:this={ref}
{...rest}
aria-invalid={invalid ||
(value.trim() !== "" && result === null) ||
undefined}
autocomplete="off"
class="{INPUT} num text-right {currency ? "pr-12" : ""}"
inputmode="decimal"
onblur={normalise}
spellcheck="false"
bind:value
>
{#if currency}
<span
class="pointer-events-none absolute inset-y-0 right-2.5 flex items-center text-[12px] text-cladd-fg-soft"
>{currency}</span
>
{/if}
{#if preview}
<span
class="num pointer-events-none absolute inset-y-0 left-2.5 flex items-center text-[12px] text-cladd-fg-soft"
>{preview}</span
>
{/if}
</div>
@@ -0,0 +1,253 @@
<script lang="ts" module>
export interface Option {
data?: unknown;
hint?: string;
id: string;
label: string;
meta?: string;
}
</script>
<script lang="ts">
import type { HTMLInputAttributes } from "svelte/elements";
import { INPUT, LIST } from "./styles.js";
const DEBOUNCE_MS = 150;
const STEPS: Record<string, number> = { ArrowDown: 1, ArrowUp: -1 };
const CTRL_STEPS: Record<string, number> = { n: 1, p: -1 };
let {
value = $bindable(""),
selected = $bindable(null),
ref = $bindable(null),
search,
onpick,
free = false,
async = false,
openOnFocus = false,
invalid = false,
bare = false,
class: className = "",
inputClass = "",
onkeydown: onkey,
onblur: onleave,
onfocus: onenter,
onenter: onsubmit,
...rest
}: Omit<HTMLInputAttributes, "value" | "onkeydown" | "onblur" | "onfocus"> & {
value?: string;
selected?: Option | null;
ref?: HTMLInputElement | null;
search: (query: string) => Option[] | Promise<Option[]>;
onpick?: (option: Option) => void;
free?: boolean;
async?: boolean;
openOnFocus?: boolean;
invalid?: boolean;
bare?: boolean;
inputClass?: string;
onkeydown?: (event: KeyboardEvent) => void;
onblur?: (event: FocusEvent) => void;
onfocus?: (event: FocusEvent) => void;
onenter?: (event: KeyboardEvent) => void;
} = $props();
const uid = $props.id();
let open = $state(false);
let items = $state<Option[]>([]);
let active = $state(-1);
let timer: ReturnType<typeof setTimeout> | undefined;
let ticket = 0;
const query = $derived(value.trim());
const exact = $derived(
items.find((item) => item.label.toLowerCase() === query.toLowerCase())
);
const shown = $derived(
free && query && !exact
? [...items, { hint: "new", id: "", label: query }]
: items
);
async function load(text: string, highlight: boolean) {
ticket += 1;
const mine = ticket;
const result = await search(text);
if (mine !== ticket || document.activeElement !== ref) {
return;
}
items = result;
open = true;
active = highlight && text.trim() ? 0 : -1;
}
function refresh(highlight: boolean) {
clearTimeout(timer);
if (async) {
timer = setTimeout(() => load(value, highlight), DEBOUNCE_MS);
} else {
load(value, highlight);
}
}
function close() {
clearTimeout(timer);
ticket += 1;
open = false;
active = -1;
}
function pick(option: Option) {
value = option.label;
selected = option.id ? option : null;
close();
onpick?.(option);
}
function move(step: number) {
if (!open) {
refresh(true);
return;
}
const count = shown.length;
if (count) {
active =
(active + step + count + (active < 0 && step < 0 ? 1 : 0)) % count;
}
}
function stepOf(event: KeyboardEvent): number {
const ctrl = event.ctrlKey && !event.metaKey && !event.altKey;
return STEPS[event.key] ?? (ctrl ? CTRL_STEPS[event.key] : 0) ?? 0;
}
function handleKey(event: KeyboardEvent) {
onkey?.(event);
if (event.defaultPrevented) {
return;
}
const step = stepOf(event);
if (step) {
event.preventDefault();
move(step);
return;
}
if (open && event.key === "Escape") {
event.preventDefault();
close();
return;
}
const plain = !(
event.shiftKey ||
event.ctrlKey ||
event.metaKey ||
event.altKey
);
const choice = open && plain ? shown[active] : undefined;
if (choice && (event.key === "Enter" || event.key === "Tab")) {
if (event.key === "Enter") {
event.preventDefault();
}
pick(choice);
return;
}
if (event.key === "Enter" && plain) {
onsubmit?.(event);
}
}
function handleInput({
currentTarget,
}: Event & { currentTarget: HTMLInputElement }) {
({ value } = currentTarget);
if (selected && selected.label !== value) {
selected = null;
}
refresh(true);
}
function handleBlur(event: FocusEvent) {
if (!selected && exact) {
value = exact.label;
selected = exact;
onpick?.(exact);
}
close();
onleave?.(event);
}
function handleFocus(event: FocusEvent) {
if (openOnFocus) {
refresh(false);
}
onenter?.(event);
}
$effect(() => {
if (open && active >= 0) {
document
.getElementById(`${uid}-${active}`)
?.scrollIntoView({ block: "nearest" });
}
});
</script>
<div class="{bare ? "" : "relative"} {className}">
<input
bind:this={ref}
{...rest}
aria-activedescendant={open && active >= 0 ? `${uid}-${active}` : undefined}
aria-autocomplete="list"
aria-controls="{uid}-list"
aria-expanded={open && shown.length > 0}
aria-invalid={invalid || undefined}
autocomplete="off"
class={inputClass || (bare ? "" : INPUT)}
onblur={handleBlur}
onfocus={handleFocus}
oninput={handleInput}
onkeydown={handleKey}
role="combobox"
spellcheck="false"
{value}
>
{#if open && shown.length > 0}
<div class={LIST} id="{uid}-list" role="listbox">
{#each shown as option, index (`${option.id}:${option.label}`)}
<div
aria-selected={index === active}
class="flex cursor-pointer items-baseline gap-2 px-2.5 py-1.5 text-[13px] sm:py-1 {index ===
active
? "bg-row-cursor"
: "hover:bg-row-hover"}"
id="{uid}-{index}"
onmousedown={(event) => {
event.preventDefault();
pick(option);
}}
role="option"
tabindex="-1"
>
<span class="min-w-0 flex-1 truncate">
{#if option.hint === "new"}
<span class="text-cladd-fg-soft">Create</span>
"{option.label}"
{:else}
{option.label}
{/if}
</span>
{#if option.hint && option.hint !== "new"}
<span class="num shrink-0 text-[12px] text-cladd-fg-soft"
>{option.hint}</span
>
{/if}
{#if option.meta}
<span class="shrink-0 text-[11px] text-cladd-fg-softer"
>{option.meta}</span
>
{/if}
</div>
{/each}
</div>
{/if}
</div>
+113
View File
@@ -0,0 +1,113 @@
<script lang="ts">
import Calendar from "@lucide/svelte/icons/calendar";
import { untrack } from "svelte";
import type { HTMLInputAttributes } from "svelte/elements";
import { addDays, today } from "#lib/dates.js";
import { formatDate } from "#lib/format.js";
import { parseDate } from "./parse.js";
import { INPUT } from "./styles.js";
let {
value = $bindable(""),
ref = $bindable(null),
invalid = false,
class: className = "",
...rest
}: Omit<HTMLInputAttributes, "value"> & {
value?: string;
ref?: HTMLInputElement | null;
invalid?: boolean;
} = $props();
let text = $state(value);
let picker = $state<HTMLInputElement | null>(null);
const parsed = $derived(parseDate(text, today()));
const shown = $derived(parsed ?? value);
const label = $derived(
shown
? `${formatDate(shown, "day")}${shown.slice(0, 4) === today().slice(0, 4) ? "" : ` ${shown.slice(0, 4)}`}`
: ""
);
$effect(() => {
const next = value;
untrack(() => {
if (next && next !== parseDate(text)) {
text = next;
}
});
});
function handleInput(event: Event & { currentTarget: HTMLInputElement }) {
text = event.currentTarget.value;
const next = parseDate(text);
if (next) {
value = next;
}
}
function handleBlur() {
if (parsed) {
text = parsed;
value = parsed;
}
}
function handleKey(event: KeyboardEvent) {
if (
(event.key === "ArrowUp" || event.key === "ArrowDown") &&
!(event.altKey || event.ctrlKey || event.metaKey)
) {
event.preventDefault();
const step =
(event.key === "ArrowUp" ? 1 : -1) * (event.shiftKey ? 7 : 1);
value = addDays(parsed ?? value ?? today(), step);
text = value;
}
}
function openPicker() {
picker?.showPicker?.();
}
</script>
<div class="relative {className}">
<input
bind:this={ref}
{...rest}
aria-invalid={invalid || (text.trim() !== "" && !parsed) || undefined}
autocomplete="off"
class="{INPUT} num pr-28"
onblur={handleBlur}
oninput={handleInput}
onkeydown={handleKey}
spellcheck="false"
value={text}
>
<span
class="pointer-events-none absolute inset-y-0 right-9 flex items-center text-[12px] text-cladd-fg-soft"
>{label}</span
>
<button
aria-label="Pick a date"
class="absolute inset-y-0 right-0 grid w-8 place-items-center text-cladd-fg-soft hover:text-cladd-fg"
onclick={openPicker}
tabindex="-1"
type="button"
>
<Calendar class="size-3.5" />
</button>
<input
aria-hidden="true"
class="pointer-events-none absolute right-0 bottom-0 h-0 w-0 opacity-0"
onchange={({ currentTarget }) => {
({ value } = currentTarget);
text = value;
}}
tabindex="-1"
type="date"
{value}
bind:this={picker}
>
</div>
+38
View File
@@ -0,0 +1,38 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { prettyKeys } from "#lib/keys.svelte.js";
let {
label,
for: target,
error: message = "",
keys = "",
note,
children,
class: className = "",
}: {
label: string;
for?: string;
error?: string;
keys?: string;
note?: Snippet;
children: Snippet;
class?: string;
} = $props();
</script>
<div class="flex min-w-0 flex-col gap-1 {className}">
<div class="flex items-baseline justify-between gap-2">
<label class="text-[12px] text-cladd-fg-soft" for={target}>{label}</label>
{#if keys}
<span class="kbd hidden lg:inline-flex"
>{prettyKeys(keys).join(" ")}</span
>
{/if}
</div>
{@render children()}
{#if message}
<p class="text-[12px] text-expense" role="alert">{message}</p>
{/if}
{@render note?.()}
</div>
@@ -0,0 +1,98 @@
<script lang="ts">
import X from "@lucide/svelte/icons/x";
import AutocompleteInput, { type Option } from "./AutocompleteInput.svelte";
import { WELL } from "./styles.js";
let {
tags = $bindable([]),
ref = $bindable(null),
search,
id,
placeholder = "",
invalid = false,
}: {
tags?: string[];
ref?: HTMLInputElement | null;
search: (query: string, taken: string[]) => Option[];
id?: string;
placeholder?: string;
invalid?: boolean;
} = $props();
let text = $state("");
function add(raw: string) {
const names = raw
.split(",")
.map((name) => name.trim())
.filter(Boolean);
const known = new Set(tags.map((tag) => tag.toLowerCase()));
const fresh = names.filter((name) => !known.has(name.toLowerCase()));
if (fresh.length) {
tags = [...tags, ...fresh];
}
text = "";
}
function remove(index: number) {
tags = tags.filter((_, i) => i !== index);
ref?.focus();
}
function handleKey(event: KeyboardEvent) {
const plain = !(event.ctrlKey || event.metaKey || event.altKey);
if (event.key === "," && plain) {
event.preventDefault();
add(text);
return;
}
if (event.key === "Backspace" && text === "" && tags.length && plain) {
event.preventDefault();
tags = tags.slice(0, -1);
}
}
function handleEnter(event: KeyboardEvent) {
if (text.trim()) {
event.preventDefault();
add(text);
}
}
</script>
<div
class="{WELL} relative flex min-h-9 w-full flex-wrap items-center gap-1 px-1.5 py-1 sm:min-h-cladd-md sm:py-0.5"
>
{#each tags as tag, index (tag)}
<span
class="inline-flex h-6 items-center gap-1 rounded-[5px] bg-cladd-surface-next pr-0.5 pl-1.5 text-[12px] text-cladd-fg sm:h-5"
>
{tag}
<button
aria-label="Remove tag {tag}"
class="grid size-4 place-items-center rounded text-cladd-fg-soft hover:bg-black/10 hover:text-cladd-fg"
onclick={() => remove(index)}
tabindex="-1"
type="button"
>
<X class="size-3" />
</button>
</span>
{/each}
<AutocompleteInput
bare
class="min-w-24 flex-1"
free
{id}
inputClass="h-7 w-full min-w-0 bg-transparent px-1 text-[16px] outline-none placeholder:text-cladd-fg-softer sm:h-6 sm:text-[13px]"
{invalid}
onblur={() => add(text)}
onenter={handleEnter}
onkeydown={handleKey}
onpick={(option) => add(option.label)}
placeholder={tags.length ? "" : placeholder}
search={(query) => search(query, tags)}
bind:ref
bind:value={text}
/>
</div>
+162
View File
@@ -0,0 +1,162 @@
import { addDays, iso, today } from "#lib/dates.js";
const SPACES = /\s+/g;
const NUMBER = /^\d[\d.,]*/;
const OPERATOR = /[+\-*/()]/;
const ISO_DAY = /^(\d{4})-(\d{1,2})-(\d{1,2})$/;
const DOTTED = /^(\d{1,2})[./](\d{1,2})(?:[./](\d{2,4}))?$/;
const RELATIVE = /^[+-]\d{1,4}$/;
const EXPRESSION = /\d\s*[+\-*/]\s*[\d(]/;
function toDecimal(raw: string): number | null {
const comma = raw.lastIndexOf(",");
const dot = raw.lastIndexOf(".");
let text = raw;
if (comma !== -1 && dot !== -1) {
const decimal = comma > dot ? "," : ".";
const thousands = decimal === "," ? "." : ",";
text = raw.split(thousands).join("").replace(decimal, ".");
} else if (comma !== -1) {
const parts = raw.split(",");
const fraction = parts.at(-1) ?? "";
text =
parts.length === 2 && fraction.length <= 2
? parts.join(".")
: parts.join("");
}
const value = Number(text);
return Number.isFinite(value) ? value : null;
}
function tokenize(input: string): (number | string)[] | null {
const tokens: (number | string)[] = [];
let rest = input;
while (rest) {
const number = rest.match(NUMBER);
if (number) {
const value = toDecimal(number[0]);
if (value === null) {
return null;
}
tokens.push(value);
rest = rest.slice(number[0].length);
} else if (OPERATOR.test(rest[0])) {
tokens.push(rest[0]);
rest = rest.slice(1);
} else {
return null;
}
}
return tokens;
}
function evaluate(tokens: (number | string)[]): number | null {
let index = 0;
const peek = () => tokens[index];
const next = () => {
index += 1;
return tokens[index - 1];
};
const factor = (): number | null => {
const token = next();
if (token === "-") {
const value = factor();
return value === null ? null : -value;
}
if (token === "(") {
const value = sum();
return next() === ")" ? value : null;
}
return typeof token === "number" ? token : null;
};
const product = (): number | null => {
let left = factor();
while (left !== null && (peek() === "*" || peek() === "/")) {
const op = next();
const right = factor();
if (right === null) {
return null;
}
left = op === "*" ? left * right : left / right;
}
return left;
};
const sum = (): number | null => {
let left = product();
while (left !== null && (peek() === "+" || peek() === "-")) {
const op = next();
const right = product();
if (right === null) {
return null;
}
left = op === "+" ? left + right : left - right;
}
return left;
};
const value = sum();
return index === tokens.length && value !== null && Number.isFinite(value)
? value
: null;
}
export function evalAmount(input: string): number | null {
const text = input.replace(SPACES, "");
if (!text) {
return null;
}
const tokens = tokenize(text);
return tokens ? evaluate(tokens) : null;
}
export const isExpression = (input: string) =>
EXPRESSION.test(input) || input.includes("(");
export function amountString(input: string, decimals = 2): string {
const value = evalAmount(input);
return value === null ? "" : value.toFixed(decimals);
}
function valid(year: number, month: number, day: number): string | null {
const date = new Date(year, month - 1, day);
return date.getMonth() === month - 1 && date.getDate() === day
? iso(date)
: null;
}
const WORDS: Record<string, number> = {
tod: 0,
today: 0,
tom: 1,
tomorrow: 1,
yd: -1,
yest: -1,
yesterday: -1,
};
export function parseDate(input: string, base = today()): string | null {
const text = input.trim().toLowerCase();
if (!text) {
return null;
}
if (text in WORDS) {
return addDays(base, WORDS[text]);
}
if (RELATIVE.test(text)) {
return addDays(base, Number(text));
}
const full = text.match(ISO_DAY);
if (full) {
return valid(Number(full[1]), Number(full[2]), Number(full[3]));
}
const dotted = text.match(DOTTED);
if (dotted) {
const year = Number(base.slice(0, 4));
const raw = dotted[3] ? Number(dotted[3]) : year;
return valid(
raw < 100 ? 2000 + raw : raw,
Number(dotted[2]),
Number(dotted[1])
);
}
return null;
}
+7
View File
@@ -0,0 +1,7 @@
export const WELL =
"cladd-cut cladd-hoverable cladd-focusable-input rounded-cladd-md text-cladd-fg";
export const INPUT = `${WELL} flex h-9 w-full min-w-0 px-2.5 text-[16px] placeholder:text-cladd-fg-softer disabled:cursor-not-allowed disabled:opacity-60 sm:h-cladd-md sm:text-[13px]`;
export const LIST =
"cladd-surface cladd-outline absolute top-full right-0 left-0 z-50 mt-1 max-h-72 overflow-y-auto rounded-cladd-md py-1 shadow-lg";