mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
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:
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user