Final Bucket 1 visual-audit follow-up. Audit of all 4 useIsMobile callers: - pipeline-chart.tsx + pipeline-funnel-chart.tsx → keep useIsMobile (short x-axis stage labels apply on tablet too — bar charts can't fit full "Reservation" / "Deposit Paid" text at narrow widths). - date-picker.tsx + date-time-picker.tsx → migrate to useViewportTier. Tablet (768-1023) has plenty of room for the desktop Popover Calendar; only the smallest phone widths now fall back to the native datepicker input. 1454/1454 vitest, tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
140 lines
4.2 KiB
TypeScript
140 lines
4.2 KiB
TypeScript
'use client';
|
|
|
|
import * as React from 'react';
|
|
import { Calendar as CalendarIcon } from 'lucide-react';
|
|
import { format } from 'date-fns';
|
|
|
|
import { Button, type ButtonProps } from '@/components/ui/button';
|
|
import { Calendar } from '@/components/ui/calendar';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
import { useViewportTier } from '@/hooks/use-is-mobile';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
export interface DatePickerProps {
|
|
/** ISO-date string (YYYY-MM-DD) or empty for unset. */
|
|
value?: string;
|
|
onChange: (next: string) => void;
|
|
placeholder?: string;
|
|
/** Disable the entire control. */
|
|
disabled?: boolean;
|
|
/** Limit selectable range — same shape react-day-picker accepts. */
|
|
fromDate?: Date;
|
|
toDate?: Date;
|
|
/** Optional className on the trigger Button (desktop) / Input (mobile). */
|
|
className?: string;
|
|
/** Tooltip text shown via `title` on the trigger. */
|
|
title?: string;
|
|
/** Trigger Button size on desktop. */
|
|
size?: ButtonProps['size'];
|
|
/** Optional id (forwarded to the trigger/input for `<label htmlFor>`). */
|
|
id?: string;
|
|
/** Forwarded `name` for native form submission paths. */
|
|
name?: string;
|
|
/**
|
|
* Force a particular variant regardless of viewport. Useful in tests
|
|
* or when a desktop popover is desired inside a mobile-only flow.
|
|
*/
|
|
forceVariant?: 'desktop' | 'mobile';
|
|
}
|
|
|
|
function parseIso(value: string | undefined): Date | undefined {
|
|
if (!value) return undefined;
|
|
const [y, m, d] = value.split('-').map(Number);
|
|
if (!y || !m || !d) return undefined;
|
|
const parsed = new Date(y, m - 1, d);
|
|
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
|
|
}
|
|
|
|
function toIso(date: Date | undefined): string {
|
|
if (!date) return '';
|
|
// Local YYYY-MM-DD (no timezone shift). `toISOString` would round-trip
|
|
// through UTC and could land on the previous day for users east of GMT.
|
|
return format(date, 'yyyy-MM-dd');
|
|
}
|
|
|
|
/**
|
|
* Cross-platform date picker. Desktop: shadcn Popover + Calendar
|
|
* (caption-dropdown nav so reps can jump months/years for backfill).
|
|
* Mobile: native `<input type="date">` for the OS-level picker —
|
|
* touch-friendly and zero bundle cost.
|
|
*
|
|
* Drop-in replacement for `<Input type="date">` — same `value` /
|
|
* `onChange` contract (YYYY-MM-DD).
|
|
*/
|
|
export function DatePicker({
|
|
value,
|
|
onChange,
|
|
placeholder,
|
|
disabled,
|
|
fromDate,
|
|
toDate,
|
|
className,
|
|
title,
|
|
size = 'default',
|
|
id,
|
|
name,
|
|
forceVariant,
|
|
}: DatePickerProps) {
|
|
// Strict mobile only — tablet (768-1023) has room for the desktop
|
|
// Popover Calendar; only the smallest phone widths fall back to the
|
|
// native datepicker input.
|
|
const tier = useViewportTier();
|
|
const variant = forceVariant ?? (tier === 'mobile' ? 'mobile' : 'desktop');
|
|
const [open, setOpen] = React.useState(false);
|
|
const selected = parseIso(value);
|
|
|
|
if (variant === 'mobile') {
|
|
return (
|
|
<Input
|
|
id={id}
|
|
name={name}
|
|
type="date"
|
|
value={value ?? ''}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
disabled={disabled}
|
|
className={className}
|
|
title={title}
|
|
min={fromDate ? toIso(fromDate) : undefined}
|
|
max={toDate ? toIso(toDate) : undefined}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
id={id}
|
|
variant="outline"
|
|
size={size}
|
|
disabled={disabled}
|
|
title={title}
|
|
className={cn(
|
|
'w-full justify-start font-normal',
|
|
!selected && 'text-muted-foreground',
|
|
className,
|
|
)}
|
|
>
|
|
<CalendarIcon aria-hidden />
|
|
{selected ? format(selected, 'PP') : (placeholder ?? 'Pick a date')}
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent align="start" className="w-auto p-0">
|
|
<Calendar
|
|
mode="single"
|
|
selected={selected}
|
|
onSelect={(d) => {
|
|
onChange(toIso(d));
|
|
setOpen(false);
|
|
}}
|
|
captionLayout="dropdown"
|
|
startMonth={fromDate}
|
|
endMonth={toDate}
|
|
autoFocus
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|