fix(mobile): clipping, dropdown-tabs and stale phone metadata
Five mobile-UX issues caught in the 2026-05-03 audit, fixed in one pass:
1. SpecRow on berth detail clipped at right edge on phone widths.
"Length 49.21 ft / 15 r" (the "m" cut off). Mobile-first stack:
label on top, value full-width below; flex row only from sm up.
2. ResponsiveTabs collapsed to a Select on phone widths, which read like
a generic dropdown and obscured the existence of peer tabs. Replaced
with a horizontally-scrollable strip that auto-scrolls the active
trigger into view (so the user sees neighbors and gets a discovery
cue that more exists beyond the edge). Removes the phone-only Select
and unifies the tab UI across viewport sizes.
3. Documents page tab strip ("All / EOI queue / Awaiting them / ...")
overflowed the 390px viewport because the wrapper was a fixed flex
row. Same horizontal-scroll fix as (2); inherits because Documents
uses ResponsiveTabs.
4. Berth detail header: "Change Status" + "Edit" buttons crowded the
area subtitle on mobile, causing "North Pier" to wrap to two lines
("North" / "Pier"). Stacked vertically on phone widths; from sm up
the buttons sit beside the title block as before.
5. Empty contact rows on client detail rendered a stale "Add tag · star"
metadata strip even when the contact value was unset, which cluttered
the row and offered no useful action. The metadata block now only
shows when contact.value is non-empty; the trash icon stays visible
so users can clean up the empty placeholder.
Verification:
- pnpm exec vitest run: 858/858 passing
- pnpm exec tsc --noEmit: same 36 errors as baseline (all pre-existing
on feat/mobile-foundation, none introduced)
- lint clean
Defers:
- Mobile More sheet last-row alignment / "Email" label specificity
- Admin index grouping (Access / System / Configuration / Content)
- Interest detail header icon labels (trophy/X discoverability)
- Pipeline funnel x-axis label abbreviations
- Reminders rail width allocation on dashboard
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -167,7 +167,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">
|
||||||
@@ -182,7 +185,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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -208,8 +208,14 @@ function ContactRow({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: tag + actions */}
|
{/* Right: tag + actions.
|
||||||
|
When the contact value is empty (e.g. a row created from a stale
|
||||||
|
import or an aborted edit), we hide the "Add tag" + Make-primary
|
||||||
|
controls so the empty placeholder doesn't clutter the row. The
|
||||||
|
trash icon is always shown so users can clean up the empty entry. */}
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{contact.value ? (
|
||||||
|
<>
|
||||||
<div className="w-28 text-xs text-muted-foreground text-right">
|
<div className="w-28 text-xs text-muted-foreground text-right">
|
||||||
<InlineEditableField
|
<InlineEditableField
|
||||||
value={
|
value={
|
||||||
@@ -234,6 +240,8 @@ function ContactRow({
|
|||||||
>
|
>
|
||||||
<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"
|
||||||
|
|||||||
@@ -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,37 +19,45 @@ 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"
|
||||||
|
>
|
||||||
|
<TabsList className="inline-flex w-max">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<SelectItem key={tab.id} value={tab.id}>
|
<TabsTrigger
|
||||||
<span className="flex items-center gap-1.5">
|
key={tab.id}
|
||||||
{tab.label}
|
value={tab.id}
|
||||||
{tab.badge !== undefined && tab.badge !== null && (
|
className="gap-1.5 whitespace-nowrap"
|
||||||
<span className="text-xs text-muted-foreground">({tab.badge})</span>
|
data-tab-id={tab.id}
|
||||||
)}
|
>
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</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.label}
|
||||||
{tab.badge !== undefined && tab.badge !== null && (
|
{tab.badge !== undefined && tab.badge !== null && (
|
||||||
<Badge variant="secondary" className="px-1.5 py-0 text-xs">
|
<Badge variant="secondary" className="px-1.5 py-0 text-xs">
|
||||||
@@ -66,6 +67,7 @@ export function ResponsiveTabs({ tabs, value, onValueChange }: ResponsiveTabsPro
|
|||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
))}
|
))}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
|
||||||
{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">
|
||||||
|
|||||||
Reference in New Issue
Block a user