Sidebar (lazy tree, keyboard navigation, saved searches), page view with typed properties, tasks, tags, calendar and clip dialogs, settings with shortcut rebinding, canvas, database and board views. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
140 lines
6.0 KiB
Svelte
140 lines
6.0 KiB
Svelte
<script>
|
|
// A text field for a page path with a list of existing pages under it, as you type: for choosing a folder.
|
|
import Icon from './Icon.svelte';
|
|
import { store, iconOf } from '$lib/state.svelte.js';
|
|
import { cleanFolder, autoFolder } from '$lib/daily.js';
|
|
|
|
// value: the chosen path ('' = automatic); onchange(path or '') is called when the choice is made
|
|
let { value = '', onchange, label = 'Folder' } = $props();
|
|
|
|
let input = $state();
|
|
let open = $state(false);
|
|
let text = $state(value);
|
|
let active = $state(0);
|
|
let at = $state({ left: 0, top: 0, bottom: null, width: 260, max: 300 });
|
|
|
|
$effect(() => { if (!open) text = value; });
|
|
|
|
const auto = $derived(autoFolder(store.pages));
|
|
const pages = $derived(store.pages.filter((p) => p.exists && !/\.canvas$/i.test(p.id)));
|
|
const q = $derived(text.trim().toLowerCase());
|
|
// best matches first: names starting with it, then names containing it, then paths containing it
|
|
const found = $derived.by(() => {
|
|
if (!q) return pages.slice().sort((a, b) => a.id.localeCompare(b.id)).slice(0, 40);
|
|
const rank = (p) => {
|
|
const t = p.title.toLowerCase();
|
|
return t.startsWith(q) ? 0 : t.includes(q) ? 1 : p.id.toLowerCase().includes(q) ? 2 : 3;
|
|
};
|
|
return pages.map((p) => [rank(p), p]).filter(([r]) => r < 3).sort((a, b) => a[0] - b[0] || a[1].id.localeCompare(b[1].id)).map(([, p]) => p).slice(0, 40);
|
|
});
|
|
const typed = $derived(cleanFolder(text));
|
|
const isNew = $derived(!!typed && !pages.some((p) => p.id.toLowerCase() === typed.toLowerCase()));
|
|
// rows: the automatic choice, matching pages, and "use what I typed" for a folder that doesn't exist yet
|
|
const rows = $derived([
|
|
...(q ? [] : [{ kind: 'auto' }]),
|
|
...found.map((p) => ({ kind: 'page', p })),
|
|
...(isNew ? [{ kind: 'new', id: typed }] : [])
|
|
]);
|
|
|
|
function show() {
|
|
const r = input.getBoundingClientRect();
|
|
const width = Math.max(r.width, 280);
|
|
// below the field if there's room (or more room than above), otherwise above it
|
|
const below = innerHeight - r.bottom - 14, above = r.top - 14;
|
|
const up = below < 240 && above > below;
|
|
const left = Math.max(8, Math.min(r.right - width, innerWidth - width - 8));
|
|
at = up
|
|
? { left, top: null, bottom: innerHeight - r.top + 6, width, max: Math.min(300, above) }
|
|
: { left, top: r.bottom + 6, bottom: null, width, max: Math.min(300, below) };
|
|
active = 0;
|
|
open = true;
|
|
}
|
|
function choose(row) {
|
|
open = false;
|
|
if (!row) return;
|
|
const next = row.kind === 'auto' ? '' : row.kind === 'page' ? row.p.id : row.id;
|
|
text = next;
|
|
if (next !== value) onchange?.(next);
|
|
input?.blur();
|
|
}
|
|
function commitTyped() {
|
|
open = false;
|
|
const next = typed;
|
|
if (next !== value) { text = next; onchange?.(next); }
|
|
else text = value;
|
|
}
|
|
function onkeydown(e) {
|
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
if (!open) return show();
|
|
const n = rows.length || 1;
|
|
active = (active + (e.key === 'ArrowDown' ? 1 : -1) + n) % n;
|
|
document.querySelector('.fp-list .mi.act')?.scrollIntoView({ block: 'nearest' });
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
// a highlighted row if the list was used; otherwise exactly what was typed
|
|
if (open && rows[active] && !(text.trim() && rows[active].kind === 'auto')) choose(rows[active]);
|
|
else { commitTyped(); input.blur(); }
|
|
} else if (e.key === 'Escape' && open) {
|
|
e.preventDefault();
|
|
e.stopPropagation(); // the dialog stays
|
|
open = false;
|
|
text = value;
|
|
}
|
|
}
|
|
const mark = (s) => {
|
|
const i = q ? s.toLowerCase().indexOf(q) : -1;
|
|
return i < 0 ? [s, '', ''] : [s.slice(0, i), s.slice(i, i + q.length), s.slice(i + q.length)];
|
|
};
|
|
</script>
|
|
|
|
<svelte:window onresize={() => (open = false)} />
|
|
|
|
<div class="fp">
|
|
<Icon name="folder" />
|
|
<input
|
|
bind:this={input}
|
|
bind:value={text}
|
|
class="fp-input"
|
|
role="combobox"
|
|
aria-label={label}
|
|
aria-expanded={open}
|
|
aria-autocomplete="list"
|
|
placeholder={auto}
|
|
spellcheck="false"
|
|
autocomplete="off"
|
|
onfocus={(e) => { show(); e.currentTarget.select(); }}
|
|
onclick={(e) => { if (!open) show(); e.currentTarget.select(); }}
|
|
oninput={() => { open = true; active = 0; }}
|
|
onblur={() => { if (open) commitTyped(); }}
|
|
{onkeydown}
|
|
/>
|
|
<button class="fp-chev" tabindex="-1" aria-label="Show folders" onmousedown={(e) => { e.preventDefault(); open ? (open = false) : (input.focus(), show()); }}>
|
|
<Icon name="chev" />
|
|
</button>
|
|
</div>
|
|
|
|
{#if open}
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div class="pop fp-list" role="listbox" style="left:{at.left}px;{at.top !== null ? `top:${at.top}px;` : `bottom:${at.bottom}px;`}width:{at.width}px;max-height:{at.max}px" class:up={at.top === null} onmousedown={(e) => e.preventDefault()}>
|
|
{#each rows as row, i (row.kind + (row.p ? row.p.id : row.id || ''))}
|
|
{#if row.kind === 'auto'}
|
|
<button class="mi" class:act={i === active} role="option" aria-selected={!value} onmousemove={() => (active = i)} onclick={() => choose(row)}>
|
|
<Icon name="sparkle" /><span class="mi-label">Find automatically</span><em>{auto}</em>{#if !value}<Icon name="tick" />{/if}
|
|
</button>
|
|
{:else if row.kind === 'page'}
|
|
{@const [a, m, b] = mark(row.p.title)}
|
|
<button class="mi" class:act={i === active} role="option" aria-selected={value === row.p.id} onmousemove={() => (active = i)} onclick={() => choose(row)}>
|
|
<Icon name={iconOf(row.p)} /><span class="mi-label">{a}{#if m}<mark>{m}</mark>{/if}{b}</span>{#if row.p.parent}<em>{row.p.parent}</em>{/if}{#if value === row.p.id}<Icon name="tick" />{/if}
|
|
</button>
|
|
{:else}
|
|
<button class="mi" class:act={i === active} role="option" aria-selected="false" onmousemove={() => (active = i)} onclick={() => choose(row)}>
|
|
<Icon name="plus" /><span class="mi-label">Use “{row.id}”</span><em>new folder</em>
|
|
</button>
|
|
{/if}
|
|
{:else}
|
|
<p class="fp-none">No pages yet.</p>
|
|
{/each}
|
|
</div>
|
|
{/if}
|