Merge feat/mobile-ux-polish: berth/header/tab/contacts mobile fixes
# Conflicts: # src/components/clients/contacts-editor.tsx
This commit is contained in:
@@ -178,7 +178,10 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
|
||||
return (
|
||||
<>
|
||||
<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 items-center gap-3 flex-wrap">
|
||||
<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>}
|
||||
</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">
|
||||
<Button variant="outline" size="sm" onClick={() => setStatusOpen(true)}>
|
||||
<RefreshCw className="mr-1.5 h-4 w-4" />
|
||||
|
||||
@@ -48,10 +48,13 @@ type BerthData = {
|
||||
|
||||
function SpecRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
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 (
|
||||
<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="font-medium text-right max-w-[60%]">{value}</span>
|
||||
<span className="font-medium sm:max-w-[60%] sm:text-right">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -223,40 +223,54 @@ function ContactRow({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom / right: tag + actions. Hidden while the phone editor is active
|
||||
to keep focus on the form — no chips fighting for space, no noise. */}
|
||||
{/* Bottom / right: tag + actions.
|
||||
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 ? (
|
||||
<div className="flex shrink-0 items-center justify-end gap-2">
|
||||
<div className="w-28 text-right text-xs text-muted-foreground">
|
||||
<InlineEditableField
|
||||
value={
|
||||
contact.label && contact.label.toLowerCase() !== 'primary' ? contact.label : null
|
||||
}
|
||||
emptyText="Add tag"
|
||||
placeholder="work, home…"
|
||||
onSave={async (v) => {
|
||||
await onUpdate({ label: v });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{contact.value ? (
|
||||
<>
|
||||
<div className="w-28 text-right text-xs text-muted-foreground">
|
||||
<InlineEditableField
|
||||
value={
|
||||
contact.label && contact.label.toLowerCase() !== 'primary'
|
||||
? contact.label
|
||||
: null
|
||||
}
|
||||
emptyText="Add tag"
|
||||
placeholder="work, home…"
|
||||
onSave={async (v) => {
|
||||
await onUpdate({ label: v });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePrimary}
|
||||
title={contact.isPrimary ? 'Primary' : 'Make primary'}
|
||||
className={cn(
|
||||
'rounded p-1 transition-colors hover:bg-background/60',
|
||||
contact.isPrimary ? 'text-primary' : 'text-muted-foreground/50',
|
||||
)}
|
||||
>
|
||||
<Star className={cn('h-3.5 w-3.5', contact.isPrimary && 'fill-current')} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePrimary}
|
||||
title={contact.isPrimary ? 'Primary' : 'Make primary'}
|
||||
className={cn(
|
||||
'rounded p-1 transition-colors hover:bg-background/60',
|
||||
contact.isPrimary ? 'text-primary' : 'text-muted-foreground/50',
|
||||
)}
|
||||
>
|
||||
<Star className={cn('h-3.5 w-3.5', contact.isPrimary && 'fill-current')} />
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
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"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { type ReactNode } from 'react';
|
||||
import { useEffect, useRef, type ReactNode } from 'react';
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
export interface ResponsiveTab {
|
||||
id: string;
|
||||
@@ -26,47 +19,56 @@ interface ResponsiveTabsProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Tabs that collapse to a native <Select> on phone-sized viewports.
|
||||
* Above sm: TabsList renders. At/below sm: a Select dropdown replaces the tab strip.
|
||||
* Tab strip that scrolls horizontally on narrow viewports. The active tab is
|
||||
* 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) {
|
||||
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 (
|
||||
<Tabs value={value} onValueChange={onValueChange}>
|
||||
{/* Mobile: select dropdown */}
|
||||
<div className="sm:hidden">
|
||||
<Select value={value} onValueChange={onValueChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tabs.map((tab) => (
|
||||
<SelectItem key={tab.id} value={tab.id}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{tab.label}
|
||||
{tab.badge !== undefined && tab.badge !== null && (
|
||||
<span className="text-xs text-muted-foreground">({tab.badge})</span>
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* Single scrollable strip for all viewport widths.
|
||||
The wrapper handles horizontal overflow with momentum scroll on
|
||||
touch devices; the inner TabsList stays its natural width and
|
||||
slides under the wrapper. */}
|
||||
<div
|
||||
ref={listRef}
|
||||
className="overflow-x-auto -mx-2 px-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
<TabsList className="inline-flex w-max">
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
className="gap-1.5 whitespace-nowrap"
|
||||
data-tab-id={tab.id}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.badge !== undefined && tab.badge !== null && (
|
||||
<Badge variant="secondary" className="px-1.5 py-0 text-xs">
|
||||
{tab.badge}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</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) => (
|
||||
<TabsContent key={tab.id} value={tab.id} className="mt-4">
|
||||
{tab.content}
|
||||
|
||||
Reference in New Issue
Block a user