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>
This commit is contained in:
@@ -6,6 +6,8 @@ import { Loader2, Upload } from 'lucide-react';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { DatePicker } from '@/components/ui/date-picker';
|
||||||
|
import { FileInputButton } from '@/components/ui/file-input-button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
@@ -132,12 +134,13 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc
|
|||||||
<div className="space-y-3 py-2">
|
<div className="space-y-3 py-2">
|
||||||
<div>
|
<div>
|
||||||
<Label>PDF file *</Label>
|
<Label>PDF file *</Label>
|
||||||
<Input
|
<div className="mt-1">
|
||||||
type="file"
|
<FileInputButton
|
||||||
accept="application/pdf,.pdf"
|
accept="application/pdf,.pdf"
|
||||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
label={file ? file.name : 'Choose PDF'}
|
||||||
className="mt-1"
|
onFilesPicked={(files) => setFile(files[0] ?? null)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Title (optional)</Label>
|
<Label>Title (optional)</Label>
|
||||||
@@ -153,12 +156,9 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Date signed</Label>
|
<Label>Date signed</Label>
|
||||||
<Input
|
<div className="mt-1">
|
||||||
type="date"
|
<DatePicker value={signedAt} onChange={setSignedAt} toDate={new Date()} />
|
||||||
value={signedAt}
|
</div>
|
||||||
onChange={(e) => setSignedAt(e.target.value)}
|
|
||||||
className="mt-1"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Signer names (comma-separated)</Label>
|
<Label>Signer names (comma-separated)</Label>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { parsePhone } from '@/lib/i18n/phone';
|
|||||||
|
|
||||||
import type { DetailTab } from '@/components/shared/detail-layout';
|
import type { DetailTab } from '@/components/shared/detail-layout';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { DatePicker } from '@/components/ui/date-picker';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { NotesList } from '@/components/shared/notes-list';
|
import { NotesList } from '@/components/shared/notes-list';
|
||||||
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||||
@@ -315,13 +315,12 @@ function MilestoneAdvanceButton({
|
|||||||
<label className="text-xs font-medium" htmlFor="milestone-date">
|
<label className="text-xs font-medium" htmlFor="milestone-date">
|
||||||
Date completed
|
Date completed
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<DatePicker
|
||||||
id="milestone-date"
|
id="milestone-date"
|
||||||
type="date"
|
|
||||||
value={date}
|
value={date}
|
||||||
max={new Date().toISOString().slice(0, 10)}
|
toDate={new Date()}
|
||||||
onChange={(e) => setDate(e.target.value)}
|
onChange={setDate}
|
||||||
className="h-9"
|
placeholder="Pick a date"
|
||||||
/>
|
/>
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
Defaults to today — back-date if the event happened earlier.
|
Defaults to today — back-date if the event happened earlier.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { Plus, Trash2, Receipt } from 'lucide-react';
|
import { Plus, Trash2, Receipt } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { DatePicker } from '@/components/ui/date-picker';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import {
|
import {
|
||||||
@@ -325,12 +326,12 @@ function RecordPaymentSheet({
|
|||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="payment-date">Received on</Label>
|
<Label htmlFor="payment-date">Received on</Label>
|
||||||
<Input
|
<DatePicker
|
||||||
id="payment-date"
|
id="payment-date"
|
||||||
type="date"
|
|
||||||
value={receivedAt}
|
value={receivedAt}
|
||||||
onChange={(e) => setReceivedAt(e.target.value)}
|
onChange={setReceivedAt}
|
||||||
required
|
toDate={new Date()}
|
||||||
|
placeholder="Pick a date"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { DateTimePicker } from '@/components/ui/date-time-picker';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
@@ -304,12 +305,11 @@ function ReminderFormBody({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<DateTimePicker
|
||||||
id="reminder-due"
|
id="reminder-due"
|
||||||
type="datetime-local"
|
|
||||||
value={dueAt}
|
value={dueAt}
|
||||||
onChange={(e) => setDueAt(e.target.value)}
|
onChange={setDueAt}
|
||||||
required
|
placeholder="Pick a due date and time"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { DateTimePicker } from '@/components/ui/date-time-picker';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -91,13 +91,14 @@ export function SnoozeDialog({ open, onOpenChange, reminderId, onSuccess }: Snoo
|
|||||||
<div className="space-y-2 pt-2">
|
<div className="space-y-2 pt-2">
|
||||||
<Label htmlFor="custom-snooze">Custom date & time</Label>
|
<Label htmlFor="custom-snooze">Custom date & time</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<div className="flex-1">
|
||||||
id="custom-snooze"
|
<DateTimePicker
|
||||||
type="datetime-local"
|
id="custom-snooze"
|
||||||
value={customDate}
|
value={customDate}
|
||||||
onChange={(e) => setCustomDate(e.target.value)}
|
onChange={setCustomDate}
|
||||||
className="flex-1"
|
placeholder="Pick a date and time"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
disabled={loading || !customDate}
|
disabled={loading || !customDate}
|
||||||
onClick={() => handleSnooze(new Date(customDate).toISOString())}
|
onClick={() => handleSnooze(new Date(customDate).toISOString())}
|
||||||
|
|||||||
@@ -59,11 +59,18 @@ export function ColumnPicker({
|
|||||||
onChange([]);
|
onChange([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hideAll() {
|
||||||
|
onChange(columns.filter((c) => !c.alwaysVisible).map((c) => c.id));
|
||||||
|
}
|
||||||
|
|
||||||
// The "All visible" affordance is only useful when something is
|
// The "All visible" affordance is only useful when something is
|
||||||
// hidden — a no-op button is noise.
|
// hidden — a no-op button is noise.
|
||||||
const canShowAll = hidden.some((id) =>
|
const canShowAll = hidden.some((id) =>
|
||||||
columns.some((col) => col.id === id && !col.alwaysVisible),
|
columns.some((col) => col.id === id && !col.alwaysVisible),
|
||||||
);
|
);
|
||||||
|
// Mirror: "Hide all" is only useful when at least one toggleable
|
||||||
|
// column is currently visible.
|
||||||
|
const canHideAll = columns.some((col) => !col.alwaysVisible && !hiddenSet.has(col.id));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -113,13 +120,16 @@ export function ColumnPicker({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{(canShowAll || canHideAll) && <DropdownMenuSeparator />}
|
||||||
{canShowAll && (
|
{canShowAll && (
|
||||||
<>
|
<DropdownMenuItem onClick={showAll} className="text-xs text-muted-foreground">
|
||||||
<DropdownMenuSeparator />
|
Show all columns
|
||||||
<DropdownMenuItem onClick={showAll} className="text-xs text-muted-foreground">
|
</DropdownMenuItem>
|
||||||
Show all columns
|
)}
|
||||||
</DropdownMenuItem>
|
{canHideAll && (
|
||||||
</>
|
<DropdownMenuItem onClick={hideAll} className="text-xs text-muted-foreground">
|
||||||
|
Hide all columns
|
||||||
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{onSaveView && (
|
{onSaveView && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
136
src/components/ui/date-picker.tsx
Normal file
136
src/components/ui/date-picker.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
'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 { useIsMobile } 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) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const variant = forceVariant ?? (isMobile ? '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>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
src/components/ui/date-time-picker.tsx
Normal file
129
src/components/ui/date-time-picker.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Calendar as CalendarIcon, Clock } 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 { useIsMobile } from '@/hooks/use-is-mobile';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface DateTimePickerProps {
|
||||||
|
/** datetime-local string ("YYYY-MM-DDTHH:mm") or empty for unset. */
|
||||||
|
value?: string;
|
||||||
|
onChange: (next: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
fromDate?: Date;
|
||||||
|
toDate?: Date;
|
||||||
|
className?: string;
|
||||||
|
title?: string;
|
||||||
|
size?: ButtonProps['size'];
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
forceVariant?: 'desktop' | 'mobile';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateTime(value: string | undefined): { date?: Date; time: string } {
|
||||||
|
if (!value) return { time: '' };
|
||||||
|
// `2026-05-21T14:30` is the datetime-local shape Input emits.
|
||||||
|
const [datePart, timePart = ''] = value.split('T');
|
||||||
|
const date = datePart ? new Date(`${datePart}T00:00:00`) : undefined;
|
||||||
|
if (date && Number.isNaN(date.getTime())) return { time: timePart };
|
||||||
|
return { date, time: timePart };
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinDateTime(date: Date | undefined, time: string): string {
|
||||||
|
if (!date) return '';
|
||||||
|
const datePart = format(date, 'yyyy-MM-dd');
|
||||||
|
const timePart = time && /^\d{2}:\d{2}/.test(time) ? time.slice(0, 5) : '00:00';
|
||||||
|
return `${datePart}T${timePart}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-platform datetime picker. Desktop: shadcn Popover + Calendar +
|
||||||
|
* native time input in the footer (the shadcn-canonical pattern).
|
||||||
|
* Mobile: native `<input type="datetime-local">` so the OS picker takes
|
||||||
|
* over. Drop-in for `<Input type="datetime-local">`.
|
||||||
|
*/
|
||||||
|
export function DateTimePicker({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
disabled,
|
||||||
|
fromDate,
|
||||||
|
toDate,
|
||||||
|
className,
|
||||||
|
title,
|
||||||
|
size = 'default',
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
forceVariant,
|
||||||
|
}: DateTimePickerProps) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const variant = forceVariant ?? (isMobile ? 'mobile' : 'desktop');
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const { date, time } = parseDateTime(value);
|
||||||
|
const displayTime = time || '09:00';
|
||||||
|
|
||||||
|
if (variant === 'mobile') {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
name={name}
|
||||||
|
type="datetime-local"
|
||||||
|
value={value ?? ''}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
className={className}
|
||||||
|
title={title}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
!date && 'text-muted-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CalendarIcon aria-hidden />
|
||||||
|
{date ? `${format(date, 'PP')} ${displayTime}` : (placeholder ?? 'Pick a date and time')}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="start" className="w-auto p-0">
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={date}
|
||||||
|
onSelect={(d) => onChange(joinDateTime(d, displayTime))}
|
||||||
|
captionLayout="dropdown"
|
||||||
|
startMonth={fromDate}
|
||||||
|
endMonth={toDate}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 border-t p-3">
|
||||||
|
<Clock className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||||
|
<Input
|
||||||
|
type="time"
|
||||||
|
value={displayTime}
|
||||||
|
step={60}
|
||||||
|
onChange={(e) => onChange(joinDateTime(date, e.target.value))}
|
||||||
|
className="h-9 w-32 [&::-webkit-calendar-picker-indicator]:hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
src/components/ui/file-input-button.tsx
Normal file
127
src/components/ui/file-input-button.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Upload, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button, type ButtonProps } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface FileInputButtonProps {
|
||||||
|
/** File-picker `accept` attribute (MIME or extension list). */
|
||||||
|
accept?: string;
|
||||||
|
/** Allow picking more than one file. Default: false. */
|
||||||
|
multiple?: boolean;
|
||||||
|
/** Required: handler invoked with the picked FileList contents. */
|
||||||
|
onFilesPicked: (files: File[]) => void;
|
||||||
|
/** Button label. Default: "Choose file" / "Choose files". */
|
||||||
|
label?: string;
|
||||||
|
/** Show an Upload icon before the label. Default: true. */
|
||||||
|
showIcon?: boolean;
|
||||||
|
/** Button variant. Default: 'outline'. */
|
||||||
|
variant?: ButtonProps['variant'];
|
||||||
|
/** Button size. Default: 'sm'. */
|
||||||
|
size?: ButtonProps['size'];
|
||||||
|
/** Disable the picker (e.g. while a previous upload is in flight). */
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Optional className applied to the wrapper. */
|
||||||
|
className?: string;
|
||||||
|
/** Optional className applied to the trigger Button. */
|
||||||
|
buttonClassName?: string;
|
||||||
|
/** Tooltip text on the trigger Button. */
|
||||||
|
title?: string;
|
||||||
|
/**
|
||||||
|
* When true, renders the selected filename next to a clear (×) button
|
||||||
|
* underneath the picker — same idiom as the expense form. Default:
|
||||||
|
* false (the caller manages its own filename display).
|
||||||
|
*/
|
||||||
|
showSelectedFilename?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Styled file-picker primitive — replaces the raw browser-default
|
||||||
|
* `<input type="file">` UI that looks different across Chromium /
|
||||||
|
* Safari / Firefox. Renders a Button + hidden input; the Button forwards
|
||||||
|
* clicks to the input. Use everywhere we'd otherwise render a raw file
|
||||||
|
* input.
|
||||||
|
*/
|
||||||
|
export const FileInputButton = React.forwardRef<HTMLInputElement, FileInputButtonProps>(
|
||||||
|
function FileInputButton(
|
||||||
|
{
|
||||||
|
accept,
|
||||||
|
multiple = false,
|
||||||
|
onFilesPicked,
|
||||||
|
label,
|
||||||
|
showIcon = true,
|
||||||
|
variant = 'outline',
|
||||||
|
size = 'sm',
|
||||||
|
disabled,
|
||||||
|
className,
|
||||||
|
buttonClassName,
|
||||||
|
title,
|
||||||
|
showSelectedFilename = false,
|
||||||
|
},
|
||||||
|
forwardedRef,
|
||||||
|
) {
|
||||||
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
const [selected, setSelected] = React.useState<File[]>([]);
|
||||||
|
|
||||||
|
React.useImperativeHandle(forwardedRef, () => inputRef.current as HTMLInputElement);
|
||||||
|
|
||||||
|
const computedLabel = label ?? (multiple ? 'Choose files' : 'Choose file');
|
||||||
|
|
||||||
|
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const list = e.target.files;
|
||||||
|
if (!list || list.length === 0) return;
|
||||||
|
const files = Array.from(list);
|
||||||
|
setSelected(files);
|
||||||
|
onFilesPicked(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
setSelected([]);
|
||||||
|
if (inputRef.current) inputRef.current.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('inline-flex flex-col gap-2', className)}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
disabled={disabled}
|
||||||
|
title={title}
|
||||||
|
className={buttonClassName}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
>
|
||||||
|
{showIcon ? <Upload aria-hidden /> : null}
|
||||||
|
{computedLabel}
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
multiple={multiple}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="hidden"
|
||||||
|
aria-hidden
|
||||||
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
{showSelectedFilename && selected.length > 0 ? (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span className="truncate" title={selected.map((f) => f.name).join(', ')}>
|
||||||
|
{selected.length === 1 ? selected[0]!.name : `${selected.length} files selected`}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clear}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label="Clear selection"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" aria-hidden />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user