feat(mana/web): app picker — autofocused search + alphabetical order

The "App hinzufügen" picker now sorts available apps alphabetically by
their (i18n-resolved) display name and shows an autofocused search input
above the list to filter quickly. Enter selects the first match.
PickerOverlay gains a `subheader` snippet slot so other pickers can
embed their own controls between header and list without having to
re-implement the shell.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-09 14:37:04 +02:00
parent 41c705a303
commit ab0ca99239
2 changed files with 81 additions and 3 deletions

View file

@ -20,6 +20,9 @@
items: TItem[];
onClose: () => void;
item: Snippet<[TItem, number]>;
/** Optional snippet rendered between the header and the list
* (e.g. a search input). */
subheader?: Snippet;
/** Optional snippet rendered after the items (e.g. "create custom" button). */
footer?: Snippet;
emptyLabel?: string;
@ -32,6 +35,7 @@
items,
onClose,
item,
subheader,
footer,
emptyLabel = 'Keine Einträge',
width = '320px',
@ -45,6 +49,11 @@
<X size={16} />
</button>
</div>
{#if subheader}
<div class="picker-subheader">
{@render subheader()}
</div>
{/if}
<div class="picker-list">
{#each items as entry, i}
{#if i > 0}<div class="divider"></div>{/if}
@ -116,6 +125,11 @@
color: hsl(var(--color-foreground));
}
.picker-subheader {
padding: 0 1rem 0.5rem;
flex-shrink: 0;
}
.picker-list {
flex: 1;
overflow-y: auto;

View file

@ -3,6 +3,8 @@
-->
<script lang="ts">
import { _ } from 'svelte-i18n';
import { tick } from 'svelte';
import { MagnifyingGlass } from '@mana/shared-icons';
import PickerOverlay from '$lib/components/PickerOverlay.svelte';
import { getAccessibleApps } from '$lib/app-registry';
@ -23,12 +25,35 @@
let { onSelect, onClose, activeAppIds = [], userTier = null }: Props = $props();
let query = $state('');
let searchInput = $state<HTMLInputElement | null>(null);
// Filter twice: tier-gate first (so guests + public users don't see
// founder/alpha/beta apps at all), then drop apps that are already
// open in the current scene.
// open in the current scene. Sort alphabetically by the displayed
// (i18n-resolved) name, then apply the search query.
let availableApps = $derived(
getAccessibleApps(userTier).filter((app) => !activeAppIds.includes(app.id))
getAccessibleApps(userTier)
.filter((app) => !activeAppIds.includes(app.id))
.map((app) => ({ app, displayName: appName(app.id, app.name) }))
.sort((a, b) => a.displayName.localeCompare(b.displayName, 'de'))
.filter(({ displayName }) =>
query.trim() === '' ? true : displayName.toLowerCase().includes(query.trim().toLowerCase())
)
.map(({ app }) => app)
);
// Auto-focus the search input when the picker opens.
$effect(() => {
tick().then(() => searchInput?.focus());
});
function handleSearchKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && availableApps.length > 0) {
e.preventDefault();
onSelect(availableApps[0].id);
}
}
</script>
<PickerOverlay
@ -36,8 +61,21 @@
items={availableApps}
{onClose}
width="300px"
emptyLabel="Alle Apps sind bereits geöffnet"
emptyLabel={query.trim() === '' ? 'Alle Apps sind bereits geöffnet' : 'Keine Treffer'}
>
{#snippet subheader()}
<div class="search-wrap">
<MagnifyingGlass size={14} />
<input
bind:this={searchInput}
bind:value={query}
type="text"
placeholder="Suchen…"
class="search-input"
onkeydown={handleSearchKeydown}
/>
</div>
{/snippet}
{#snippet item(app)}
<button class="picker-option" onclick={() => onSelect(app.id)}>
<div class="app-dot" style="background-color: {app.color}"></div>
@ -47,6 +85,32 @@
</PickerOverlay>
<style>
.search-wrap {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.625rem;
border-radius: 0.375rem;
background: hsl(var(--color-muted) / 0.6);
color: hsl(var(--color-muted-foreground));
transition: background 0.15s;
}
.search-wrap:focus-within {
background: hsl(var(--color-muted));
}
.search-input {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
}
.search-input::placeholder {
color: hsl(var(--color-muted-foreground));
}
:global(.picker .app-dot) {
width: 10px;
height: 10px;