ux: i18n leaks, focus rings, tap-target floor, admin chip-scroll, empty-state CTA vocabulary

Eight hotfix-ready UX/a11y findings from the v1.4.34.3 UX audit
land as one batch.

i18n leaks (3 primitives):
  - Dialog + Sheet close-X carried hardcoded `<span sr-only>Close</span>`
    so a German user heard 'Close, button' on every modal. Both lift
    `useTranslations()` and read `common.close`.
  - ChartSkeleton's sr-only loading announcement was English-only.
    The component is already a client island; reads `charts.loadingLabel`
    from each locale now. New key shipped in DE / EN / FR / ES / IT / PL.
  - Notifications-section breadcrumb `<nav aria-label="Breadcrumb">`
    was hardcoded. Routes through `t("nav.breadcrumb")` with a new
    key in each locale.

Focus rings (mobile + tablet nav):
  - Bottom-nav primary links + the More overflow trigger relied on a
    `text-muted-foreground` → `text-primary` colour transition only;
    keyboard / switch-control users saw no focus indicator at all.
    Added `focus-visible:ring-ring/50 focus-visible:ring-2
    focus-visible:outline-none focus-visible:ring-offset-2` matching
    the v1.4.33 sidebar treatment.
  - Mobile top-bar trigger had `focus:outline-none` without a
    `focus-visible` replacement. Same treatment applied.

Tap-target floor (WCAG 2.5.5):
  - Input / NativeSelect / Select / DateTimeInput were locked to
    h-10 (40 px) on every viewport. Switched to `h-11 sm:h-10` so
    mobile clears the 44 px floor while desktop keeps the 40 px norm
    that the Button primitive already converged on in v1.4.33.

Admin chip-strip auto-scroll-into-view:
  - The admin shell shipped 13 chips but lacked the
    `scrollIntoView({inline: 'center'})` effect the settings shell
    got in v1.4.33. Admins tapping a chip past the right edge landed
    on the new page with the strip scrolled to the left and the
    active chip off-screen. Copied the settings-shell pattern verbatim.

List-page subtitles on mobile:
  - Measurements / Mood / Medications pages hid their descriptive
    subtitle on `<sm` with `hidden sm:block`. Comparable apps keep
    the subtitle on mobile because it doubles as contextual help.
    Dropped the hide rule; the H1 still anchors the visual.

Insights empty-state CTA vocabulary:
  - `ALLOWED_ADD_TYPES` on `/measurements` accepted tokens (`GLUCOSE`,
    `TEMPERATURE`, `HEART_RATE`, `BMI`) that didn't match the
    `MEASUREMENT_TYPES` enum the form uses. Several `/insights/*`
    empty-state CTAs emitted those mismatched tokens and the dialog
    silently failed to open. Replaced with a single source of truth
    derived from `MEASUREMENT_TYPES` plus a normalisation map for
    legacy tokens. Added BODY_TEMPERATURE to the form's enum row.
    New unit test walks every `?add=` token emitted under
    `src/app/insights/**` and asserts it resolves to a real form type.
This commit is contained in:
Marc-André Bombeck committed 2026-05-17 00:34:00 +02:00
1 parent 6885703505
commit 1768b1d971
22 files changed
+235 -39

No files matched your search

+2
View File
@@ -182,6 +182,7 @@
"sidebar": "Sidebar",
"mainNavigation": "Hauptnavigation",
"mobileNavigation": "Mobile Navigation",
"breadcrumb": "Brotkrumen-Navigation",
"loadingScreen": "Laden…",
"more": "Mehr",
"moreSheetTitle": "Mehr",
@@ -760,6 +761,7 @@
}
},
"charts": {
"loadingLabel": "Diagramm wird geladen…",
"weight": "Gewicht",
"bloodPressure": "Blutdruck",
"pulse": "Puls",
+2
View File
@@ -182,6 +182,7 @@
"sidebar": "Sidebar",
"mainNavigation": "Main navigation",
"mobileNavigation": "Mobile navigation",
"breadcrumb": "Breadcrumb navigation",
"loadingScreen": "Loading…",
"more": "More",
"moreSheetTitle": "More",
@@ -760,6 +761,7 @@
}
},
"charts": {
"loadingLabel": "Loading chart…",
"weight": "Weight",
"bloodPressure": "Blood Pressure",
"pulse": "Pulse",
+2
View File
@@ -182,6 +182,7 @@
"sidebar": "Sidebar",
"mainNavigation": "Main navigation",
"mobileNavigation": "Mobile navigation",
"breadcrumb": "Ruta de navegación",
"loadingScreen": "Loading…",
"more": "More",
"moreSheetTitle": "More",
@@ -760,6 +761,7 @@
}
},
"charts": {
"loadingLabel": "Cargando gráfico…",
"weight": "Peso",
"bloodPressure": "Presión arterial",
"pulse": "Pulso",
+2
View File
@@ -182,6 +182,7 @@
"sidebar": "Sidebar",
"mainNavigation": "Main navigation",
"mobileNavigation": "Mobile navigation",
"breadcrumb": "Fil d'Ariane",
"loadingScreen": "Loading…",
"more": "More",
"moreSheetTitle": "More",
@@ -760,6 +761,7 @@
}
},
"charts": {
"loadingLabel": "Chargement du graphique…",
"weight": "Poids",
"bloodPressure": "Tension artérielle",
"pulse": "Pouls",
+2
View File
@@ -182,6 +182,7 @@
"sidebar": "Sidebar",
"mainNavigation": "Main navigation",
"mobileNavigation": "Mobile navigation",
"breadcrumb": "Navigazione breadcrumb",
"loadingScreen": "Loading…",
"more": "More",
"moreSheetTitle": "More",
@@ -760,6 +761,7 @@
}
},
"charts": {
"loadingLabel": "Caricamento grafico…",
"weight": "Peso",
"bloodPressure": "Pressione arteriosa",
"pulse": "Polso",
+2
View File
@@ -182,6 +182,7 @@
"sidebar": "Sidebar",
"mainNavigation": "Main navigation",
"mobileNavigation": "Mobile navigation",
"breadcrumb": "Nawigacja okruszkami",
"loadingScreen": "Loading…",
"more": "More",
"moreSheetTitle": "More",
@@ -760,6 +761,7 @@
}
},
"charts": {
"loadingLabel": "Ładowanie wykresu…",
"weight": "Waga",
"bloodPressure": "Ciśnienie krwi",
"pulse": "Tętno",
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import {
MEASUREMENT_FORM_TYPE_VALUES,
ADD_TOKEN_ALIASES,
resolveAddToken,
} from "@/components/measurements/measurement-form";
/**
* F-1 contract — every `?add=<token>` the Insights surfaces ship must
* either land on a real `<MeasurementForm>` row or be an explicit
* legacy alias mapped to one. Without this guard the empty-state CTA
* silently drops the deep link and the dialog never opens — the
* symptom that surfaced as "the Körpertemperatur CTA does nothing".
*/
describe("measurements ?add= deep link", () => {
const repoRoot = join(__dirname, "..", "..", "..", "..");
const insightsRoot = join(repoRoot, "src", "app", "insights");
const insightsComponentsRoot = join(repoRoot, "src", "components", "insights");
// Captures both `href="/measurements?add=FOO"` literals and the
// dynamic `?add=${prop}` template the shared HealthKit metric page
// uses. The latter is covered by walking `emptyStateCtaType="…"` in
// every `/insights/<slug>/page.tsx`.
const literalAddPattern = /\/measurements\?add=([A-Z_]+)/g;
const ctaTypePattern = /emptyStateCtaType=\{?["']([A-Z_]+)["']/g;
function collectFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) {
if (entry === "__tests__" || entry === "node_modules") continue;
out.push(...collectFiles(full));
} else if (entry.endsWith(".tsx") || entry.endsWith(".ts")) {
out.push(full);
}
}
return out;
}
const files = [
...collectFiles(insightsRoot),
...collectFiles(insightsComponentsRoot),
];
const emittedTokens = new Set<string>();
for (const file of files) {
const source = readFileSync(file, "utf8");
for (const match of source.matchAll(literalAddPattern)) {
emittedTokens.add(match[1]);
}
for (const match of source.matchAll(ctaTypePattern)) {
emittedTokens.add(match[1]);
}
}
it("walks at least one `?add=` token under src/app/insights", () => {
// Sanity guard so the regex collector doesn't silently zero out.
expect(emittedTokens.size).toBeGreaterThan(0);
});
it("every emitted `?add=` token resolves to a real form type", () => {
const unresolved: string[] = [];
for (const token of emittedTokens) {
if (resolveAddToken(token) === null) unresolved.push(token);
}
expect(unresolved).toEqual([]);
});
it("legacy aliases each map to a real form type", () => {
for (const [legacy, canonical] of Object.entries(ADD_TOKEN_ALIASES)) {
expect(MEASUREMENT_FORM_TYPE_VALUES).toContain(canonical);
expect(resolveAddToken(legacy)).toBe(canonical);
}
});
it("resolves unknown tokens to null without throwing", () => {
expect(resolveAddToken("NOT_A_TYPE")).toBeNull();
expect(resolveAddToken(null)).toBeNull();
expect(resolveAddToken(undefined)).toBeNull();
expect(resolveAddToken("")).toBeNull();
});
});
+15 -21
View File
@@ -2,7 +2,10 @@
import { useEffect, useState } from "react";
import { useAuth } from "@/hooks/use-auth";
import { MeasurementForm } from "@/components/measurements/measurement-form";
import {
MeasurementForm,
resolveAddToken,
} from "@/components/measurements/measurement-form";
import { MeasurementList } from "@/components/measurements/measurement-list";
import { Button } from "@/components/ui/button";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
@@ -10,23 +13,6 @@ import { Plus, Loader2 } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "@/lib/i18n/context";
// v1.4.27 MB6 — the insights empty-state CTAs link here with a
// `?add=<TYPE>` query param to auto-open the add-measurement dialog
// with the right default type. Keep the allow-list aligned with
// MEASUREMENT_TYPES inside `measurement-form.tsx`; an unknown value
// is dropped silently so a stale link can never trap the user on a
// broken form.
const ALLOWED_ADD_TYPES = new Set([
"BLOOD_PRESSURE",
"WEIGHT",
"PULSE",
"BMI",
"GLUCOSE",
"TEMPERATURE",
"BODY_FAT",
"HEART_RATE",
]);
export default function MeasurementsPage() {
const { isAuthenticated, isLoading } = useAuth();
const router = useRouter();
@@ -52,8 +38,13 @@ export default function MeasurementsPage() {
if (addParam && addParam !== consumedAddParam) {
setConsumedAddParam(addParam);
if (ALLOWED_ADD_TYPES.has(addParam)) {
setDefaultType(addParam);
// v1.4.34 IW-G — `resolveAddToken` is the single source of truth
// for legacy aliases (`GLUCOSE`, `TEMPERATURE`, …) plus the
// canonical form values. Unknown tokens still drop silently so a
// stale link cannot trap the user on a broken form.
const resolved = resolveAddToken(addParam);
if (resolved) {
setDefaultType(resolved);
setDialogOpen(true);
}
// Drop the query string so the back-button leaves the user on
@@ -82,7 +73,10 @@ export default function MeasurementsPage() {
<h1 className="text-2xl font-bold tracking-tight">
{t("measurements.title")}
</h1>
<p className="text-muted-foreground hidden text-sm sm:block">
{/* v1.4.34 IW-G — subtitle now stays visible on mobile so
the H1 isn't an unframed label. `text-xs sm:text-sm`
preserves the desktop hierarchy. */}
<p className="text-muted-foreground text-xs sm:text-sm">
{t("measurements.subtitle")}
</p>
</div>
+3 -1
View File
@@ -154,7 +154,9 @@ export default function MedicationsPage() {
<h1 className="text-2xl font-bold tracking-tight">
{t("medications.title")}
</h1>
<p className="text-muted-foreground hidden text-sm sm:block">
{/* v1.4.34 IW-G — subtitle stays visible on mobile so the
H1 isn't an unframed label. */}
<p className="text-muted-foreground text-xs sm:text-sm">
{t("medications.subtitle")}
</p>
</div>
+3 -1
View File
@@ -42,7 +42,9 @@ export default function MoodPage() {
<h1 className="text-2xl font-bold tracking-tight">
{t("mood.title")}
</h1>
<p className="text-muted-foreground hidden text-sm sm:block">
{/* v1.4.34 IW-G — subtitle stays visible on mobile so the
H1 isn't an unframed label. */}
<p className="text-muted-foreground text-xs sm:text-sm">
{t("mood.subtitle")}
</p>
</div>
+30 -4
View File
@@ -156,6 +156,26 @@ export function AdminShell({ active, children }: AdminShellProps) {
const { t } = useTranslations();
const activeSlug = deriveActiveSlug(pathname, active);
// v1.4.34 IW-G — mirror the settings-shell auto-scroll pattern so
// the active chip stays in view when the user lands on a deeper
// admin route. Without it the 16-chip strip stays scrolled to the
// left and the active chip lives off-screen, reading as a broken nav.
const mobileStripRef = React.useRef<HTMLElement | null>(null);
React.useEffect(() => {
if (typeof window === "undefined") return;
const strip = mobileStripRef.current;
if (!strip) return;
const activeChip = strip.querySelector<HTMLElement>(
'[aria-current="page"]',
);
if (!activeChip) return;
activeChip.scrollIntoView({
block: "nearest",
inline: "center",
behavior: "smooth",
});
}, [activeSlug]);
// v1.4.25 W8 — AuthShell wraps the page in `px-4 py-6 md:px-6`
// already, so this inner shell only carries the wider max-width.
// Previously the duplicate `px-4 py-6 md:px-6 md:py-8` here was
@@ -169,13 +189,19 @@ export function AdminShell({ active, children }: AdminShellProps) {
horizontal swipe + keyboard arrow scrolling still work, the
bar just doesn't draw. Without this the 13-section strip
would render an always-on scrollbar that the maintainer kept
mis-attributing to the api-tokens table below it. */}
mis-attributing to the api-tokens table below it.
v1.4.34 IW-G — `snap-x snap-mandatory` plus per-chip
`snap-start` lets a swipe-flick land on the next chip's
leading edge instead of the in-between dead zone, matching
the settings-shell polish. */}
<nav
ref={mobileStripRef}
aria-label={t("admin.shell.sectionsNav")}
className="no-scrollbar -mx-4 mb-4 overflow-x-auto px-4 md:hidden"
className="no-scrollbar -mx-4 mb-4 snap-x snap-mandatory overflow-x-auto px-4 md:hidden"
>
<ul className="flex min-w-max gap-2">
<li>
<li className="snap-start">
<Link
href="/admin"
aria-current={activeSlug === null ? "page" : undefined}
@@ -197,7 +223,7 @@ export function AdminShell({ active, children }: AdminShellProps) {
const isActive = section.slug === activeSlug;
const Icon = section.icon;
return (
<li key={section.slug}>
<li key={section.slug} className="snap-start">
<Link
href={`/admin/${section.slug}`}
aria-current={isActive ? "page" : undefined}
+5 -1
View File
@@ -2,6 +2,7 @@
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.4.28 R3d (R1.2 H4) — layout-stable loading shell for every
@@ -27,6 +28,7 @@ import { cn } from "@/lib/utils";
* `motion-reduce:animate-none` modifier.
*/
export function ChartSkeleton({ className }: { className?: string }) {
const { t } = useTranslations();
return (
<div
data-slot="chart-skeleton"
@@ -38,7 +40,9 @@ export function ChartSkeleton({ className }: { className?: string }) {
className,
)}
>
<span className="sr-only">Loading chart…</span>
{/* v1.4.34 IW-G — sr-only announcement now reads the locale's
`charts.loadingLabel` key instead of literal English. */}
<span className="sr-only">{t("charts.loadingLabel")}</span>
{/* Header row — title + range tabs match the real chart's
`mb-4 flex flex-col gap-2 sm:flex-row` chrome. */}
+7 -2
View File
@@ -99,8 +99,13 @@ export function BottomNav() {
// Touch target sized to WCAG 2.5.5 (44×44 CSS px). The
// outer min-h-11 min-w-11 is the actual hit area; the icon
// stays visually centered at 20px so the design doesn't shift.
//
// v1.4.34 IW-G — keyboard users get a visible ring on
// focus-visible, matching the sidebar treatment from
// v1.4.33. Without it the bottom-nav had no focus
// indicator at all on Tab navigation.
className={cn(
"relative flex min-h-11 min-w-11 flex-1 items-center justify-center rounded-lg transition-colors",
"relative flex min-h-11 min-w-11 flex-1 items-center justify-center rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2",
isActive
? "text-primary"
: "text-muted-foreground hover:text-foreground",
@@ -121,7 +126,7 @@ export function BottomNav() {
aria-current={overflowActive ? "page" : undefined}
onClick={() => setMoreOpen(true)}
className={cn(
"relative flex min-h-11 min-w-11 flex-1 items-center justify-center rounded-lg transition-colors",
"relative flex min-h-11 min-w-11 flex-1 items-center justify-center rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2",
overflowActive
? "text-primary"
: "text-muted-foreground hover:text-foreground",
+5 -1
View File
@@ -64,7 +64,11 @@ export function TopBar() {
aria-label={t("nav.userMenu")}
// v1.4.25 W8 — hit the WCAG 2.5.5 44 px touch-target floor on
// mobile. The text-only py-1.5 was previously ~30 px tall.
className="text-muted-foreground hover:text-foreground flex min-h-11 min-w-11 items-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors focus:outline-none"
//
// v1.4.34 IW-G — keyboard users get a visible ring on
// focus-visible. The previous `focus:outline-none` killed
// the focus indicator without replacing it.
className="text-muted-foreground hover:text-foreground flex min-h-11 min-w-11 items-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2"
>
<User className="h-4 w-4" />
<span className="hidden sm:inline">{user.username}</span>
@@ -87,8 +87,45 @@ const MEASUREMENT_TYPES = [
unit: "%",
placeholder: "98",
},
{
value: "BODY_TEMPERATURE",
labelKey: "measurements.typeBodyTemperature",
unit: "°C",
placeholder: "36.6",
},
] as const;
// v1.4.34 IW-G — single source of truth for the `/measurements?add=<TYPE>`
// deep link the Insights empty-state CTAs ship. Derived from the form's
// MEASUREMENT_TYPES so a new row in the form is immediately usable as
// a deep-link target.
export const MEASUREMENT_FORM_TYPE_VALUES = MEASUREMENT_TYPES.map(
(t) => t.value,
) as readonly string[];
// Legacy / Insights-internal tokens that predate the canonical enum.
// Older empty-state CTAs and a handful of dashboard tiles still emit
// these — translate them to the form's canonical value so the link
// keeps working without forcing every caller to rename in lockstep.
export const ADD_TOKEN_ALIASES: Readonly<Record<string, string>> = {
GLUCOSE: "BLOOD_GLUCOSE",
TEMPERATURE: "BODY_TEMPERATURE",
HEART_RATE: "PULSE",
BMI: "WEIGHT",
};
/**
* Resolve a `?add=<token>` deep-link value to a real form type, or
* `null` when the token has no canonical mapping. Centralised so the
* page-level dispatcher and the F-1 contract test consume the same
* resolver.
*/
export function resolveAddToken(token: string | null | undefined): string | null {
if (!token) return null;
const aliased = ADD_TOKEN_ALIASES[token] ?? token;
return MEASUREMENT_FORM_TYPE_VALUES.includes(aliased) ? aliased : null;
}
const GLUCOSE_CONTEXTS = [
{ value: "FASTING", labelKey: "measurements.glucoseContextFasting" },
{
@@ -46,7 +46,10 @@ export function NotificationsSection() {
inbox at `/notifications`. Both surfaces used to be named plain
"Notifications", so the crumb spells out where the user is and
offers a one-tap jump to the inbox. */}
<nav aria-label="Breadcrumb" className="text-muted-foreground text-xs">
<nav
aria-label={t("nav.breadcrumb")}
className="text-muted-foreground text-xs"
>
<ol className="flex items-center gap-1">
<li>{t("notifications.breadcrumbSettings")}</li>
<li aria-hidden="true">
+4 -1
View File
@@ -29,8 +29,11 @@ import { useTranslations } from "@/lib/i18n/context";
* even when iOS shrinks its placeholder, so the field looks
* identical on both engines.
*/
// v1.4.34 IW-G — match the `<Input>` mobile floor (44 px) so date and
// datetime fields don't sit below the WCAG 2.5.5 target-size minimum
// on Pixel-5-class viewports. Desktop falls back to the 40 px norm.
const DATE_INPUT_HEIGHT_CLASSES =
"appearance-none min-h-10 h-10 [&::-webkit-date-and-time-value]:min-h-[1.5em]";
"appearance-none min-h-11 h-11 sm:min-h-10 sm:h-10 [&::-webkit-date-and-time-value]:min-h-[1.5em]";
type InputProps = React.ComponentProps<typeof Input>;
type DateInputProps = Omit<InputProps, "type" | "lang">;
+7 -2
View File
@@ -6,6 +6,7 @@ import { Dialog as DialogPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
function Dialog({
...props
@@ -55,6 +56,7 @@ function DialogContent({
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
const { t } = useTranslations();
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
@@ -84,7 +86,9 @@ function DialogContent({
className="ring-offset-background focus-visible:border-ring focus-visible:ring-ring/50 text-muted-foreground hover:bg-accent hover:text-foreground absolute top-3 right-4 inline-flex min-h-9 min-w-9 items-center justify-center rounded-md transition-colors focus-visible:ring-[3px] focus-visible:outline-none disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
{/* v1.4.34 IW-G — read `common.close` so screen readers
hear the localised label instead of literal English. */}
<span className="sr-only">{t("common.close")}</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
@@ -110,6 +114,7 @@ function DialogFooter({
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
const { t } = useTranslations();
return (
<div
data-slot="dialog-footer"
@@ -122,7 +127,7 @@ function DialogFooter({
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
<Button variant="outline">{t("common.close")}</Button>
</DialogPrimitive.Close>
)}
</div>
+3 -1
View File
@@ -67,7 +67,9 @@ function Input({
data-lpignore={skipAutofill ? "true" : undefined}
data-1p-ignore={skipAutofill ? "true" : undefined}
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-10 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
// v1.4.34 IW-G — floor at 44 px on mobile to clear WCAG 2.5.5;
// shrink to 40 px on sm+ where pointer precision is higher.
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-11 sm:h-10 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
+3 -1
View File
@@ -35,8 +35,10 @@ import { cn } from "@/lib/utils";
* uniform with the rest of the form's `<Input>` + `<SelectTrigger>`
* rows.
*/
// v1.4.34 IW-G — floor at 44 px on mobile to clear WCAG 2.5.5; shrink
// to 40 px on sm+ where the pointer precision is higher.
const NATIVE_SELECT_CLASS =
"border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 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";
"border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-11 sm:h-10 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>;
+5 -1
View File
@@ -37,7 +37,11 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-10 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
// v1.4.34 IW-G — `size=default` floors at 44 px on mobile to
// clear WCAG 2.5.5 and shrinks to 40 px on sm+ to match the
// desktop input rhythm. `size=sm` (filter chips) intentionally
// keeps the 32 px footprint.
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-11 data-[size=default]:sm:h-10 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
+5 -1
View File
@@ -5,6 +5,7 @@ import { XIcon } from "lucide-react";
import { Dialog as SheetPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
@@ -54,6 +55,7 @@ function SheetContent({
side?: "top" | "right" | "bottom" | "left";
showCloseButton?: boolean;
}) {
const { t } = useTranslations();
return (
<SheetPortal>
<SheetOverlay />
@@ -86,7 +88,9 @@ function SheetContent({
className="ring-offset-background focus-visible:border-ring focus-visible:ring-ring/50 text-muted-foreground hover:bg-accent hover:text-foreground absolute top-3 right-4 inline-flex min-h-9 min-w-9 items-center justify-center rounded-md transition-colors focus-visible:ring-[3px] focus-visible:outline-none disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
{/* v1.4.34 IW-G — read `common.close` so screen readers
hear the localised label instead of literal English. */}
<span className="sr-only">{t("common.close")}</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>