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 (