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

@@ -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}