Files
pn-new-crm/src/components/ui/date-picker.tsx

140 lines
4.2 KiB
TypeScript
Raw Normal View History

feat(uat-batch-3): wave-1 primitives — DatePicker, DateTimePicker, FileInputButton, ColumnPicker hideAll Builds the foundational primitives that subsequent waves depend on. None of these introduce new deps — date-fns, react-day-picker, and shadcn Calendar were already in the tree. - `<DatePicker>` and `<DateTimePicker>` in src/components/ui — desktop popover wrapping the existing shadcn Calendar (caption-dropdown nav so reps can jump months/years for the SkipAheadBanner backfill UX), mobile native input via useIsMobile. Drop-in for `<Input type=date>` / `<Input type=datetime-local>`. - `<FileInputButton>` in src/components/ui — styled Button + hidden input, replaces browser-default file picker UI. Most queued sweep sites already used the hidden-input + Button-trigger pattern; the primitive lands for any new caller plus consistent filename display + clear button. - ColumnPicker `hideAll()` footer item — symmetric to existing `showAll()`, with the same visibility gate. Lands platform-wide via the shared component. - Migrated highest-leverage call sites to the new primitives: * MilestoneAdvanceButton (backfill UX) * Reminder form (datetime-local → DateTimePicker) * Snooze dialog (datetime-local → DateTimePicker) * External-EOI upload dialog (date + file picker) * Payments section (received-on date) - Remaining 15+ date-input call sites parked for a follow-up sweep — several use react-hook-form `register` patterns that need careful migration to the new controlled-value contract. tsc clean. 1419/1419 vitest pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:10:02 +02:00
'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';
feat(uat-batch-3): wave-1 primitives — DatePicker, DateTimePicker, FileInputButton, ColumnPicker hideAll Builds the foundational primitives that subsequent waves depend on. None of these introduce new deps — date-fns, react-day-picker, and shadcn Calendar were already in the tree. - `<DatePicker>` and `<DateTimePicker>` in src/components/ui — desktop popover wrapping the existing shadcn Calendar (caption-dropdown nav so reps can jump months/years for the SkipAheadBanner backfill UX), mobile native input via useIsMobile. Drop-in for `<Input type=date>` / `<Input type=datetime-local>`. - `<FileInputButton>` in src/components/ui — styled Button + hidden input, replaces browser-default file picker UI. Most queued sweep sites already used the hidden-input + Button-trigger pattern; the primitive lands for any new caller plus consistent filename display + clear button. - ColumnPicker `hideAll()` footer item — symmetric to existing `showAll()`, with the same visibility gate. Lands platform-wide via the shared component. - Migrated highest-leverage call sites to the new primitives: * MilestoneAdvanceButton (backfill UX) * Reminder form (datetime-local → DateTimePicker) * Snooze dialog (datetime-local → DateTimePicker) * External-EOI upload dialog (date + file picker) * Payments section (received-on date) - Remaining 15+ date-input call sites parked for a follow-up sweep — several use react-hook-form `register` patterns that need careful migration to the new controlled-value contract. tsc clean. 1419/1419 vitest pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:10:02 +02:00
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');
feat(uat-batch-3): wave-1 primitives — DatePicker, DateTimePicker, FileInputButton, ColumnPicker hideAll Builds the foundational primitives that subsequent waves depend on. None of these introduce new deps — date-fns, react-day-picker, and shadcn Calendar were already in the tree. - `<DatePicker>` and `<DateTimePicker>` in src/components/ui — desktop popover wrapping the existing shadcn Calendar (caption-dropdown nav so reps can jump months/years for the SkipAheadBanner backfill UX), mobile native input via useIsMobile. Drop-in for `<Input type=date>` / `<Input type=datetime-local>`. - `<FileInputButton>` in src/components/ui — styled Button + hidden input, replaces browser-default file picker UI. Most queued sweep sites already used the hidden-input + Button-trigger pattern; the primitive lands for any new caller plus consistent filename display + clear button. - ColumnPicker `hideAll()` footer item — symmetric to existing `showAll()`, with the same visibility gate. Lands platform-wide via the shared component. - Migrated highest-leverage call sites to the new primitives: * MilestoneAdvanceButton (backfill UX) * Reminder form (datetime-local → DateTimePicker) * Snooze dialog (datetime-local → DateTimePicker) * External-EOI upload dialog (date + file picker) * Payments section (received-on date) - Remaining 15+ date-input call sites parked for a follow-up sweep — several use react-hook-form `register` patterns that need careful migration to the new controlled-value contract. tsc clean. 1419/1419 vitest pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:10:02 +02:00
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>
);
}