From 5732398e601b0c0d534bbdbcbf791f2393ba25c3 Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 13 Jun 2026 01:05:44 +0200 Subject: [PATCH] feat(rennmaeuse): Tiere-Liste neu im Rennmaus-Filter-Design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filter-UX aus dem Design: - Quick-Chip-Kategorien (Status/Geschlecht/Farbschlag/Herkunft) öffnen ein Bottom-Sheet mit Mehrfachauswahl (suchbar bei Farbschlag/Herkunft); aktive Filter als entfernbare Chips; Sortierung + "Externe Ahnen" als gleich große Chips (Sortierung öffnet dasselbe Drawer). - Neue zweizeilige Tierkarte (Name + Geschlechts-Kreis + Status-Badge, Meta-Zeile), cardIn/Chip/Sheet-Animationen, Titel blendet beim Scrollen aus. App-weit: - Design-Palette (Creme/Braun/Terrakotta), Hanken-Grotesk-Schrift und gemeinsamer --shadow-Token; obere "Rennmaus-Manager"-Titelleiste entfernt (mobil bleibt die untere Aktionsleiste); fixe Shell-Höhe, nur der Hauptbereich scrollt. Funktion: - Infinite Scroll via useInfiniteList (seitenweise) + IntersectionObserver-Sentinel. - Namensgenerator: Option männlich/weiblich/egal. - Mehrfach-OR-Filter über gridify orGroup; e2e an die neue UI angepasst. Co-Authored-By: Claude Opus 4.8 (1M context) --- gerbil-manager-web/e2e/bestand.spec.ts | 7 +- gerbil-manager-web/e2e/filter-panel.spec.ts | 91 ++-- gerbil-manager-web/e2e/fixtures.ts | 12 + gerbil-manager-web/e2e/navigation.spec.ts | 2 +- gerbil-manager-web/e2e/status-model.spec.ts | 6 +- gerbil-manager-web/e2e/tiere.spec.ts | 12 +- gerbil-manager-web/index.html | 7 + gerbil-manager-web/src/api/gridify.ts | 17 + .../src/components/AppShell.tsx | 5 - .../src/components/GerbilFilterBar.tsx | 271 +++++++++++ .../src/components/NameSuggestPanel.css | 33 ++ .../src/components/NameSuggestPanel.tsx | 32 +- .../src/components/gerbilFilterBar.css | 427 ++++++++++++++++++ gerbil-manager-web/src/hooks/useApi.ts | 2 +- .../src/hooks/useInfiniteList.ts | 116 +++++ gerbil-manager-web/src/index.css | 83 ++-- gerbil-manager-web/src/pages/GerbilsPage.tsx | Bin 10992 -> 13478 bytes gerbil-manager-web/src/pages/gerbils.css | 262 ++++++++++- gerbil-manager-web/src/strings/de.ts | 18 + 19 files changed, 1254 insertions(+), 149 deletions(-) create mode 100644 gerbil-manager-web/src/components/GerbilFilterBar.tsx create mode 100644 gerbil-manager-web/src/components/gerbilFilterBar.css create mode 100644 gerbil-manager-web/src/hooks/useInfiniteList.ts diff --git a/gerbil-manager-web/e2e/bestand.spec.ts b/gerbil-manager-web/e2e/bestand.spec.ts index dc7dc59..8d49510 100644 --- a/gerbil-manager-web/e2e/bestand.spec.ts +++ b/gerbil-manager-web/e2e/bestand.spec.ts @@ -1,5 +1,5 @@ /** BESTAND-FILTER: die Tiere-Liste zeigt standardmäßig nur den eigenen Bestand. */ -import { de, expect, gotoSection, openFilterPanel, skipUnlessMock, test } from './fixtures' +import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures' const t = de.pages.gerbils @@ -19,9 +19,8 @@ test('Toggle „Externe Ahnen einblenden” zeigt externe Tiere mit Extern-Marki await gotoSection(page, de.nav.gerbils) await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible() - // UX-MOBILE-1: Checkbox liegt im Filter-Drawer — auf Mobil erst öffnen. - await openFilterPanel(page) - await page.getByRole('checkbox', { name: t.filters.showExternal }).check() + // DESIGN (Rennmaus Filter): „Externe Ahnen" ist ein Toggle-Chip (Button), kein Checkbox. + await page.getByRole('button', { name: t.filters.showExternalShort }).click() const maxRow = page.locator('.gerbil-row', { hasText: 'Max' }) await expect(maxRow).toBeVisible() diff --git a/gerbil-manager-web/e2e/filter-panel.spec.ts b/gerbil-manager-web/e2e/filter-panel.spec.ts index 7ba4976..4831195 100644 --- a/gerbil-manager-web/e2e/filter-panel.spec.ts +++ b/gerbil-manager-web/e2e/filter-panel.spec.ts @@ -1,85 +1,54 @@ /** - * UX-MOBILE-1: FilterPanel — einklappbare Filter auf Smartphone, inline auf Desktop. + * DESIGN (Rennmaus Filter.html): Quick-Chips-Filter der Rennmäuse-Liste. * - * Telefon (390px): Filter-Button sichtbar, Drawer eingeklappt; Tippen öffnet/schliesst. - * Desktop (1280px): Alle Controls direkt sichtbar, kein Toggle-Button. + * Eine Pillen-Reihe (Status / Geschlecht / Farbschlag / Herkunft) öffnet je ein + * Bottom-Sheet mit Mehrfachauswahl; gewählte Werte erscheinen als entfernbare + * Chips, „Alle löschen" setzt zurück. Mobile-first; identisches Verhalten auf Desktop. */ -import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures' +import { de, expect, gotoSection, selectListFilter, skipUnlessMock, test } from './fixtures' const t = de.pages.gerbils -test.describe('FilterPanel – Rennmäuse-Liste', () => { - test('Phone: Filter-Drawer standardmäßig eingeklappt, Toggle-Button sichtbar', async ({ - page, - }, testInfo) => { +test.describe('Quick-Chips-Filter – Rennmäuse-Liste', () => { + test('Kategorie-Pillen sind sichtbar', async ({ page }) => { skipUnlessMock() - if (testInfo.project.name !== 'phone') return - await gotoSection(page, de.nav.gerbils) await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible() - // Toggle-Button sichtbar. - const toggle = page.locator('.filter-panel__toggle') - await expect(toggle).toBeVisible() - - // Status-Beschriftung im Drawer ist noch verborgen. - await expect(page.getByText(t.filters.status, { exact: true }).first()).not.toBeVisible() + const quickrow = page.locator('.rmf-quickrow') + await expect(quickrow.getByRole('button', { name: t.filters.status })).toBeVisible() + await expect(quickrow.getByRole('button', { name: t.filters.gender })).toBeVisible() + await expect(quickrow.getByRole('button', { name: t.filters.colorVariety })).toBeVisible() + await expect(quickrow.getByRole('button', { name: t.fields.origin })).toBeVisible() }) - test('Phone: Toggle öffnet und schließt den Filter-Drawer', async ({ + test('Mehrfachauswahl erzeugt entfernbare Chips, „Alle löschen" setzt zurück', async ({ page, - }, testInfo) => { + }) => { skipUnlessMock() - if (testInfo.project.name !== 'phone') return - await gotoSection(page, de.nav.gerbils) - const toggle = page.locator('.filter-panel__toggle') - // Öffnen → Status-Feld wird sichtbar. - await toggle.click() - await expect(page.getByText(t.filters.status, { exact: true }).first()).toBeVisible() + // Default: Zucht-Chip ist aktiv. + await expect(page.locator('.rmf-chip-val', { hasText: t.statusLabels.Breeding })).toBeVisible() - // Schließen → wieder verborgen. - await toggle.click() - await expect(page.getByText(t.filters.status, { exact: true }).first()).not.toBeVisible() + // Geschlecht = männlich über das Sheet wählen → zweiter Chip erscheint. + await selectListFilter(page, t.filters.gender, t.genderLabels.male) + await expect(page.locator('.rmf-chip-val', { hasText: t.genderLabels.male })).toBeVisible() + + // „Alle löschen" entfernt sämtliche Chips. + await page.getByRole('button', { name: de.filterBar.clearAll }).click() + await expect(page.locator('.rmf-chip')).toHaveCount(0) }) - test('Phone: Badge zählt aktive Filter korrekt', async ({ - page, - }, testInfo) => { + test('Einzelner Chip lässt sich per Klick entfernen', async ({ page }) => { skipUnlessMock() - if (testInfo.project.name !== 'phone') return - await gotoSection(page, de.nav.gerbils) - const toggle = page.locator('.filter-panel__toggle') - // Initial: Status='Active' ist Default → Badge zeigt kein „(N)". - await expect(toggle).toHaveText('Filter') - - // Filter-Drawer öffnen und Geschlecht setzen → 1 aktiver Filter. - await toggle.click() - await page.locator('.filter-panel__drawer select').nth(1).selectOption('male') - await expect(toggle).toHaveText('Filter (1)') - - // Zurücksetzen → Badge weg. - await page.getByRole('button', { name: de.filterPanel.resetButton }).click() - await expect(toggle).toHaveText('Filter') - }) - - test('Desktop: Toggle-Button nicht sichtbar, alle Controls inline', async ({ - page, - }, testInfo) => { - skipUnlessMock() - if (testInfo.project.name !== 'desktop') return - - await gotoSection(page, de.nav.gerbils) - await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible() - - // Kein Toggle-Button auf Desktop (display:none via Media Query). - const toggle = page.locator('.filter-panel__toggle') - await expect(toggle).toBeHidden() - - // Status-Beschriftung direkt sichtbar. - await expect(page.getByText(t.filters.status, { exact: true }).first()).toBeVisible() + const breedingChip = page.locator('.rmf-chip', { + has: page.locator('.rmf-chip-val', { hasText: t.statusLabels.Breeding }), + }) + await expect(breedingChip).toBeVisible() + await breedingChip.click() + await expect(breedingChip).toHaveCount(0) }) }) diff --git a/gerbil-manager-web/e2e/fixtures.ts b/gerbil-manager-web/e2e/fixtures.ts index a1715cc..35e870d 100644 --- a/gerbil-manager-web/e2e/fixtures.ts +++ b/gerbil-manager-web/e2e/fixtures.ts @@ -86,6 +86,18 @@ export async function openFilterPanel(page: Page) { } } +/** + * DESIGN (Rennmaus Filter): wählt einen Wert im Quick-Chips-Filter der Rennmäuse-Liste. + * Öffnet das Bottom-Sheet der Kategorie, hakt die Option an und schließt mit „Fertig". + */ +export async function selectListFilter(page: Page, categoryLabel: string, optionLabel: string) { + // Pille der Kategorie in der Quick-Chips-Reihe öffnen (nicht den aktiven Chip treffen). + await page.locator('.rmf-quickrow').getByRole('button', { name: categoryLabel }).click() + const sheet = page.getByRole('dialog', { name: categoryLabel }) + await sheet.getByRole('button', { name: optionLabel, exact: true }).click() + await sheet.getByRole('button', { name: de.filterBar.done }).click() +} + /** Eindeutiger Name für LIVE-taugliche Create-Flows. */ export const uniqueName = (prefix: string) => `${prefix} E2E ${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` diff --git a/gerbil-manager-web/e2e/navigation.spec.ts b/gerbil-manager-web/e2e/navigation.spec.ts index 9d9b6a2..e90dbef 100644 --- a/gerbil-manager-web/e2e/navigation.spec.ts +++ b/gerbil-manager-web/e2e/navigation.spec.ts @@ -3,7 +3,7 @@ import { de, expect, gotoSection, test } from './fixtures' test('alle Bereiche sind über die Navigation erreichbar', async ({ page }) => { await page.goto('/') - await expect(page.getByRole('heading', { name: de.app.title })).toBeVisible() + // DESIGN: the top "Rennmaus-Manager" title bar was removed — the page heading remains. await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible() await gotoSection(page, de.nav.gerbils) diff --git a/gerbil-manager-web/e2e/status-model.spec.ts b/gerbil-manager-web/e2e/status-model.spec.ts index 9924b9a..7a26961 100644 --- a/gerbil-manager-web/e2e/status-model.spec.ts +++ b/gerbil-manager-web/e2e/status-model.spec.ts @@ -37,10 +37,8 @@ test('Tier-Formular zeigt abgeleiteten Status Verstorben als Text + Hinweis', as test('Tiere-Liste zeigt Zucht als Standard-Status-Filter', async ({ page }) => { skipUnlessMock() await page.goto('/rennmaeuse') - const statusSelect = page - .locator('label.field', { has: page.locator(`span:text-is("${t.filters.status}")`) }) - .locator('select') - await expect(statusSelect).toHaveValue('Breeding') + // DESIGN (Rennmaus Filter): der Default-Status erscheint als aktiver, entfernbarer Chip. + await expect(page.locator('.rmf-chip-val', { hasText: t.statusLabels.Breeding })).toBeVisible() }) test('Neues Tier anlegen mit Status Liebhaber', async ({ page }) => { diff --git a/gerbil-manager-web/e2e/tiere.spec.ts b/gerbil-manager-web/e2e/tiere.spec.ts index 30868d5..18304c0 100644 --- a/gerbil-manager-web/e2e/tiere.spec.ts +++ b/gerbil-manager-web/e2e/tiere.spec.ts @@ -1,5 +1,5 @@ /** QA-1: Tiere (Rennmäuse) — Liste/Suche, CRUD, Detail-Tabs (FEAT-1 + FEAT-6). */ -import { de, expect, gotoSection, openFilterPanel, skipUnlessMock, test, uniqueName } from './fixtures' +import { de, expect, gotoSection, selectListFilter, skipUnlessMock, test, uniqueName } from './fixtures' const t = de.pages.gerbils const tabs = de.pages.tierTabs @@ -23,13 +23,9 @@ test('Herkunft-Filter (originBreeder) zeigt nur Tiere der gewählten Zucht (SEAR await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible() await expect(page.getByRole('link', { name: /Fridolin/ })).toBeVisible() - // UX-MOBILE-1: Herkunft-Select liegt im Filter-Drawer — auf Mobil erst öffnen. - await openFilterPanel(page) - // Herkunft (originBreeder) auf die Seed-Zucht 'Clan-Kleine-Chaoten' (nur Krümel). - await page - .locator('label.field', { has: page.locator(`span:text-is("${t.fields.origin}")`) }) - .locator('select') - .selectOption({ label: 'Clan-Kleine-Chaoten' }) + // DESIGN (Rennmaus Filter): Herkunft (originBreeder) über das Quick-Chips-Sheet + // auf die Seed-Zucht 'Clan-Kleine-Chaoten' (nur Krümel) filtern. + await selectListFilter(page, t.fields.origin, 'Clan-Kleine-Chaoten') await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible() await expect(page.getByRole('link', { name: /Fridolin/ })).toBeHidden() diff --git a/gerbil-manager-web/index.html b/gerbil-manager-web/index.html index ca4ca6a..6cd7487 100644 --- a/gerbil-manager-web/index.html +++ b/gerbil-manager-web/index.html @@ -5,6 +5,13 @@ + + + + Rennmaus-Manager diff --git a/gerbil-manager-web/src/api/gridify.ts b/gerbil-manager-web/src/api/gridify.ts index cbb1c0c..b7391de 100644 --- a/gerbil-manager-web/src/api/gridify.ts +++ b/gerbil-manager-web/src/api/gridify.ts @@ -61,6 +61,23 @@ export function andFilter(...conditions: Array Boolean(c)).join(',') } +/** + * Build an OR-group of conditions for one field, e.g. `(status=Breeding|status=ForSale)`. + * Used for multi-select filters: several allowed values within one category are OR'd, + * while categories are AND'd together by {@link andFilter}. + * Returns '' for an empty value list (so andFilter drops it); a single value needs no parens. + */ +export function orGroup( + field: string, + values: ReadonlyArray, + op: GridifyOp = '==', +): string { + const parts = values.map((value) => condition({ field, op, value })) + if (parts.length === 0) return '' + if (parts.length === 1) return parts[0] + return `(${parts.join('|')})` +} + /** Assemble the URL query string ("?...") from a GridifyQuery. */ export function toQueryString(q: GridifyQuery): string { const params = new URLSearchParams() diff --git a/gerbil-manager-web/src/components/AppShell.tsx b/gerbil-manager-web/src/components/AppShell.tsx index 06b2c93..22dea28 100644 --- a/gerbil-manager-web/src/components/AppShell.tsx +++ b/gerbil-manager-web/src/components/AppShell.tsx @@ -47,11 +47,6 @@ export default function AppShell() { return (
-
- -

{de.app.title}

-
-