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