Merge feat/mobile-ux-polish: berth/header/tab/contacts mobile fixes

# Conflicts:
#	src/components/clients/contacts-editor.tsx
This commit is contained in:
Matt Ciaccio
2026-05-03 16:20:12 +02:00
4 changed files with 95 additions and 73 deletions

View File

@@ -178,7 +178,10 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
return ( return (
<> <>
<DetailHeaderStrip> <DetailHeaderStrip>
<div className="flex items-start gap-4"> {/* Stacks vertically on phone widths so the action buttons don't
squeeze the area subtitle into a two-line wrap. From sm up the
title/area block sits side-by-side with the action buttons. */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<h1 className="hidden sm:block text-2xl font-bold text-foreground"> <h1 className="hidden sm:block text-2xl font-bold text-foreground">
@@ -193,7 +196,7 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
{berth.area && <p className="text-muted-foreground mt-1">{berth.area}</p>} {berth.area && <p className="text-muted-foreground mt-1">{berth.area}</p>}
</div> </div>
<div className="flex flex-wrap items-center gap-2 shrink-0"> <div className="flex flex-wrap items-center gap-2 sm:shrink-0">
<PermissionGate resource="berths" action="edit"> <PermissionGate resource="berths" action="edit">
<Button variant="outline" size="sm" onClick={() => setStatusOpen(true)}> <Button variant="outline" size="sm" onClick={() => setStatusOpen(true)}>
<RefreshCw className="mr-1.5 h-4 w-4" /> <RefreshCw className="mr-1.5 h-4 w-4" />

View File

@@ -48,10 +48,13 @@ type BerthData = {
function SpecRow({ label, value }: { label: string; value: React.ReactNode }) { function SpecRow({ label, value }: { label: string; value: React.ReactNode }) {
if (!value && value !== 0 && value !== false) return null; if (!value && value !== 0 && value !== false) return null;
// Mobile-first: stack vertically with label on top so long values
// (e.g. "206.69 ft / 62.99 m") never clip at the right edge.
// From `sm` (>=640px) up: switch to the original two-column layout.
return ( return (
<div className="flex justify-between py-2 text-sm"> <div className="flex flex-col gap-0.5 py-2 text-sm sm:flex-row sm:items-baseline sm:justify-between sm:gap-3">
<span className="text-muted-foreground">{label}</span> <span className="text-muted-foreground">{label}</span>
<span className="font-medium text-right max-w-[60%]">{value}</span> <span className="font-medium sm:max-w-[60%] sm:text-right">{value}</span>
</div> </div>
); );
} }

View File

@@ -223,40 +223,54 @@ function ContactRow({
</div> </div>
</div> </div>
{/* Bottom / right: tag + actions. Hidden while the phone editor is active {/* Bottom / right: tag + actions.
to keep focus on the form — no chips fighting for space, no noise. */} Two layers of hiding compose here:
(a) phoneEditing — when the phone editor is open, hide the entire
action cluster (tag + star + trash) so the user can focus on
the form without chips fighting for space.
(b) contact.value — when the value is empty (stale import row,
aborted edit), hide just the tag + Make-primary star;
neither makes sense without a value. The trash icon stays
so the user can clean up the empty entry.
On touch (no hover), trash is always rendered; on desktop it
fades in on hover only (sm:opacity-0 + sm:group-hover:opacity-100). */}
{!phoneEditing ? ( {!phoneEditing ? (
<div className="flex shrink-0 items-center justify-end gap-2"> <div className="flex shrink-0 items-center justify-end gap-2">
<div className="w-28 text-right text-xs text-muted-foreground"> {contact.value ? (
<InlineEditableField <>
value={ <div className="w-28 text-right text-xs text-muted-foreground">
contact.label && contact.label.toLowerCase() !== 'primary' ? contact.label : null <InlineEditableField
} value={
emptyText="Add tag" contact.label && contact.label.toLowerCase() !== 'primary'
placeholder="work, home…" ? contact.label
onSave={async (v) => { : null
await onUpdate({ label: v }); }
}} emptyText="Add tag"
/> placeholder="work, home…"
</div> onSave={async (v) => {
await onUpdate({ label: v });
}}
/>
</div>
<button <button
type="button" type="button"
onClick={togglePrimary} onClick={togglePrimary}
title={contact.isPrimary ? 'Primary' : 'Make primary'} title={contact.isPrimary ? 'Primary' : 'Make primary'}
className={cn( className={cn(
'rounded p-1 transition-colors hover:bg-background/60', 'rounded p-1 transition-colors hover:bg-background/60',
contact.isPrimary ? 'text-primary' : 'text-muted-foreground/50', contact.isPrimary ? 'text-primary' : 'text-muted-foreground/50',
)} )}
> >
<Star className={cn('h-3.5 w-3.5', contact.isPrimary && 'fill-current')} /> <Star className={cn('h-3.5 w-3.5', contact.isPrimary && 'fill-current')} />
</button> </button>
</>
) : null}
<button <button
type="button" type="button"
onClick={onRemove} onClick={onRemove}
title="Remove" title="Remove"
// Trash is opacity-0 on desktop hover-only; on touch, always show.
className="rounded p-1 text-muted-foreground/50 transition-all hover:bg-background/60 hover:text-destructive sm:opacity-0 sm:group-hover:opacity-100" className="rounded p-1 text-muted-foreground/50 transition-all hover:bg-background/60 hover:text-destructive sm:opacity-0 sm:group-hover:opacity-100"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />

View File

@@ -1,16 +1,9 @@
'use client'; 'use client';
import { type ReactNode } from 'react'; import { useEffect, useRef, type ReactNode } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
export interface ResponsiveTab { export interface ResponsiveTab {
id: string; id: string;
@@ -26,47 +19,56 @@ interface ResponsiveTabsProps {
} }
/** /**
* Tabs that collapse to a native <Select> on phone-sized viewports. * Tab strip that scrolls horizontally on narrow viewports. The active tab is
* Above sm: TabsList renders. At/below sm: a Select dropdown replaces the tab strip. * automatically scrolled into view so users can tell at a glance that more
* tabs exist beyond the visible edge.
*
* Previously this collapsed to a <Select> on phone widths, but that read as
* a generic dropdown and obscured the fact that multiple peer tabs exist.
*/ */
export function ResponsiveTabs({ tabs, value, onValueChange }: ResponsiveTabsProps) { export function ResponsiveTabs({ tabs, value, onValueChange }: ResponsiveTabsProps) {
const listRef = useRef<HTMLDivElement>(null);
// Keep the active trigger in view when the value changes externally
// (e.g. ?tab= in the URL or a back/forward navigation).
useEffect(() => {
const root = listRef.current;
if (!root) return;
const active = root.querySelector<HTMLButtonElement>(`[data-tab-id="${CSS.escape(value)}"]`);
if (active) {
active.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
}
}, [value]);
return ( return (
<Tabs value={value} onValueChange={onValueChange}> <Tabs value={value} onValueChange={onValueChange}>
{/* Mobile: select dropdown */} {/* Single scrollable strip for all viewport widths.
<div className="sm:hidden"> The wrapper handles horizontal overflow with momentum scroll on
<Select value={value} onValueChange={onValueChange}> touch devices; the inner TabsList stays its natural width and
<SelectTrigger> slides under the wrapper. */}
<SelectValue /> <div
</SelectTrigger> ref={listRef}
<SelectContent> className="overflow-x-auto -mx-2 px-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
{tabs.map((tab) => ( >
<SelectItem key={tab.id} value={tab.id}> <TabsList className="inline-flex w-max">
<span className="flex items-center gap-1.5"> {tabs.map((tab) => (
{tab.label} <TabsTrigger
{tab.badge !== undefined && tab.badge !== null && ( key={tab.id}
<span className="text-xs text-muted-foreground">({tab.badge})</span> value={tab.id}
)} className="gap-1.5 whitespace-nowrap"
</span> data-tab-id={tab.id}
</SelectItem> >
))} {tab.label}
</SelectContent> {tab.badge !== undefined && tab.badge !== null && (
</Select> <Badge variant="secondary" className="px-1.5 py-0 text-xs">
{tab.badge}
</Badge>
)}
</TabsTrigger>
))}
</TabsList>
</div> </div>
{/* Desktop / tablet: tab strip */}
<TabsList className="hidden sm:flex">
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id} className="gap-1.5">
{tab.label}
{tab.badge !== undefined && tab.badge !== null && (
<Badge variant="secondary" className="px-1.5 py-0 text-xs">
{tab.badge}
</Badge>
)}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => ( {tabs.map((tab) => (
<TabsContent key={tab.id} value={tab.id} className="mt-4"> <TabsContent key={tab.id} value={tab.id} className="mt-4">
{tab.content} {tab.content}