bf6c5d079a
- 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
161 lines
6.3 KiB
TypeScript
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>
|
|
);
|
|
}
|