chore(release): v1.4.27

v1.4.27 — Mobile capability and maintainer-finding cleanup.

ResponsiveSheet primitive (bottom-sheet on narrow viewports, centred dialog on desktop)
applied to BD, Weight, Glucose, Mood, Activity forms. Maintainer-finding cleanups across
Coach, Insights, Reports, Privacy. GeoLite2 offline geocoding with resilience layer
(graceful fallback when EULA not yet propagated). E2E gap-fill coverage. 450-line
release notes attached to the GitHub Release.
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-15 22:59:10 +02:00
1 parent d98e85cd03
commit 4f09c3f862
314 files changed
+26801 -4419

No files matched your search

@@ -28,4 +28,44 @@ describe("<Input>", () => {
expect(html).not.toContain("lpignore");
expect(html).not.toContain("1p-ignore");
});
it("derives inputMode=decimal from type=number", () => {
const html = renderToStaticMarkup(<Input id="weight" type="number" />);
expect(html).toContain('inputMode="decimal"');
});
it("derives inputMode=email from type=email", () => {
const html = renderToStaticMarkup(
<Input id="email" type="email" autoComplete="email" />,
);
expect(html).toContain('inputMode="email"');
});
it("derives inputMode=url from type=url", () => {
const html = renderToStaticMarkup(<Input id="webhook" type="url" />);
expect(html).toContain('inputMode="url"');
});
it("derives inputMode=tel from type=tel", () => {
const html = renderToStaticMarkup(<Input id="phone" type="tel" />);
expect(html).toContain('inputMode="tel"');
});
it("derives inputMode=search from type=search", () => {
const html = renderToStaticMarkup(<Input id="filter" type="search" />);
expect(html).toContain('inputMode="search"');
});
it("omits inputMode for plain text inputs", () => {
const html = renderToStaticMarkup(<Input id="note" type="text" />);
expect(html).not.toContain("inputMode=");
});
it("respects an explicit inputMode override on type=number", () => {
const html = renderToStaticMarkup(
<Input id="steps" type="number" inputMode="numeric" />,
);
expect(html).toContain('inputMode="numeric"');
expect(html).not.toContain('inputMode="decimal"');
});
});
@@ -0,0 +1,223 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
// `<ResponsiveSheet>` flips between a `<Sheet side="bottom">` and a
// centred `<Dialog>` via `useIsMobile()`. Vitest runs in the Node
// environment without `matchMedia`; the hook returns its SSR-safe
// default (`false`) and is overridden below for the Sheet branch.
//
// shadcn `<Sheet>` / `<Dialog>` both wrap Radix Portals — `renderToStaticMarkup`
// does not materialise portal trees — so we mock the primitives down
// to passthrough wrappers so the rendered shape is reachable in the
// static markup.
let mobile = false;
vi.mock("@/hooks/use-is-mobile", () => ({
useIsMobile: () => mobile,
}));
vi.mock("@/components/ui/sheet", () => ({
Sheet: ({
children,
open,
}: {
children: React.ReactNode;
open: boolean;
}) => (open ? <div data-slot="mock-sheet">{children}</div> : null),
SheetContent: ({
children,
className,
...rest
}: {
children: React.ReactNode;
className?: string;
[key: string]: unknown;
}) => (
<div data-slot="mock-sheet-content" className={className} {...rest}>
{children}
</div>
),
SheetHeader: ({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) => <div className={className}>{children}</div>,
SheetFooter: ({
children,
className,
...rest
}: {
children: React.ReactNode;
className?: string;
[key: string]: unknown;
}) => (
<div className={className} {...rest}>
{children}
</div>
),
SheetTitle: ({ children }: { children: React.ReactNode }) => (
<h2>{children}</h2>
),
SheetDescription: ({ children }: { children: React.ReactNode }) => (
<p>{children}</p>
),
}));
vi.mock("@/components/ui/dialog", () => ({
Dialog: ({
children,
open,
}: {
children: React.ReactNode;
open: boolean;
}) => (open ? <div data-slot="mock-dialog">{children}</div> : null),
DialogContent: ({
children,
className,
...rest
}: {
children: React.ReactNode;
className?: string;
[key: string]: unknown;
}) => (
<div data-slot="mock-dialog-content" className={className} {...rest}>
{children}
</div>
),
DialogHeader: ({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) => <div className={className}>{children}</div>,
DialogFooter: ({
children,
className,
...rest
}: {
children: React.ReactNode;
className?: string;
[key: string]: unknown;
}) => (
<div className={className} {...rest}>
{children}
</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2>{children}</h2>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<p>{children}</p>
),
}));
import { ResponsiveSheet } from "../responsive-sheet";
describe("<ResponsiveSheet>", () => {
it("renders the Dialog branch on `md+` viewports", () => {
mobile = false;
const html = renderToStaticMarkup(
<ResponsiveSheet
open
onOpenChange={() => {}}
title="Add measurement"
description="Log a new reading"
>
<p>body</p>
</ResponsiveSheet>,
);
expect(html).toContain('data-variant="dialog"');
expect(html).toContain("Add measurement");
expect(html).toContain("Log a new reading");
expect(html).toContain("body");
// Sheet branch markers must be absent.
expect(html).not.toContain('data-slot="mock-sheet"');
});
it("renders the bottom Sheet branch on narrow viewports", () => {
mobile = true;
const html = renderToStaticMarkup(
<ResponsiveSheet open onOpenChange={() => {}} title="Add measurement">
<p>body</p>
</ResponsiveSheet>,
);
expect(html).toContain('data-variant="sheet"');
expect(html).toContain('data-slot="mock-sheet"');
expect(html).toContain("Add measurement");
expect(html).toContain("body");
mobile = false;
});
it("renders a sticky-pinned footer on the Sheet branch", () => {
mobile = true;
const html = renderToStaticMarkup(
<ResponsiveSheet
open
onOpenChange={() => {}}
title="Add measurement"
footer={<button type="button">Save</button>}
>
<p>body</p>
</ResponsiveSheet>,
);
expect(html).toContain('data-slot="responsive-sheet-footer"');
expect(html).toContain("sticky");
expect(html).toContain("Save");
mobile = false;
});
it("renders a flow-layout footer on the Dialog branch", () => {
mobile = false;
const html = renderToStaticMarkup(
<ResponsiveSheet
open
onOpenChange={() => {}}
title="Add measurement"
footer={<button type="button">Save</button>}
>
<p>body</p>
</ResponsiveSheet>,
);
expect(html).toContain('data-slot="responsive-sheet-footer"');
expect(html).not.toContain("sticky bottom-0");
expect(html).toContain("Save");
});
it("hides the visual header but keeps the title accessible via sr-only when hideHeader is set", () => {
mobile = false;
const html = renderToStaticMarkup(
<ResponsiveSheet
open
onOpenChange={() => {}}
title="Edit medication"
hideHeader
>
<p>body</p>
</ResponsiveSheet>,
);
expect(html).toContain("sr-only");
expect(html).toContain("Edit medication");
});
it("does not throw when onOpenChange is invoked (controlled-state plumbing)", () => {
mobile = false;
const onOpenChange = vi.fn();
// Mounted closed — Dialog mock returns null, smoke-only render
// confirms no throw + the prop wiring round-trips.
renderToStaticMarkup(
<ResponsiveSheet
open={false}
onOpenChange={onOpenChange}
title="closed"
>
<p>body</p>
</ResponsiveSheet>,
);
onOpenChange(true);
expect(onOpenChange).toHaveBeenCalledWith(true);
});
});
+4 -4
View File
@@ -21,14 +21,14 @@ const buttonVariants = cva(
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
default: "h-10 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
lg: "h-11 rounded-md px-6 has-[>svg]:px-4",
icon: "size-10",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
"icon-lg": "size-11",
},
},
defaultVariants: {
+47
View File
@@ -0,0 +1,47 @@
"use client";
import * as React from "react";
import { Checkbox as CheckboxPrimitive } from "radix-ui";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* v1.4.27 R3d MB4 / CF-40 — shadcn-style `<Checkbox>` primitive backed
* by `radix-ui`'s `Checkbox` namespace. Brought in so the Coach
* sources-rail can drop its raw `<input type="checkbox">` for a
* keyboard + touch accessible control that ships proper focus rings
* and a 44 px hit target on mobile.
*
* Mirrors the v1.4.27 shadcn `<Checkbox>` recipe: a Root that draws
* the border + checked-state background, an Indicator slot that
* shows the lucide `<Check>` when the box is checked.
*/
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-border/70 size-4 shrink-0 rounded border",
"data-[state=checked]:bg-dracula-purple data-[state=checked]:border-dracula-purple data-[state=checked]:text-background",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
"disabled:cursor-not-allowed disabled:opacity-50",
"transition-colors",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current"
>
<Check className="size-3" aria-hidden="true" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };
+13 -2
View File
@@ -61,16 +61,27 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
// v1.4.27 R3d MB1 — cap `<DialogContent>` at the viewport's
// dynamic height with internal scroll so long forms (medication
// editor, API endpoint, intake import) never spill past the
// viewport on short / soft-keyboard-collapsed mobile screens.
// Consumers that need a different ceiling can override via
// `className`.
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] max-h-[calc(100dvh-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
// v1.4.27 R3d MB2 — widen the icon-only close-X tap target
// to a 36 px floor per WCAG 2.5.8 minimum target size.
// 36 px (not 44 px) is the intentional exception for fixed
// icon-only close affordances per Decision I — 44 px would
// crowd the dialog header out of proportion.
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus-visible:border-ring focus-visible:ring-ring/50 text-muted-foreground hover:bg-accent hover:text-foreground absolute top-5 right-6 inline-flex h-6 w-6 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"
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>
+1 -1
View File
@@ -74,7 +74,7 @@ function DropdownMenuItem({
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex min-h-11 cursor-default items-center gap-2 rounded-sm px-2 py-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
+28 -1
View File
@@ -36,6 +36,16 @@ export interface EmptyStateProps extends Omit<
* Compact density for use inside tiles or table-row empty rows.
*/
size?: "default" | "compact";
/**
* v1.4.27 MB7 / CF-36 — size hint for the inner action wrapper.
*
* `"default"` keeps the legacy inline-tight CTA. `"lg"` adds a
* `[&_button]:min-h-11 [&_a]:min-h-11` selector so the inner button
* or `asChild` link meets the 44 px floor on mobile, and lifts the
* wrapper to `w-full sm:w-auto` so the CTA fills the column at
* narrow viewports and centres at `sm+`.
*/
ctaSize?: "default" | "lg";
}
function EmptyState({
@@ -45,6 +55,7 @@ function EmptyState({
action,
variant = "card",
size = "default",
ctaSize = "default",
className,
...props
}: EmptyStateProps) {
@@ -81,7 +92,23 @@ function EmptyState({
</p>
) : null}
</div>
{action ? <div className="mt-1">{action}</div> : null}
{action ? (
<div
className={cn(
"mt-1",
// v1.4.27 MB7 / CF-36 — when `ctaSize === "lg"` the wrapper
// lifts to full-width on mobile and constrains the inner
// `<Button>` / `asChild` `<Link>` to the 44 px tap floor.
// The selectors target the wrapper's immediate child so
// existing CTAs that pass a plain `<Button>` (no class
// overrides) pick up the lift automatically.
ctaSize === "lg" &&
"w-full sm:w-auto [&>a]:min-h-11 [&>a]:w-full [&>button]:min-h-11 [&>button]:w-full sm:[&>a]:w-auto sm:[&>button]:w-auto",
)}
>
{action}
</div>
) : null}
</div>
);
+31 -1
View File
@@ -19,25 +19,55 @@ import { cn } from "@/lib/utils";
* `autoComplete` is anything other than `"off"`, the LastPass /
* 1Password ignore attrs are dropped so password managers fill the
* field normally.
*
* v1.4.27 mobile pass: derive a sensible `inputMode` default from the
* `type` prop so every numeric / email / tel / url / search field
* surfaces the right on-screen keyboard on iOS Safari and Android
* Chrome without each call site having to repeat the attribute. Most
* `type="number"` fields in HealthLog accept decimals (kg, mmol/L,
* sleep hours), so the default is `"decimal"`; integer-only call
* sites can still pass `inputMode="numeric"` explicitly.
*/
function deriveInputMode(
type: React.HTMLInputTypeAttribute | undefined,
): React.HTMLAttributes<HTMLInputElement>["inputMode"] | undefined {
switch (type) {
case "number":
return "decimal";
case "tel":
return "tel";
case "email":
return "email";
case "url":
return "url";
case "search":
return "search";
default:
return undefined;
}
}
function Input({
className,
type,
autoComplete,
inputMode,
...props
}: React.ComponentProps<"input">) {
const resolvedAutoComplete = autoComplete ?? "off";
const skipAutofill = resolvedAutoComplete === "off";
const resolvedInputMode = inputMode ?? deriveInputMode(type);
return (
<input
type={type}
data-slot="input"
autoComplete={resolvedAutoComplete}
inputMode={resolvedInputMode}
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-9 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",
"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",
"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,
+57
View File
@@ -0,0 +1,57 @@
"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, 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.
*
* Height: `h-10` (40 px) — matches the v1.4.27 tap-target floor on
* `<Input>` and `<SelectTrigger>` (MB2). The legacy `h-9` (36 px)
* the three call sites carried was the pre-v1.4.27 contract; the
* primitive extraction lands at the new floor so every form input on
* the same row reads at the same height.
*
* 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-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>;
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>
);
});
+51
View File
@@ -0,0 +1,51 @@
"use client";
import { useState } from "react";
import { Eye, EyeOff } from "lucide-react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
/**
* Password input with a built-in show/hide toggle. Lives in the shared UI
* layer so every surface that needs a masked secret (Withings, moodLog,
* ntfy, Telegram, AI provider keys, account password, auth login /
* register) can share the same UX without duplicating the
* visibility-toggle plumbing.
*
* v1.4.27 R3d MB2 — the icon-only toggle wraps in an `inline-flex h-11
* w-11` hit area so the 44 px WCAG 2.5.5 floor is honoured on touch.
* The icon stays at 16 px; the surrounding padding does the work. The
* input itself gets `pr-12` so user input never collides with the
* toggle glyph.
*/
export function PasswordInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
const [visible, setVisible] = useState(false);
const { t } = useTranslations();
return (
<div className="relative">
<Input
{...props}
type={visible ? "text" : "password"}
className={cn("pr-12", className)}
/>
<button
type="button"
tabIndex={-1}
// WCAG 2.1.2 button-name — the icon-only toggle has no visible text
// and screen readers fall back to the icon's filename otherwise.
aria-label={
visible ? t("common.hidePassword") : t("common.showPassword")
}
onClick={() => setVisible((v) => !v)}
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-1 inline-flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-md"
>
{visible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
"use client";
import * as React from "react";
import { Popover as PopoverPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* Popover primitive — Radix `Popover` wrapped with the same Dracula
* surface tokens the rest of the shadcn primitives use.
*
* v1.4.27 MB3 — introduced so the Coach composer hint can swap off
* the `<Tooltip>` primitive. Radix tooltips never tap-toggle reliably
* on touch surfaces; popovers do. The mouse-hover preview that the
* tooltip used to provide on desktop is dropped intentionally —
* tap-to-open is the same gesture on every device.
*/
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
children,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground border-border animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-w-xs origin-(--radix-popover-content-transform-origin) rounded-md border p-3 text-xs shadow-md outline-none",
className,
)}
{...props}
>
{children}
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
);
}
export { Popover, PopoverTrigger, PopoverContent };
+194
View File
@@ -0,0 +1,194 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useIsMobile } from "@/hooks/use-is-mobile";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
export interface ResponsiveSheetProps {
/** Controlled open state. */
open: boolean;
/** Open-state setter forwarded to the underlying Sheet / Dialog root. */
onOpenChange: (open: boolean) => void;
/**
* Required accessible title. Rendered inside the sheet / dialog
* header so screen-readers always announce the surface, even when
* the visual header is empty.
*/
title: React.ReactNode;
/** Optional short description rendered beneath the title. */
description?: React.ReactNode;
/**
* Hide the visual header (title + description). The title remains
* mounted in an `sr-only` block so the accessible name is still
* announced. Use when the consumer renders its own header inside
* the body.
*/
hideHeader?: boolean;
/**
* Footer slot. The Sheet branch sticky-pins the footer to the
* bottom of the bottom-sheet so the primary CTA stays reachable
* once the body scrolls; the Dialog branch lets the footer flow
* normally beneath the body.
*/
footer?: React.ReactNode;
/** Class name applied to the content surface (sheet / dialog). */
className?: string;
/** Class name applied to the body wrapper. */
bodyClassName?: string;
/**
* Forward to the underlying Sheet/Dialog `showCloseButton`
* primitive prop. Defaults to `true`; pass `false` when the
* consumer renders its own close affordance inside the header.
*/
showCloseButton?: boolean;
children: React.ReactNode;
}
/**
* v1.4.27 R3d MB1 — shared mount primitive that flips between a
* bottom-anchored `<Sheet>` on narrow viewports and the existing
* centred `<Dialog>` on tablet/desktop. Picks the breakpoint via
* the `useIsMobile()` hook (Tailwind `md` — 768 px).
*
* On the Sheet branch we cap the content at `90dvh`, scroll the
* body on overflow, and sticky-pin the footer so Save / Cancel
* stay reachable when the keyboard pushes the bottom of the sheet
* up. The Dialog branch consumes the primitive's `max-h-[calc(100dvh-2rem)]`
* default so long forms scroll inside the dialog instead of
* spilling off-screen on tablet portrait.
*
* Consumers swap `<Dialog>` → `<ResponsiveSheet>` and feed the
* existing form markup through `children` / `footer`. The rest of
* the form's chrome is unchanged because both branches expose the
* same header / body / footer slot shape.
*/
export function ResponsiveSheet({
open,
onOpenChange,
title,
description,
hideHeader = false,
footer,
className,
bodyClassName,
showCloseButton = true,
children,
}: ResponsiveSheetProps) {
const isMobile = useIsMobile();
if (isMobile) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="bottom"
showCloseButton={showCloseButton}
data-slot="responsive-sheet-content"
data-variant="sheet"
className={cn(
// Cap at 90 dvh so the user sees a slice of the page
// behind the sheet (clear "this is a sheet, not a
// takeover") + the bottom-sheet rounded top edge that
// matches the iOS feel.
"flex max-h-[90dvh] flex-col gap-0 rounded-t-2xl p-0",
className,
)}
>
{hideHeader ? (
<SheetHeader className="sr-only">
<SheetTitle>{title}</SheetTitle>
{description ? (
<SheetDescription>{description}</SheetDescription>
) : null}
</SheetHeader>
) : (
<SheetHeader
data-slot="responsive-sheet-header"
className="border-border/70 gap-1.5 border-b p-4 pr-12"
>
<SheetTitle className="text-base font-semibold">
{title}
</SheetTitle>
{description ? (
<SheetDescription className="text-muted-foreground text-xs">
{description}
</SheetDescription>
) : null}
</SheetHeader>
)}
<div
data-slot="responsive-sheet-body"
className={cn(
"flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4",
bodyClassName,
)}
>
{children}
</div>
{footer ? (
<SheetFooter
data-slot="responsive-sheet-footer"
className="border-border/70 sticky bottom-0 mt-0 flex-row justify-end gap-2 border-t bg-background/95 p-4 backdrop-blur supports-[backdrop-filter]:bg-background/80"
>
{footer}
</SheetFooter>
) : null}
</SheetContent>
</Sheet>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={showCloseButton}
data-slot="responsive-sheet-content"
data-variant="dialog"
className={cn("sm:max-w-md", className)}
>
{hideHeader ? (
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
{description ? (
<DialogDescription>{description}</DialogDescription>
) : null}
</DialogHeader>
) : (
<DialogHeader data-slot="responsive-sheet-header">
<DialogTitle>{title}</DialogTitle>
{description ? (
<DialogDescription>{description}</DialogDescription>
) : null}
</DialogHeader>
)}
<div
data-slot="responsive-sheet-body"
className={cn("flex flex-col gap-4", bodyClassName)}
>
{children}
</div>
{footer ? (
<DialogFooter data-slot="responsive-sheet-footer">
{footer}
</DialogFooter>
) : null}
</DialogContent>
</Dialog>
);
}
+1 -1
View File
@@ -37,7 +37,7 @@ 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-9 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",
"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",
className,
)}
{...props}
+11 -2
View File
@@ -75,8 +75,17 @@ function SheetContent({
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
// v1.4.27 R4 RC3 — widen the icon-only close-X tap target to
// a 36 px floor, matching the Dialog close-X treatment from
// MB2 (Decision I). 36 px (not 44 px) is the intentional
// exception for fixed icon-only close affordances per the
// dialog primitive's rationale: 44 px would crowd the sheet
// header out of proportion on the bottom-sheet branch.
<SheetPrimitive.Close
data-slot="sheet-close-button"
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>
</SheetPrimitive.Close>
)}
+1 -1
View File
@@ -19,7 +19,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
loading: <Loader2Icon className="size-4 animate-spin motion-reduce:animate-none" />,
}}
style={
{