feat(ui): components, routes and styles
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>
This commit is contained in:
1 parent
c0587cb267
commit
27fafa0bcd
39 files changed
+8744
-94
No files matched your search
@@ -0,0 +1,139 @@
|
||||
<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}
|
||||
Reference in new issue
Block a user