Files
Marco bf6c5d079a Fix order-filter dropdowns, restructure Konto identity line, drop blog reset link
- CustomSelect.tsx: mousedown on an option blurred the trigger button
  before the click landed, unmounting the list before anything could be
  selected — add preventDefault on the list's mousedown to stop that.
- KontoShell no longer renders "Eingeloggt als ..." above each page's
  content; each page renders its own title then AccountIdentity right
  below it, and ProfileForm's now-duplicate email/Kundennummer line is
  removed.
- Blog category filter: drop the separate "Zurücksetzen" link — clicking
  an active category badge again already deactivates it.
- Werkzeuge cards: more vertical gap between stacked cards below sm:.
- Legal pages' "Stand: ..." line is now derived from the LegalPages doc's
  own updatedAt instead of a hand-typed string.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018unaXmuzVA8ct1b6WoyP1U
2026-07-31 22:29:36 +00:00

161 lines
6.3 KiB
TypeScript

"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. Originally built for
// /konto/bestellungen's filters, promoted to a shared component so
// checkout's country selects can use the same look (moved here 2026-07-30).
export function CustomSelect({
label,
options,
value,
onChange,
includeAllOption = true,
fullWidth = false,
}: {
/** Screen-reader label — also the trigger's placeholder text when
* `includeAllOption` is true and nothing is selected. */
label: string;
options: Option[];
value: string;
onChange: (value: string) => void;
/** true (default): prepends a `{value: "", label}` "clear/show all"
* pseudo-option — the filter-dropdown use case (Order/blog/etc.
* filters), where "nothing selected" is a real, meaningful state.
* false: no pseudo-option, every real option is selectable and one is
* always genuinely selected — the plain-select-replacement use case
* (e.g. checkout's country picker), where there's no "clear" concept. */
includeAllOption?: boolean;
/** false (default): trigger shrinks to its content width from sm: up —
* right for a row of compact filter dropdowns. true: trigger always
* stays full width of its container — right for a form-field
* replacement (e.g. checkout's country picker, alongside other w-full
* inputs). */
fullWidth?: boolean;
}) {
const [open, setOpen] = useState(false);
const [highlighted, setHighlighted] = useState(0);
const rootRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const allOptions: Option[] = includeAllOption ? [{ value: "", label }, ...options] : 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]);
// Keyboard/focus-driven close: Tabbing (or programmatically moving
// focus) away from the trigger+list entirely used to leave the panel
// open forever — the mousedown-outside listener above only ever reacts
// to a mouse click, not focus leaving via Tab. `relatedTarget` is where
// focus is headed; null on some browsers when it lands outside the
// document/on a non-focusable element, which should also close.
function onBlur(e: React.FocusEvent) {
if (!rootRef.current?.contains(e.relatedTarget as Node)) setOpen(false);
}
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} onBlur={onBlur} className={`relative w-full ${fullWidth ? "" : "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 ${fullWidth ? "" : "sm:w-auto min-w-[10rem]"} border rounded-sm ${
fullWidth ? "px-4 py-3" : "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}
// Without this, a mousedown on an <li> blurs the trigger button
// first (li isn't focusable) — the resulting onBlur closes and
// unmounts this list before the click event ever fires, so
// nothing is ever selectable by mouse/touch.
onMouseDown={(e) => e.preventDefault()}
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>
);
}