Replace native <select> filters with a fully custom-styled dropdown

A native <select>'s open options popup is rendered by the browser/OS
and can't be styled at all — wrong font size, wrong colors, no brand
styling whatsoever, reported as looking completely off-brand. New
CustomSelect.tsx renders both the trigger and the options list as
plain styled HTML (button + role="listbox" panel, keyboard nav,
click-outside-to-close) instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-30 22:13:51 +00:00
parent 475ee3fa16
commit 1d91a3f21c
2 changed files with 142 additions and 38 deletions
@@ -0,0 +1,125 @@
"use client";
import { useEffect, useRef, useState } from "react";
type Option = { value: string; label: string };
// A fully custom-styled dropdown — a native <select>'s trigger box can be
// styled, but its open options popup is rendered by the browser/OS itself
// and can't be reached with CSS at all (wrong font size, wrong colors, no
// brand styling whatsoever). This renders both the trigger and the
// options panel as plain HTML we control end to end.
export function CustomSelect({
label,
options,
value,
onChange,
}: {
/** Screen-reader label and the "not selected" trigger text. */
label: string;
options: Option[];
value: string;
onChange: (value: string) => void;
}) {
const [open, setOpen] = useState(false);
const [highlighted, setHighlighted] = useState(0);
const rootRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const allOptions: Option[] = [{ value: "", label }, ...options];
const selectedIndex = Math.max(
0,
allOptions.findIndex((o) => o.value === value),
);
const selectedLabel = allOptions[selectedIndex]?.label ?? label;
useEffect(() => {
if (!open) return;
setHighlighted(selectedIndex);
function onClickOutside(e: MouseEvent) {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", onClickOutside);
return () => document.removeEventListener("mousedown", onClickOutside);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
useEffect(() => {
if (!open) return;
listRef.current?.querySelector<HTMLElement>(`[data-index="${highlighted}"]`)?.scrollIntoView({ block: "nearest" });
}, [open, highlighted]);
function select(index: number) {
onChange(allOptions[index].value);
setOpen(false);
}
function onKeyDown(e: React.KeyboardEvent) {
if (!open) {
if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
e.preventDefault();
setOpen(true);
}
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
setHighlighted((i) => Math.min(i + 1, allOptions.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlighted((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
select(highlighted);
} else if (e.key === "Escape") {
e.preventDefault();
setOpen(false);
}
}
return (
<div ref={rootRef} className="relative w-full sm:w-auto">
<button
type="button"
aria-haspopup="listbox"
aria-expanded={open}
aria-label={label}
onClick={() => setOpen((v) => !v)}
onKeyDown={onKeyDown}
className={`flex items-center justify-between gap-2 w-full sm:w-auto min-w-[10rem] border rounded-sm px-3 py-2 text-body-sm transition-colors outline-none ${
value ? "border-brand text-text-primary" : "border-border text-text-muted"
} hover:border-brand focus-visible:border-brand`}
>
<span className="truncate">{selectedLabel}</span>
<svg width="10" height="6" viewBox="0 0 10 6" fill="none" className={`shrink-0 transition-transform ${open ? "rotate-180" : ""}`}>
<path d="M1 1L5 5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{open && (
<ul
ref={listRef}
role="listbox"
aria-label={label}
className="absolute z-20 mt-1 w-full sm:min-w-[12rem] max-h-64 overflow-y-auto bg-bg-base border border-border rounded-sm shadow-lg py-1"
>
{allOptions.map((o, i) => (
<li
key={o.value || "__all__"}
data-index={i}
role="option"
aria-selected={i === selectedIndex}
onMouseEnter={() => setHighlighted(i)}
onClick={() => select(i)}
className={`px-3 py-2 text-body-sm cursor-pointer transition-colors ${
i === selectedIndex ? "font-semibold text-brand" : "text-text-primary"
} ${i === highlighted ? "bg-bg-muted" : ""}`}
>
{o.label}
</li>
))}
</ul>
)}
</div>
);
}
@@ -1,14 +1,20 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { CustomSelect } from "./CustomSelect";
// Three native <select>s in a row instead of a wall of filter chips (tried
// first, reverted 2026-07-30 — with 7 status options + 5 payment-status
// options + N years, a chip per option read as cluttered and ate a lot of
// vertical space). Client Component only for the onChange→navigate wiring;
// the actual filtering still happens server-side in page.tsx via the same
// URL search params, so this stays a plain GET-style filter (shareable/
// bookmarkable/back-button-safe), not client-side state.
// Three custom-styled dropdowns in a row instead of a wall of filter
// chips (tried first, reverted 2026-07-30 — with 7 status options + 5
// payment-status options + N years, a chip per option read as cluttered
// and ate a lot of vertical space) or plain native <select>s (tried next,
// also reverted the same day — a native <select>'s open options popup is
// rendered by the browser/OS and can't be styled at all, so it looked
// completely off-brand next to everything else on the page; see
// CustomSelect.tsx for the fully custom-styled replacement). Client
// Component only for the onChange→navigate wiring; the actual filtering
// still happens server-side in page.tsx via the same URL search params,
// so this stays a plain GET-style filter (shareable/bookmarkable/
// back-button-safe), not client-side state.
export function OrderFilters({
statusOptions,
paymentStatusOptions,
@@ -34,40 +40,13 @@ export function OrderFilters({
router.push(qs ? `/konto/bestellungen?${qs}` : "/konto/bestellungen");
}
const selectClass =
"w-full sm:w-auto min-w-0 border border-border rounded-sm px-3 py-2 text-body-sm text-text-primary bg-bg-base outline-none focus:border-brand transition-colors";
const yearOptions = years.map((y) => ({ value: y, label: y }));
return (
<div className="flex flex-col sm:flex-row sm:items-center gap-3 w-full">
<select aria-label="Nach Fulfillment-Status filtern" className={selectClass} value={status} onChange={(e) => setParam("status", e.target.value)}>
<option value="">Alle Status</option>
{statusOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<select
aria-label="Nach Zahlungsstatus filtern"
className={selectClass}
value={paymentStatus}
onChange={(e) => setParam("paymentStatus", e.target.value)}
>
<option value="">Alle Zahlungsstatus</option>
{paymentStatusOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<select aria-label="Nach Jahr filtern" className={selectClass} value={year} onChange={(e) => setParam("year", e.target.value)}>
<option value="">Alle Jahre</option>
{years.map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
<CustomSelect label="Alle Status" options={statusOptions} value={status} onChange={(v) => setParam("status", v)} />
<CustomSelect label="Alle Zahlungsstatus" options={paymentStatusOptions} value={paymentStatus} onChange={(v) => setParam("paymentStatus", v)} />
<CustomSelect label="Alle Jahre" options={yearOptions} value={year} onChange={(v) => setParam("year", v)} />
{hasAnyFilter && (
<button
type="button"