mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
Intake history stayed a 4-5 column table with two full datetime columns on mobile, forcing sideways scroll of a primary history surface; add the stacked card-list split below md (the measurement-list pattern) and lift the row kebab off a hard 32px box onto the size-11/size-9 floor. The documents bulk-action bar wrapped five labelled buttons into a 2-3 row floating slab at 360px; collapse to icon-only (aria-label carries the name) below sm, matching the document-detail-sheet footer's established pattern. Two coach/medication modals were still on raw Dialog while every sibling had moved to ResponsiveSheet: the schedule-era editor and the document attachment picker. Both now share the bottom-sheet-on-mobile primitive; the era editor's submit button associates to its form via the native `form` attribute since the footer slot sits outside the form's DOM subtree (the log-intake-dialog precedent). Tap-target floor: side-effect entry chips and the coach attachment-pill remove control were below the 44px floor; the chips get a min-h-11 flex-center, the pill remove gets an invisible hit-slop instead of growing the pill's visual footprint. Two insights empty-state CTAs were missing the existing `ctaSize="lg"` treatment already used by their sibling empty states.
180 lines
6.2 KiB
TypeScript
180 lines
6.2 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* Floating bulk-action bar for the vault's multi-select mode. Appears once
|
||
* at least one document is selected, pinned above the bottom nav on phones
|
||
* and near the bottom edge on desktop. Carries the selected count and the
|
||
* bulk verbs — set type, link condition, share (one link for the whole
|
||
* selection), delete (undo-able), clear — all driving the page's handlers.
|
||
*
|
||
* A `role="toolbar"` with proper labels; the destructive verb sits last
|
||
* before Clear so a stray tap sequence never ends on Delete. Share sits just
|
||
* before Delete (the safe actions lead). The bar is a deliberate hand-rolled
|
||
* shell (a floating toolbar is not a Card surface): dense-tile padding `p-3`
|
||
* per the standards.
|
||
*/
|
||
import { FolderPlus, Share2, Tag, Trash2, X } from "lucide-react";
|
||
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuTrigger,
|
||
} from "@/components/ui/dropdown-menu";
|
||
import { useTranslations } from "@/lib/i18n/context";
|
||
import { cn } from "@/lib/utils";
|
||
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
|
||
import { DOCUMENT_KIND_ICONS, DOCUMENT_KIND_ORDER } from "./document-kind-meta";
|
||
|
||
export interface BulkEpisodeOption {
|
||
id: string;
|
||
label: string;
|
||
}
|
||
|
||
export function DocumentBulkBar({
|
||
selectedCount,
|
||
episodes,
|
||
busy,
|
||
onSetKind,
|
||
onLinkEpisode,
|
||
onShare,
|
||
onDelete,
|
||
onClear,
|
||
}: {
|
||
selectedCount: number;
|
||
/** The caller's live illness episodes — the link-condition targets. */
|
||
episodes: BulkEpisodeOption[];
|
||
/** A bulk call is in flight — the verbs disable, Clear stays live. */
|
||
busy: boolean;
|
||
onSetKind: (kind: InboundDocumentKindValue) => void;
|
||
onLinkEpisode: (episodeId: string) => void;
|
||
/**
|
||
* Share the whole selection as ONE documents-only link. The page caps the
|
||
* selection at `SHARE_LINK_MAX_DOCUMENTS` and surfaces the over-cap hint —
|
||
* this handler just opens the share sheet seeded with the selection.
|
||
*/
|
||
onShare: () => void;
|
||
onDelete: () => void;
|
||
onClear: () => void;
|
||
}) {
|
||
const { t } = useTranslations();
|
||
|
||
return (
|
||
<div
|
||
data-slot="document-bulk-bar"
|
||
role="toolbar"
|
||
aria-label={t("documents.bulk.barLabel")}
|
||
className={cn(
|
||
"bg-card border-border fixed bottom-20 left-1/2 z-40 -translate-x-1/2 md:bottom-6",
|
||
"flex w-[calc(100%-2rem)] max-w-2xl flex-wrap items-center gap-2 rounded-xl border p-3 shadow-lg",
|
||
)}
|
||
>
|
||
<p className="px-1 text-sm font-medium whitespace-nowrap" role="status">
|
||
{t("documents.selection.count", { count: selectedCount })}
|
||
</p>
|
||
|
||
<div className="ml-auto flex flex-wrap items-center justify-end gap-1.5">
|
||
{/* v1.30 mobile audit (DO-1) — five labelled buttons wrapped into a
|
||
2–3-row floating slab at ~360 px. Below `sm` every verb collapses
|
||
to icon-only (`aria-label` carries the accessible name; the
|
||
visible label returns at `sm+`) — the `document-detail-sheet`
|
||
footer's established icon-collapse pattern. */}
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={busy}
|
||
aria-label={t("documents.bulk.setKind")}
|
||
>
|
||
<Tag className="size-4" aria-hidden />
|
||
<span className="hidden sm:inline">
|
||
{t("documents.bulk.setKind")}
|
||
</span>
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end">
|
||
{DOCUMENT_KIND_ORDER.map((kind) => {
|
||
const Icon = DOCUMENT_KIND_ICONS[kind];
|
||
return (
|
||
<DropdownMenuItem key={kind} onSelect={() => onSetKind(kind)}>
|
||
<Icon className="size-4" aria-hidden />
|
||
{t(`documents.kind.${kind}`)}
|
||
</DropdownMenuItem>
|
||
);
|
||
})}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
|
||
{episodes.length > 0 ? (
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={busy}
|
||
aria-label={t("documents.bulk.linkCondition")}
|
||
>
|
||
<FolderPlus className="size-4" aria-hidden />
|
||
<span className="hidden sm:inline">
|
||
{t("documents.bulk.linkCondition")}
|
||
</span>
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end">
|
||
{episodes.map((episode) => (
|
||
<DropdownMenuItem
|
||
key={episode.id}
|
||
onSelect={() => onLinkEpisode(episode.id)}
|
||
>
|
||
<span className="max-w-56 truncate">{episode.label}</span>
|
||
</DropdownMenuItem>
|
||
))}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
) : null}
|
||
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={busy}
|
||
onClick={onShare}
|
||
data-slot="document-bulk-share"
|
||
aria-label={t("documents.bulk.share")}
|
||
>
|
||
<Share2 className="size-4" aria-hidden />
|
||
<span className="hidden sm:inline">{t("documents.bulk.share")}</span>
|
||
</Button>
|
||
|
||
{/* Solid destructive (matching the detail sheet's Delete) — the
|
||
outline variant's destructive text on the card surface fails the
|
||
WCAG contrast gate. */}
|
||
<Button
|
||
variant="destructive"
|
||
size="sm"
|
||
disabled={busy}
|
||
onClick={onDelete}
|
||
aria-label={t("documents.bulk.delete")}
|
||
>
|
||
<Trash2 className="size-4" aria-hidden />
|
||
<span className="hidden sm:inline">{t("documents.bulk.delete")}</span>
|
||
</Button>
|
||
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="text-muted-foreground"
|
||
onClick={onClear}
|
||
aria-label={t("documents.selection.clear")}
|
||
>
|
||
<X className="size-3.5" aria-hidden />
|
||
<span className="hidden sm:inline">
|
||
{t("documents.selection.clear")}
|
||
</span>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|