mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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={
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user