feat(ui): extract a shared NativeSelect primitive and migrate three call sites

v1.4.27 MB7 / CF-52 — three settings/admin surfaces each declared a
local `NATIVE_SELECT_CLASS` constant tracing the same visual contract
as the shadcn `<SelectTrigger>`. The copies had drifted slightly so
`AccountSection`, `TimezonePicker`, and `GeneralSettingsSection`
rendered at subtly different focus-ring weights. The new
`<NativeSelect>` primitive owns the contract; all three call sites
import it and the in-file constants retire. The native picker remains
the right experience for the 400-entry timezone list and the
admin-only locale dropdowns, so the primitive deliberately stays on a
styled `<select>` rather than wrapping the radix popover-driven
`<Select>`.
This commit is contained in:
Marc-André Bombeck committed 2026-05-15 19:10:13 +02:00
1 parent e52b64bf34
commit 07c9d01faf
4 files changed
+81 -28

No files matched your search

@@ -3,11 +3,16 @@
import { useMemo } from "react";
import { Settings } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { NativeSelect } from "@/components/ui/native-select";
import { SettingsToggle, useAdminSettings, useUpdateSettings } from "./_shared";
import { listSupportedTimezones } from "@/lib/tz/format";
const NATIVE_SELECT_CLASS =
"border-input bg-background text-foreground ring-offset-background focus-visible:ring-ring flex h-9 rounded-md border px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-none";
// v1.4.27 MB7 / CF-52 — the in-file `NATIVE_SELECT_CLASS` constant
// retired; the shared `<NativeSelect>` primitive owns the visual
// contract now. The admin copy previously diverged on the
// `focus-visible:ring-[3px]` weight; the shared primitive pins
// `focus-visible:ring-2` so admin selects match the rest of the form
// surface.
export function GeneralSettingsSection() {
const { t } = useTranslations();
@@ -43,17 +48,17 @@ export function GeneralSettingsSection() {
{t("admin.defaultLanguageDescription")}
</p>
</div>
<select
<NativeSelect
value={settings?.defaultLocale ?? "de"}
onChange={(e) =>
updateSettings.mutate({ defaultLocale: e.target.value })
}
disabled={updateSettings.isPending}
className={NATIVE_SELECT_CLASS}
className="w-auto"
>
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
</NativeSelect>
</div>
{/* v1.4.25 W7 — server-wide default timezone for new
@@ -69,13 +74,13 @@ export function GeneralSettingsSection() {
{t("admin.defaultUserTimezoneDescription")}
</p>
</div>
<select
<NativeSelect
value={currentDefaultTz}
onChange={(e) =>
updateSettings.mutate({ defaultUserTimezone: e.target.value })
}
disabled={updateSettings.isPending}
className={NATIVE_SELECT_CLASS}
className="sm:w-auto"
>
<option value="">{t("admin.defaultUserTimezoneFallback")}</option>
{/* Preserve the stored value even if it's not in the
@@ -88,7 +93,7 @@ export function GeneralSettingsSection() {
{zone}
</option>
))}
</select>
</NativeSelect>
</div>
</div>
</div>
+9 -15
View File
@@ -37,6 +37,7 @@ import {
import { Input } from "@/components/ui/input";
import { DateInput } from "@/components/ui/date-input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { PasswordInput } from "@/components/ui/password-input";
import { useAuth } from "@/hooks/use-auth";
import { formatDate } from "@/lib/format";
@@ -53,15 +54,10 @@ interface PasskeyInfo {
createdAt: string;
}
/**
* Shared class string for native `<select>` elements inside the Account
* card. Mirrors the visual contract of `<Input>` and shadcn
* `<SelectTrigger>` (`h-9`, identical border/focus tokens) so gender,
* language, and any future native dropdown render at the same height
* and width as every other input in the form.
*/
const NATIVE_SELECT_CLASS =
"border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none";
// v1.4.27 MB7 / CF-52 — the in-file `NATIVE_SELECT_CLASS` constant
// retired; the shared `<NativeSelect>` primitive owns the visual
// contract now. Existing `<select className={NATIVE_SELECT_CLASS}>`
// call sites in this file swapped to `<NativeSelect>` below.
export function AccountSection() {
const { t, locale, setLocale } = useTranslations();
@@ -380,16 +376,15 @@ export function AccountSection() {
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="gender">{t("settings.gender")}</Label>
<select
<NativeSelect
id="gender"
value={gender}
onChange={(e) => setGender(e.target.value)}
className={NATIVE_SELECT_CLASS}
>
<option value="">{t("settings.genderNone")}</option>
<option value="MALE">{t("settings.genderMale")}</option>
<option value="FEMALE">{t("settings.genderFemale")}</option>
</select>
</NativeSelect>
<p className="text-muted-foreground text-xs">
{t("settings.genderHint")}
</p>
@@ -432,18 +427,17 @@ export function AccountSection() {
</div>
<div className="space-y-2">
<Label htmlFor="language-select">{t("settings.language")}</Label>
<select
<NativeSelect
id="language-select"
value={locale}
onChange={(e) => setLocale(e.target.value as Locale)}
className={NATIVE_SELECT_CLASS}
>
{locales.map((loc) => (
<option key={loc} value={loc}>
{localeLabels[loc as Locale]}
</option>
))}
</select>
</NativeSelect>
<p className="text-muted-foreground text-xs">
{t("settings.languageDescription")}
</p>
+8 -5
View File
@@ -21,14 +21,17 @@ import { Compass } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import {
detectBrowserTimezone,
listSupportedTimezones,
} from "@/lib/tz/format";
import { useTranslations } from "@/lib/i18n/context";
const NATIVE_SELECT_CLASS =
"border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none";
// v1.4.27 MB7 / CF-52 — the in-file `NATIVE_SELECT_CLASS` constant
// retired; the shared `<NativeSelect>` primitive owns the visual
// contract now. The `sm:max-w-sm` modifier still composes via the
// `className` prop below so the picker keeps its narrow desktop width.
export interface TimezonePickerProps {
/** The user's current stored timezone. */
@@ -89,11 +92,11 @@ export function TimezonePicker({
<div className="space-y-2">
<Label htmlFor={id}>{t("settings.timezone")}</Label>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<select
<NativeSelect
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
className={`${NATIVE_SELECT_CLASS} sm:max-w-sm`}
className="sm:max-w-sm"
>
{/* Preserve the stored value even if the runtime's IANA
list changed across engines or tzdata rolls. */}
@@ -105,7 +108,7 @@ export function TimezonePicker({
{zone}
</option>
))}
</select>
</NativeSelect>
<Button
type="button"
variant="outline"
+51
View File
@@ -0,0 +1,51 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* v1.4.27 MB7 / CF-52 — shared `<select>` primitive.
*
* Three settings/admin surfaces (`AccountSection`, `TimezonePicker`,
* `GeneralSettingsSection`) each declared a local `NATIVE_SELECT_CLASS`
* constant that traced the same visual contract as the shadcn
* `<SelectTrigger>` (border + bg + ring tokens, `h-9` floor, identical
* focus vocabulary). The three copies had drifted slightly — one
* dropped `w-full`, another dropped the `shadow-xs` token — so the
* three forms rendered at subtly different weights despite reading
* "the same select" to the user.
*
* The primitive collapses the duplicates. Consumers wrap it like a
* raw `<select>` and the className still composes via `cn(...)` so
* site-specific tweaks (`sm:max-w-sm`, etc.) keep working.
*
* Why native? The shadcn `<Select>` from `@radix-ui/react-select` is
* the better keyboard experience on desktop but pulls in extra
* client JS and does not show the iOS / Android native picker.
* Settings/admin surfaces benefit from the native picker (especially
* for the 400-entry timezone list) so we deliberately stay on
* `<select>` here. The styled primitive keeps the visual rhythm
* uniform with the rest of the form's `<Input>` + `<SelectTrigger>`
* rows.
*/
const NATIVE_SELECT_CLASS =
"border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none";
export type NativeSelectProps = React.SelectHTMLAttributes<HTMLSelectElement>;
export const NativeSelect = React.forwardRef<
HTMLSelectElement,
NativeSelectProps
>(function NativeSelect({ className, children, ...props }, ref) {
return (
<select
ref={ref}
data-slot="native-select"
className={cn(NATIVE_SELECT_CLASS, className)}
{...props}
>
{children}
</select>
);
});