feat(filter): Draft-Auswahl mit OK/Abbrechen-Buttons
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m17s

Der Filter-Dropdown sammelt Checkbox-Klicks jetzt nur noch lokal und
wendet sie erst beim „OK"-Klick auf den Store an. Solange der User
herumklickt, läuft die aktive Suche unverändert weiter. Abbrechen (per
Button, Klick außerhalb oder Escape) verwirft die Draft-Auswahl.

- Neuer searchFilterStore.commit(Set) für One-Shot-Apply (triggert den
  active-$effect nur ein einziges Mal).
- „Alle"-Quick-Action setzt draft = alle Domains explizit; erst beim
  Commit wird das wieder in die leere Menge überführt, damit neu
  freigeschaltete Admin-Domains weiterhin automatisch dabei sind.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-18 08:34:43 +02:00
parent 2e196b4834
commit 52858f94fe
2 changed files with 105 additions and 32 deletions

View File

@@ -62,6 +62,14 @@ class SearchFilterStore {
this.persist();
}
// Übernimmt eine vorbereitete Draft-Auswahl auf einmal — wird vom
// Filter-Dropdown genutzt, der Toggles erst lokal sammelt und erst beim
// „OK"-Klick committet. Triggert den active-$effect nur ein einziges Mal.
commit(next: Set<string>): void {
this.active = next;
this.persist();
}
// True wenn der User die Suche eingeschränkt hat (mindestens eine aber nicht alle).
get isFiltered(): boolean {
return this.active.size > 0 && this.active.size < this.domains.length;

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { SlidersHorizontal, Check, ChevronDown } from 'lucide-svelte';
import { SlidersHorizontal, Check, X, ChevronDown } from 'lucide-svelte';
import { searchFilterStore } from '$lib/client/search-filter.svelte';
// inline: Button wird transparent und ohne eigenen Border gestylt,
@@ -9,16 +9,51 @@
let open = $state(false);
let container: HTMLElement | undefined = $state();
function toggle() {
open = !open;
// Draft-Auswahl: wird beim Öffnen vom Store initialisiert und nur bei „OK"
// in den Store committet. Dadurch bleibt die laufende Suche unangetastet,
// solange der User im Menu herumklickt, und ein versehentlicher Klick
// daneben verwirft die Auswahl (statt sie halbfertig anzuwenden).
let draft = $state<Set<string>>(new Set());
function snapshotActive(): Set<string> {
// Leere Menge heißt im Store „alle aktiv". Für die Draft machen wir
// das explizit, damit toggle() ein vorhersehbares Verhalten hat.
if (searchFilterStore.active.size === 0) {
return new Set(searchFilterStore.domains.map((d) => d.domain));
}
return new Set(searchFilterStore.active);
}
function openMenu() {
draft = snapshotActive();
open = true;
}
function cancel() {
open = false;
}
function apply() {
// Wenn alle gewählt sind, speichern wir die leere Menge — damit sind
// neu zur Whitelist hinzugefügte Domains automatisch dabei.
const allSelected =
draft.size === searchFilterStore.domains.length &&
searchFilterStore.domains.every((d) => draft.has(d.domain));
searchFilterStore.commit(allSelected ? new Set() : draft);
open = false;
}
function toggleTrigger() {
if (open) cancel();
else openMenu();
}
function handleClickOutside(e: MouseEvent) {
if (container && !container.contains(e.target as Node)) open = false;
if (container && !container.contains(e.target as Node)) cancel();
}
function handleKey(e: KeyboardEvent) {
if (e.key === 'Escape' && open) open = false;
if (e.key === 'Escape' && open) cancel();
}
$effect(() => {
@@ -32,24 +67,15 @@
}
});
function checked(domain: string): boolean {
// Leere Menge (default "alle") heißt: jeder Eintrag ist implizit aktiv.
if (searchFilterStore.active.size === 0) return true;
return searchFilterStore.active.has(domain);
function onToggleDomain(domain: string) {
const next = new Set(draft);
if (next.has(domain)) next.delete(domain);
else next.add(domain);
draft = next;
}
function onToggleDomain(domain: string) {
if (searchFilterStore.active.size === 0) {
// Aus "Alle" wird "alle außer dem abgewählten": explizite Liste mit
// allen Domains minus der einen.
const rest = searchFilterStore.domains
.map((d) => d.domain)
.filter((d) => d !== domain);
searchFilterStore.active = new Set(rest);
searchFilterStore.persist();
return;
}
searchFilterStore.toggle(domain);
function selectAllDraft() {
draft = new Set(searchFilterStore.domains.map((d) => d.domain));
}
</script>
@@ -62,7 +88,7 @@
aria-label="Suchfilter"
aria-haspopup="menu"
aria-expanded={open}
onclick={toggle}
onclick={toggleTrigger}
>
<SlidersHorizontal size={16} strokeWidth={2} />
<span class="badge">{searchFilterStore.label}</span>
@@ -73,22 +99,14 @@
<div class="menu" role="menu">
<div class="menu-head">
<span class="head-title">Gefunden auf</span>
<button
class="quick"
type="button"
onclick={() => {
searchFilterStore.selectAll();
}}
>
Alle
</button>
<button class="quick" type="button" onclick={selectAllDraft}>Alle</button>
</div>
{#if searchFilterStore.domains.length === 0}
<p class="empty">Keine Domains in der Whitelist.</p>
{:else}
<ul class="list">
{#each searchFilterStore.domains as d (d.id)}
{@const isOn = checked(d.domain)}
{@const isOn = draft.has(d.domain)}
<li>
<button
class="row"
@@ -110,6 +128,16 @@
</li>
{/each}
</ul>
<div class="menu-foot">
<button class="btn ghost" type="button" onclick={cancel}>
<X size={16} strokeWidth={2} />
<span>Abbrechen</span>
</button>
<button class="btn primary" type="button" onclick={apply}>
<Check size={16} strokeWidth={2.5} />
<span>OK</span>
</button>
</div>
{/if}
</div>
{/if}
@@ -267,6 +295,43 @@
font-size: 0.9rem;
margin: 0;
}
.menu-foot {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
padding: 0.6rem 0.5rem 0.35rem;
border-top: 1px solid #f0f3f1;
margin-top: 0.2rem;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.55rem 0.9rem;
border-radius: 8px;
border: 1px solid #cfd9d1;
background: white;
color: #1a1a1a;
cursor: pointer;
font-size: 0.92rem;
min-height: 40px;
font-family: inherit;
}
.btn.ghost {
color: #666;
}
.btn.ghost:hover {
background: #f4f8f5;
}
.btn.primary {
background: #2b6a3d;
color: white;
border-color: #2b6a3d;
font-weight: 600;
}
.btn.primary:hover {
background: #235532;
}
@media (max-width: 520px) {
.trigger {
padding: 0.5rem 0.55rem;