Files
website/src/lib/components/desktop/Launcher.svelte
T

198 lines
4.4 KiB
Svelte

<script lang="ts">
import Icon from '@iconify/svelte';
import { desktop, type DesktopAction } from '#lib/content';
import { allSitePages } from '#lib/site/pages';
import { runDesktopAction } from '#lib/desktop/actions';
import { windowManager } from '#lib/desktop/windows.svelte';
interface LauncherEntry {
label: string;
detail: string;
icon: string;
open: DesktopAction;
}
const pageEntries: LauncherEntry[] = allSitePages.map((page) => ({
label: page.title,
detail: page.path,
icon: page.icon,
open: { page: page.path }
}));
const iconEntries: LauncherEntry[] = desktop.iconGroups
.flatMap((group) => group.icons)
.filter((item) => !('page' in item.open))
.map((item) => ({
label: item.label,
detail: launcherDetail(item.open),
icon: item.icon,
open: item.open
}));
const entries = [...pageEntries, ...iconEntries];
let query = $state('');
let selectedIndex = $state(0);
let searchElement = $state<HTMLInputElement>();
const filteredEntries = $derived(
entries.filter((entry) => `${entry.label} ${entry.detail}`.toLowerCase().includes(query.toLowerCase()))
);
$effect(() => {
query;
selectedIndex = 0;
});
$effect(() => {
searchElement?.focus();
});
function close() {
windowManager.launcherOpen = false;
}
function launch(entry: LauncherEntry | undefined) {
if (entry) runDesktopAction(entry.open);
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') close();
else if (event.key === 'ArrowDown') {
event.preventDefault();
selectedIndex = Math.min(filteredEntries.length - 1, selectedIndex + 1);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
selectedIndex = Math.max(0, selectedIndex - 1);
} else if (event.key === 'Enter') launch(filteredEntries[selectedIndex]);
}
function launcherDetail(action: DesktopAction): string {
if ('terminal' in action) return `kitty ${action.terminal}`.trim();
if ('url' in action) return action.url;
if ('app' in action) return action.app;
return '';
}
</script>
<button class="backdrop" aria-label="Close launcher" onclick={close}></button>
<div class="launcher" role="dialog" aria-label="Launcher">
<div class="launcher-surface">
<label class="search">
<Icon icon="mdi:magnify" width="20" />
<input bind:this={searchElement} bind:value={query} onkeydown={handleKeydown} placeholder="Search apps and pages" />
</label>
<ul class="results">
{#each filteredEntries as entry, index (entry.label + entry.detail)}
<li>
<button class="result" class:selected={index === selectedIndex} onclick={() => launch(entry)} onmouseenter={() => (selectedIndex = index)}>
<Icon icon={entry.icon} width="20" />
<span class="result-label">{entry.label}</span>
<span class="result-detail">{entry.detail}</span>
</button>
</li>
{:else}
<li class="empty">No matches</li>
{/each}
</ul>
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
z-index: 60;
background: color-mix(in srgb, var(--dark-background) 40%, transparent);
border: none;
cursor: default;
}
.launcher {
position: fixed;
top: 18vh;
left: 50%;
z-index: 70;
width: min(640px, calc(100vw - 24px));
padding: var(--border-size);
background: var(--border-active);
transform: translateX(-50%);
}
.launcher-surface {
background: var(--background);
}
.search {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.8rem;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
color: var(--accent);
}
.search input {
flex: 1;
padding: 0;
background: none;
border: none;
outline: none;
box-shadow: none;
color: var(--foreground);
font: inherit;
}
.results {
max-height: 50vh;
margin: 0;
padding: 0.3rem;
overflow-y: auto;
list-style: none;
}
.result {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0.4rem 0.5rem;
background: none;
border: none;
text-align: left;
}
.result.selected {
background: color-mix(in srgb, var(--foreground) 15%, transparent);
color: var(--accent);
}
.result-label {
flex: 1;
}
.result-detail,
.empty {
color: var(--muted);
font-size: 0.8rem;
}
.empty {
padding: 0.5rem;
}
@media (max-width: 640px) {
.launcher {
top: calc(var(--bar-height) + 8px);
}
.results {
max-height: 60dvh;
}
.result {
padding: 0.6rem 0.5rem;
}
}
</style>