diff --git a/src/lib/components/shell/Palette.svelte b/src/lib/components/shell/Palette.svelte index aaa28f5..864d3f2 100644 --- a/src/lib/components/shell/Palette.svelte +++ b/src/lib/components/shell/Palette.svelte @@ -17,9 +17,75 @@ run: () => void; } + const CLICKABLE = + "a[href], button, summary, select, textarea, input:not([type='hidden']), [role='button'], [role='tab'], [role='link'], [role='menuitem']"; + const FIELD = + "select, textarea, input:not([type='checkbox'], [type='radio'])"; + const SKIP = "tbody, [aria-hidden='true'], [inert]"; + const MAX_LABEL = 80; + const WHITESPACE = /\s+/g; + let dialog = $state(); let input = $state(); let cursor = $state(0); + let pageEntries = $state.raw([]); + + function labelOf(element: HTMLElement): string { + const field = element as HTMLInputElement; + const text = + element.getAttribute("aria-label") || + field.labels?.[0]?.innerText || + element.innerText || + element.getAttribute("title") || + field.placeholder || + ""; + return text.replace(WHITESPACE, " ").trim().slice(0, MAX_LABEL); + } + + function press(element: HTMLElement, field: boolean) { + requestAnimationFrame(() => { + element.focus(); + if (!field) { + element.click(); + } + }); + } + + function scanPage(): Entry[] { + const seen = new Set(); + const found: Entry[] = []; + const elements = + document + .querySelector("main") + ?.querySelectorAll(CLICKABLE) ?? []; + for (const element of elements) { + const hidden = + element.closest(SKIP) !== null || + element.matches(":disabled") || + element.getClientRects().length === 0; + const label = hidden ? "" : labelOf(element); + const id = `${label}|${element.getAttribute("href") ?? ""}`; + if (!label || seen.has(id)) { + continue; + } + seen.add(id); + const field = element.matches(FIELD); + let hint = "button"; + if (field) { + hint = "field"; + } else if (element.matches("a")) { + hint = "link"; + } + found.push({ + group: "On this page", + hint, + id: `dom:${found.length}`, + label, + run: () => press(element, field), + }); + } + return found; + } const accounts = createQuery(() => ({ ...lists.accounts("all"), @@ -149,6 +215,7 @@ ]); const entries = $derived([ + ...pageEntries, ...staticEntries, ...commandEntries, ...entityEntries, @@ -175,6 +242,7 @@ $effect(() => { if (ui.palette && dialog && !dialog.open) { + pageEntries = scanPage(); cursor = 0; dialog.showModal(); input?.select(); @@ -244,7 +312,7 @@ aria-expanded="true" class="h-12 flex-1 bg-transparent text-[15px] outline-none placeholder:text-cladd-fg-softer" {onkeydown} - placeholder="Go to page, account, budget, category, tag, or run a command" + placeholder="Press anything on this page, go to a page, account, budget, tag, or run a command" role="combobox" bind:this={input} bind:value={ui.paletteQuery}