Use CustomSelect for checkout's country pickers too

Promoted CustomSelect from konto/bestellungen/components/ to a shared
app/components/ location. New includeAllOption (checkout doesn't want
a "clear" pseudo-option — a country is always genuinely selected) and
fullWidth (matches the other w-full form fields, no sm: shrink) props.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-30 23:15:08 +00:00
parent 2b2ee4d23c
commit ee39a5d7dc
3 changed files with 46 additions and 25 deletions
+20 -19
View File
@@ -11,6 +11,7 @@ import { computeSubtotal, computeCartTotals, effectivePrice, effectiveTaxRate, c
import { computeTaxBreakdown } from "@einfach-produktiv/invoicing";
import { formatPrice } from "../../lib/format";
import { Reveal } from "../../components/Reveal";
import { CustomSelect } from "../../components/CustomSelect";
import { VersandModal } from "../../components/VersandModal";
import { VatBreakdown } from "../../components/VatBreakdown";
import { CheckoutSteps } from "../../components/CheckoutSteps";
@@ -1021,17 +1022,19 @@ export function CheckoutContent({
</div>
<label className="flex flex-col gap-2 items-start w-full">
<span className="text-label text-text-muted">Land</span>
<select
name="country"
{/* CustomSelect, not a native <select> — consistent with the
filter dropdowns elsewhere (native options popups can't be
styled at all). includeAllOption={false}: every country is
a real, selectable choice, there's no "clear" concept
here — "country" is always genuinely set to something. */}
<CustomSelect
label="Land wählen"
includeAllOption={false}
fullWidth
options={shippingCountries.map((c) => ({ value: c.name, label: c.name }))}
value={country}
onChange={(e) => setCountry(e.target.value)}
required
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors bg-bg-base"
>
{shippingCountries.map((c) => (
<option key={c.name}>{c.name}</option>
))}
</select>
onChange={setCountry}
/>
</label>
<div className="h-px bg-border w-full" />
@@ -1188,16 +1191,14 @@ export function CheckoutContent({
</div>
<label className="flex flex-col gap-2 items-start w-full">
<span className="text-label text-text-muted">Land</span>
<select
<CustomSelect
label="Land wählen"
includeAllOption={false}
fullWidth
options={shippingCountries.map((c) => ({ value: c.name, label: c.name }))}
value={shippingCountry}
onChange={(e) => setShippingCountry(e.target.value)}
required
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors bg-bg-base"
>
{shippingCountries.map((c) => (
<option key={c.name}>{c.name}</option>
))}
</select>
onChange={setShippingCountry}
/>
</label>
{/* Both optional and independent — handed to the shipping
carrier, not used for any customer communication (that
@@ -8,25 +8,43 @@ type Option = { value: string; label: string };
// 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.
// 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 and the "not selected" trigger text. */
/** 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[] = [{ value: "", label }, ...options];
const allOptions: Option[] = includeAllOption ? [{ value: "", label }, ...options] : options;
const selectedIndex = Math.max(
0,
allOptions.findIndex((o) => o.value === value),
@@ -78,7 +96,7 @@ export function CustomSelect({
}
return (
<div ref={rootRef} className="relative w-full sm:w-auto">
<div ref={rootRef} className={`relative w-full ${fullWidth ? "" : "sm:w-auto"}`}>
<button
type="button"
aria-haspopup="listbox"
@@ -86,7 +104,9 @@ export function CustomSelect({
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 ${
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`}
>
@@ -1,7 +1,7 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { CustomSelect } from "./CustomSelect";
import { CustomSelect } from "../../../components/CustomSelect";
// 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