mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge wave-mobile-rest: phone tap targets, shared list-row, scroll-touch
Raise primary touch controls across the non-Settings surfaces to a 44px floor that condenses at sm+, add a shared ListRow primitive and migrate the three bespoke list-row mini-cards onto it, and add a scroll-touch utility for the horizontal scrollers.
This commit is contained in:
commit
b10d978bce
13 files changed
+128
-30
No files matched your search
@@ -448,6 +448,21 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* `.scroll-touch` — momentum scrolling for horizontal scrollers on iOS
|
||||
* WebKit. `-webkit-overflow-scrolling: touch` restores the inertial
|
||||
* flick the default `overflow-x-auto` row loses on touch; `overscroll-
|
||||
* behavior: contain` keeps a flick that reaches the end of the row from
|
||||
* chaining into the document scroll (the "rubber-band drags the whole
|
||||
* page" paper-cut). Applied to the in-app horizontal pill / tab strips
|
||||
* (insights tab strip, cycle tab list) so the gesture matches the
|
||||
* native feel the bottom-sheet scrollers already get for free.
|
||||
*/
|
||||
.scroll-touch {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
/* ── Insight card animations ─────────────────────── */
|
||||
@keyframes insight-fade-in {
|
||||
from {
|
||||
|
||||
@@ -255,7 +255,7 @@ export function CycleView() {
|
||||
triggers size to their content (`text-xs`, no `flex-1`) and the
|
||||
strip scrolls cleanly if needed; from `sm` up they reclaim the
|
||||
equal-width `flex-1` layout. */}
|
||||
<TabsList className="w-full max-w-full overflow-x-auto">
|
||||
<TabsList className="scroll-touch w-full max-w-full overflow-x-auto">
|
||||
<TabsTrigger
|
||||
value="calendar"
|
||||
className="text-xs sm:flex-1 sm:text-sm"
|
||||
|
||||
@@ -258,15 +258,17 @@ export function DashboardHero({
|
||||
cta.kind === "link" ? (
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
size="default"
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="dashboard-hero-cta"
|
||||
>
|
||||
<Link href={cta.href}>{t(ctaLabelKey)}</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
size="default"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="dashboard-hero-cta"
|
||||
onClick={() => onQuickEntry(cta.target)}
|
||||
>
|
||||
|
||||
@@ -109,7 +109,7 @@ function EpisodeCard({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-9"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => onLogDay(episode.id)}
|
||||
>
|
||||
{t("illness.logDay")}
|
||||
|
||||
@@ -175,6 +175,39 @@ describe("<CoachInput>", () => {
|
||||
expect(html).toContain("Stopp");
|
||||
});
|
||||
|
||||
it("sizes the send control to the 44px tap-target floor on phones", () => {
|
||||
// v1.18.7 — the composer controls (send / stop / mic) must clear the
|
||||
// 44px WCAG 2.5.5 floor on phones and condense at `sm:` upward. Guard
|
||||
// the `size-11 sm:size-9` pattern so a future restyle can't quietly
|
||||
// drop the send button back to 36px.
|
||||
const html = render(
|
||||
<CoachInput value="Hi" onChange={() => {}} onSubmit={() => {}} />,
|
||||
);
|
||||
const sendTag = html.match(
|
||||
/<button[^>]*data-slot="coach-input-send"[^>]*>/,
|
||||
);
|
||||
expect(sendTag?.[0]).toMatch(/\bsize-11\b/);
|
||||
expect(sendTag?.[0]).toMatch(/\bsm:size-9\b/);
|
||||
});
|
||||
|
||||
it("sizes the stop control to the 44px tap-target floor on phones", () => {
|
||||
const html = render(
|
||||
<CoachInput
|
||||
value="Hi"
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
onCancel={() => {}}
|
||||
disabled
|
||||
isStreaming
|
||||
/>,
|
||||
);
|
||||
const stopTag = html.match(
|
||||
/<button[^>]*data-slot="coach-input-stop"[^>]*>/,
|
||||
);
|
||||
expect(stopTag?.[0]).toMatch(/\bsize-11\b/);
|
||||
expect(stopTag?.[0]).toMatch(/\bsm:size-9\b/);
|
||||
});
|
||||
|
||||
it("invokes onChange when the parent passes a controlled handler", () => {
|
||||
// SSR can't fire DOM events; smoke-check the contract by calling
|
||||
// the supplied handler directly.
|
||||
|
||||
@@ -352,7 +352,7 @@ export function CoachInput({
|
||||
: t("insights.coach.dictate")
|
||||
}
|
||||
className={cn(
|
||||
"size-9 shrink-0 rounded-xl transition-colors",
|
||||
"size-11 shrink-0 rounded-xl transition-colors sm:size-9",
|
||||
listening
|
||||
? "text-dracula-pink bg-dracula-pink/10 hover:text-dracula-pink"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
@@ -396,8 +396,8 @@ export function CoachInput({
|
||||
// Desktop shrinks back to `text-sm` for the compact
|
||||
// composer.
|
||||
"min-w-0 flex-1 resize-none bg-transparent text-base leading-relaxed outline-none sm:text-sm",
|
||||
// Centre the single-line state against the 36 px send
|
||||
// button so the placeholder and the icon share a baseline.
|
||||
// Centre the single-line state against the send button so
|
||||
// the placeholder and the icon share a baseline.
|
||||
"px-2 py-1.5",
|
||||
"max-h-[9.5rem] overflow-auto",
|
||||
// v1.18.7 — calm, thin scrollbar inside the composer when
|
||||
@@ -420,7 +420,7 @@ export function CoachInput({
|
||||
data-slot="coach-input-stop"
|
||||
aria-label={t("insights.coach.stop")}
|
||||
title={t("insights.coach.stop")}
|
||||
className="size-9 shrink-0 rounded-xl"
|
||||
className="size-11 shrink-0 rounded-xl sm:size-9"
|
||||
>
|
||||
<Square className="size-3.5 fill-current" aria-hidden="true" />
|
||||
</Button>
|
||||
@@ -432,7 +432,7 @@ export function CoachInput({
|
||||
data-slot="coach-input-send"
|
||||
aria-label={t("insights.coach.send")}
|
||||
title={t("insights.coach.send")}
|
||||
className="size-9 shrink-0 rounded-xl"
|
||||
className="size-11 shrink-0 rounded-xl sm:size-9"
|
||||
>
|
||||
{isStreaming ? (
|
||||
<Loader2
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ListRow } from "@/components/ui/list-row";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -230,28 +231,28 @@ function BriefingRow({
|
||||
);
|
||||
if (href) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
<ListRow
|
||||
asChild
|
||||
data-slot={dataSlot}
|
||||
data-metric={sourceMetric}
|
||||
className={cn(
|
||||
"border-border/60 bg-card/40 relative flex items-start gap-3 rounded-md border p-3",
|
||||
"border-border/60 bg-card/40 relative flex items-start gap-3",
|
||||
"hover:bg-accent/40 transition-colors",
|
||||
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
|
||||
)}
|
||||
>
|
||||
{rowContent}
|
||||
</Link>
|
||||
<Link href={href}>{rowContent}</Link>
|
||||
</ListRow>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
<ListRow
|
||||
data-slot={dataSlot}
|
||||
data-metric={sourceMetric}
|
||||
className="border-border/60 bg-card/40 relative flex items-start gap-3 rounded-md border p-3"
|
||||
className="border-border/60 bg-card/40 relative flex items-start gap-3"
|
||||
>
|
||||
{rowContent}
|
||||
</div>
|
||||
</ListRow>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -709,7 +709,7 @@ function InsightsTabStripImpl({
|
||||
// gives that room back to the layout so the strip height is
|
||||
// unchanged. `px-1 -mx-1` does the same horizontally so the
|
||||
// first/last pill's focus ring isn't clipped at the row edges.
|
||||
className="-mx-1 -my-1 flex min-w-0 flex-1 [scrollbar-width:none] gap-2 overflow-x-auto px-1 py-1 [&::-webkit-scrollbar]:hidden"
|
||||
className="scroll-touch -mx-1 -my-1 flex min-w-0 flex-1 [scrollbar-width:none] gap-2 overflow-x-auto px-1 py-1 [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
if (tab.kind === "link") {
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ListRow } from "@/components/ui/list-row";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { MeasurementForm } from "@/components/measurements/measurement-form";
|
||||
@@ -119,10 +120,12 @@ export function VorsorgeDashboardCard() {
|
||||
const due = relativeDueKey(reminder.nextDueAt, now);
|
||||
const isLinked = reminder.measurementType != null;
|
||||
return (
|
||||
<li
|
||||
<ListRow
|
||||
key={reminder.id}
|
||||
className="flex items-center justify-between gap-3 rounded-md border p-2.5"
|
||||
asChild
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<li>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{resolveLabel(reminder, t)}
|
||||
@@ -139,8 +142,8 @@ export function VorsorgeDashboardCard() {
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
size="default"
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
onClick={() => onPrimaryAction(reminder)}
|
||||
disabled={satisfy.isPending}
|
||||
>
|
||||
@@ -156,7 +159,8 @@ export function VorsorgeDashboardCard() {
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</li>
|
||||
</li>
|
||||
</ListRow>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
@@ -794,8 +794,8 @@ function VorsorgeCard({
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11"
|
||||
size="icon-lg"
|
||||
className="sm:size-10"
|
||||
aria-label={t("common.moreOptions")}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
@@ -909,7 +909,7 @@ function VorsorgeCard({
|
||||
type="button"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"min-h-9",
|
||||
"min-h-11 sm:min-h-9",
|
||||
isDue && "bg-success text-success-foreground hover:bg-success/90",
|
||||
)}
|
||||
onClick={onPrimaryAction}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ListRow } from "@/components/ui/list-row";
|
||||
import {
|
||||
FilterBar,
|
||||
FilterBarDateRange,
|
||||
@@ -1080,10 +1081,10 @@ export function MeasurementList({
|
||||
const drilldownId = `drilldown-mobile-${m.dayKey ?? m.id}`;
|
||||
const isSelected = selectedIds.has(m.id);
|
||||
return (
|
||||
<div
|
||||
<ListRow
|
||||
key={m.id}
|
||||
data-state={isSelected ? "selected" : undefined}
|
||||
className="bg-card border-border data-[state=selected]:border-dracula-purple/60 data-[state=selected]:bg-dracula-purple/5 rounded-lg border p-3"
|
||||
className="bg-card border-border data-[state=selected]:border-dracula-purple/60 data-[state=selected]:bg-dracula-purple/5"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
@@ -1243,7 +1244,7 @@ export function MeasurementList({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ListRow>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -243,7 +243,7 @@ export function Step3Dose({ payload, applyPartial }: StepProps) {
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-9 w-full justify-between px-2"
|
||||
className="min-h-11 w-full justify-between px-2 sm:min-h-9"
|
||||
aria-expanded={allowedOpen}
|
||||
onClick={() => setAllowedOpen((prev) => !prev)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* v1.18.7 — shared list-row primitive.
|
||||
*
|
||||
* Before this, every list-row "mini-card" (a bordered row sitting inside
|
||||
* a section or a `<Card>`) invented its own padding + radius: the
|
||||
* Vorsorge dashboard card used `rounded-md p-2.5`, the daily-briefing
|
||||
* rows `rounded-md p-3`, the measurement-list mobile row a bespoke
|
||||
* `rounded-lg p-3` div. Three surfaces, three shapes for one visual
|
||||
* concept. `ListRow` pins the canonical shape — `rounded-lg border p-3`
|
||||
* — so the rows read as one language across the app.
|
||||
*
|
||||
* It owns ONLY the box shape (radius + border + padding). Layout
|
||||
* (flex direction, alignment, gap), colour overrides, and interaction
|
||||
* state stay with the caller via `className`, so migrating a bespoke row
|
||||
* is purely dropping the radius/border/padding literals and keeping the
|
||||
* rest.
|
||||
*
|
||||
* `asChild` renders the row as its single child (e.g. a `<Link>` or
|
||||
* `<li>`) while still applying the shared classes, matching the shadcn
|
||||
* primitive convention used elsewhere in `ui/`.
|
||||
*/
|
||||
function ListRow({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "div";
|
||||
return (
|
||||
<Comp
|
||||
data-slot="list-row"
|
||||
className={cn("rounded-lg border p-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { ListRow };
|
||||
Reference in new issue
Block a user