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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user