feat(geschichten): chip-row UI for multi-person AND filter
The /geschichten list page now renders one removable chip per active person filter and lets users add more via the existing typeahead. The URL uses repeated ?personId= params (matching the documents tag filter), which the regenerated API client passes straight through to the backend's new array-bound endpoint. New translation keys cover the chip remove aria-label, the AND hint shown while picking, and the multi-person empty state.
This commit is contained in:
@@ -10,22 +10,32 @@ let { data }: { data: PageData } = $props();
|
||||
|
||||
let showPersonPicker = $state(false);
|
||||
|
||||
const filterName = $derived(data.personFilter?.displayName ?? '');
|
||||
const selectedPersonIds = $derived(data.personFilters.map((p) => p.id!));
|
||||
const hasFilters = $derived(data.personFilters.length > 0 || !!data.documentFilter);
|
||||
|
||||
function clearFilter() {
|
||||
function rebuildUrl(personIds: string[]) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('personId');
|
||||
url.searchParams.delete('documentId');
|
||||
goto(url.pathname + url.search, { replaceState: true });
|
||||
for (const id of personIds) url.searchParams.append('personId', id);
|
||||
return url.pathname + url.search;
|
||||
}
|
||||
|
||||
function pickPerson(personId: string) {
|
||||
if (!personId) return;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('personId', personId);
|
||||
url.searchParams.delete('documentId');
|
||||
function clearAll() {
|
||||
goto(rebuildUrl([]), { replaceState: true });
|
||||
}
|
||||
|
||||
function addPerson(personId: string) {
|
||||
if (!personId || selectedPersonIds.includes(personId)) {
|
||||
showPersonPicker = false;
|
||||
return;
|
||||
}
|
||||
showPersonPicker = false;
|
||||
goto(url.pathname + url.search);
|
||||
goto(rebuildUrl([...selectedPersonIds, personId]));
|
||||
}
|
||||
|
||||
function removePerson(personId: string) {
|
||||
goto(rebuildUrl(selectedPersonIds.filter((id) => id !== personId)));
|
||||
}
|
||||
|
||||
function authorName(g: { author?: { firstName?: string; lastName?: string; email: string } }) {
|
||||
@@ -58,33 +68,34 @@ function publishedAt(g: { publishedAt?: string }): string | null {
|
||||
<div class="mb-6 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={!data.personFilter}
|
||||
onclick={clearFilter}
|
||||
aria-pressed={!hasFilters}
|
||||
onclick={clearAll}
|
||||
class="inline-flex h-9 items-center rounded-full border border-line px-3 font-sans text-xs font-bold tracking-wider text-ink-2 uppercase hover:bg-muted aria-pressed:bg-ink aria-pressed:text-primary-fg"
|
||||
>
|
||||
{m.geschichten_filter_all_pill()}
|
||||
</button>
|
||||
|
||||
{#if data.personFilter}
|
||||
{#each data.personFilters as p (p.id)}
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed="true"
|
||||
onclick={clearFilter}
|
||||
aria-label={m.geschichten_filter_remove_chip({ name: p.displayName })}
|
||||
onclick={() => removePerson(p.id!)}
|
||||
class="inline-flex h-9 items-center gap-2 rounded-full bg-ink px-3 font-sans text-xs font-bold tracking-wider text-primary-fg uppercase"
|
||||
>
|
||||
{filterName}
|
||||
{p.displayName}
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={m.geschichten_filter_aria_label()}
|
||||
onclick={() => (showPersonPicker = !showPersonPicker)}
|
||||
class="inline-flex h-9 items-center rounded-full border border-line px-3 font-sans text-xs font-bold tracking-wider text-ink-2 uppercase hover:bg-muted"
|
||||
>
|
||||
+ {m.geschichten_filter_choose_person()}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={showPersonPicker}
|
||||
onclick={() => (showPersonPicker = !showPersonPicker)}
|
||||
class="inline-flex h-9 items-center rounded-full border border-line px-3 font-sans text-xs font-bold tracking-wider text-ink-2 uppercase hover:bg-muted"
|
||||
>
|
||||
+ {m.geschichten_filter_choose_person()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showPersonPicker}
|
||||
@@ -93,16 +104,24 @@ function publishedAt(g: { publishedAt?: string }): string | null {
|
||||
name="filter-person"
|
||||
label={m.geschichten_filter_choose_person()}
|
||||
compact
|
||||
onchange={pickPerson}
|
||||
autofocus
|
||||
onchange={addPerson}
|
||||
/>
|
||||
{#if selectedPersonIds.length > 1}
|
||||
<p class="mt-1 font-sans text-xs text-ink-3">
|
||||
{m.geschichten_filter_and_hint()}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Card list -->
|
||||
{#if data.geschichten.length === 0}
|
||||
<div class="rounded border border-line bg-surface p-6 text-center font-sans text-sm text-ink-3">
|
||||
{#if data.personFilter}
|
||||
{m.geschichten_empty_for_person({ name: filterName })}
|
||||
{#if data.personFilters.length > 0}
|
||||
{m.geschichten_empty_for_persons({
|
||||
names: data.personFilters.map((p) => p.displayName).join(' & ')
|
||||
})}
|
||||
{:else}
|
||||
{m.geschichten_empty_no_filter()}
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user