imperijal-components 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/INSTALL_AND_USAGE.md +288 -0
- package/PUBLISHING.md +306 -0
- package/README.md +35 -0
- package/package.json +22 -0
- package/packages/date-time-picker/README.md +78 -0
- package/packages/date-time-picker/package.json +82 -0
- package/packages/date-time-picker/src/components/date-calendar-panel.tsx +63 -0
- package/packages/date-time-picker/src/components/date-quick-chips.tsx +45 -0
- package/packages/date-time-picker/src/components/date-time-picker-content.tsx +121 -0
- package/packages/date-time-picker/src/components/date-time-picker.tsx +92 -0
- package/packages/date-time-picker/src/components/time-slot-grid.tsx +122 -0
- package/packages/date-time-picker/src/components/use-date-time-selection.ts +83 -0
- package/packages/date-time-picker/src/index.ts +19 -0
- package/packages/date-time-picker/src/lib/local-input-value.ts +45 -0
- package/packages/date-time-picker/src/lib/quick-dates.ts +59 -0
- package/packages/date-time-picker/src/lib/time-slots.ts +46 -0
- package/packages/date-time-picker/src/lib/utils.ts +6 -0
- package/packages/date-time-picker/src/styles.css +19 -0
- package/packages/date-time-picker/src/ui/button.tsx +51 -0
- package/packages/date-time-picker/src/ui/calendar.tsx +159 -0
- package/packages/date-time-picker/src/ui/collapsible.tsx +23 -0
- package/packages/date-time-picker/src/ui/popover.tsx +41 -0
- package/packages/date-time-picker/tsconfig.json +8 -0
- package/packages/date-time-picker/tsup.config.ts +23 -0
- package/pnpm-workspace.yaml +2 -0
- package/tsconfig.base.json +17 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# @imperijal/date-time-picker
|
|
2
|
+
|
|
3
|
+
Lumina-style date & time picker for React. Outputs **`YYYY-MM-DDTHH:mm`** (same as HTML `datetime-local`).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @imperijal/date-time-picker
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Peer dependencies (install in your app if missing):
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pnpm add react react-dom date-fns lucide-react react-day-picker \
|
|
15
|
+
@radix-ui/react-popover @radix-ui/react-collapsible @radix-ui/react-slot \
|
|
16
|
+
class-variance-authority clsx tailwind-merge
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
'use client';
|
|
23
|
+
|
|
24
|
+
import { useState } from 'react';
|
|
25
|
+
import {
|
|
26
|
+
DateTimePicker,
|
|
27
|
+
toLocalInputValue,
|
|
28
|
+
} from '@imperijal/date-time-picker';
|
|
29
|
+
|
|
30
|
+
export function Example() {
|
|
31
|
+
const [value, setValue] = useState(() => toLocalInputValue(new Date()));
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<DateTimePicker
|
|
35
|
+
value={value}
|
|
36
|
+
onChange={setValue}
|
|
37
|
+
timeSlots={{ startHour: 5, endHour: 24, intervalMinutes: 30 }}
|
|
38
|
+
/>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Tailwind
|
|
44
|
+
|
|
45
|
+
This package uses Tailwind utility classes. Scan the built output in your app config:
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
node_modules/@imperijal/date-time-picker/dist/**/*.js
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Your app needs shadcn-style CSS variables (`--primary`, `--border`, etc.) or import:
|
|
52
|
+
|
|
53
|
+
```css
|
|
54
|
+
@import '@imperijal/date-time-picker/styles.css';
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## API
|
|
58
|
+
|
|
59
|
+
| Prop | Type | Default |
|
|
60
|
+
|------|------|---------|
|
|
61
|
+
| `value` | `string` | required — `YYYY-MM-DDTHH:mm` |
|
|
62
|
+
| `onChange` | `(value: string) => void` | required |
|
|
63
|
+
| `placeholder` | `string` | `'Select date & time'` |
|
|
64
|
+
| `size` | `'default' \| 'sm'` | `'default'` |
|
|
65
|
+
| `timeSlots` | `{ startHour?, endHour?, intervalMinutes? }` | 5–24h, 30 min |
|
|
66
|
+
| `disabled` | `boolean` | `false` |
|
|
67
|
+
|
|
68
|
+
## Exports
|
|
69
|
+
|
|
70
|
+
- `DateTimePicker`
|
|
71
|
+
- `DateTimePickerContent` (headless panel)
|
|
72
|
+
- `useDateTimeSelection`
|
|
73
|
+
- `toLocalInputValue`, `parseLocalInputValue`, `formatLocalInputDisplay`
|
|
74
|
+
- `generateTimeSlots`, `getQuickDateOptions`
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@imperijal/date-time-picker",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Lumina-style date & time picker for React (outputs YYYY-MM-DDTHH:mm)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"require": {
|
|
16
|
+
"types": "./dist/index.d.cts",
|
|
17
|
+
"default": "./dist/index.cjs"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"./styles.css": "./src/styles.css"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"src/styles.css",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"sideEffects": [
|
|
28
|
+
"**/*.css"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsup",
|
|
32
|
+
"dev": "tsup --watch",
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"prepublishOnly": "pnpm build"
|
|
35
|
+
},
|
|
36
|
+
"keywords": [
|
|
37
|
+
"react",
|
|
38
|
+
"datetime",
|
|
39
|
+
"date-picker",
|
|
40
|
+
"time-picker",
|
|
41
|
+
"tailwind"
|
|
42
|
+
],
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "https://github.com/YOUR_ORG/imperijal-components.git",
|
|
47
|
+
"directory": "packages/date-time-picker"
|
|
48
|
+
},
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"@radix-ui/react-collapsible": "^1.1.0",
|
|
54
|
+
"@radix-ui/react-popover": "^1.1.0",
|
|
55
|
+
"@radix-ui/react-slot": "^1.1.0",
|
|
56
|
+
"class-variance-authority": "^0.7.0",
|
|
57
|
+
"clsx": "^2.1.0",
|
|
58
|
+
"date-fns": "^4.0.0",
|
|
59
|
+
"lucide-react": ">=0.400.0",
|
|
60
|
+
"react": "^18 || ^19",
|
|
61
|
+
"react-day-picker": "^9.0.0",
|
|
62
|
+
"react-dom": "^18 || ^19",
|
|
63
|
+
"tailwind-merge": "^2.0.0 || ^3.0.0"
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"@radix-ui/react-collapsible": "^1.1.11",
|
|
67
|
+
"@radix-ui/react-popover": "^1.1.14",
|
|
68
|
+
"@radix-ui/react-slot": "^1.2.3",
|
|
69
|
+
"@types/react": "^19.0.0",
|
|
70
|
+
"@types/react-dom": "^19.0.0",
|
|
71
|
+
"class-variance-authority": "^0.7.1",
|
|
72
|
+
"clsx": "^2.1.1",
|
|
73
|
+
"date-fns": "^4.1.0",
|
|
74
|
+
"lucide-react": "^0.511.0",
|
|
75
|
+
"react": "^19.0.0",
|
|
76
|
+
"react-day-picker": "^9.7.0",
|
|
77
|
+
"react-dom": "^19.0.0",
|
|
78
|
+
"tailwind-merge": "^3.0.0",
|
|
79
|
+
"tsup": "^8.5.0",
|
|
80
|
+
"typescript": "^5.8.0"
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { format } from 'date-fns';
|
|
4
|
+
import { CalendarIcon, ChevronDownIcon } from 'lucide-react';
|
|
5
|
+
import { useMemo, useState } from 'react';
|
|
6
|
+
|
|
7
|
+
import { Calendar } from '../ui/calendar';
|
|
8
|
+
import {
|
|
9
|
+
Collapsible,
|
|
10
|
+
CollapsibleContent,
|
|
11
|
+
CollapsibleTrigger,
|
|
12
|
+
} from '../ui/collapsible';
|
|
13
|
+
import { cn } from '../lib/utils';
|
|
14
|
+
|
|
15
|
+
type DateCalendarPanelProps = {
|
|
16
|
+
selected: Date | null;
|
|
17
|
+
onSelect: (date: Date) => void;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function DateCalendarPanel({ selected, onSelect }: DateCalendarPanelProps) {
|
|
21
|
+
const [open, setOpen] = useState(false);
|
|
22
|
+
const month = useMemo(() => selected ?? new Date(), [selected]);
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<Collapsible open={open} onOpenChange={setOpen}>
|
|
26
|
+
<CollapsibleTrigger asChild>
|
|
27
|
+
<button
|
|
28
|
+
type="button"
|
|
29
|
+
className="group flex w-full items-center justify-between rounded-xl border border-border bg-background px-4 py-3 transition-colors hover:bg-accent"
|
|
30
|
+
>
|
|
31
|
+
<div className="flex items-center gap-3">
|
|
32
|
+
<CalendarIcon className="size-5 text-muted-foreground transition-colors group-hover:text-primary" />
|
|
33
|
+
<span className="text-sm font-medium">View Calendar</span>
|
|
34
|
+
</div>
|
|
35
|
+
<ChevronDownIcon
|
|
36
|
+
className={cn(
|
|
37
|
+
'size-5 text-muted-foreground transition-transform duration-300',
|
|
38
|
+
open && 'rotate-180',
|
|
39
|
+
)}
|
|
40
|
+
/>
|
|
41
|
+
</button>
|
|
42
|
+
</CollapsibleTrigger>
|
|
43
|
+
<CollapsibleContent className="mt-4 overflow-hidden rounded-xl border border-border bg-background p-4 shadow-sm">
|
|
44
|
+
<div className="mb-3 flex items-center justify-between px-1">
|
|
45
|
+
<span className="text-sm font-semibold">
|
|
46
|
+
{format(month, 'MMMM yyyy')}
|
|
47
|
+
</span>
|
|
48
|
+
</div>
|
|
49
|
+
<Calendar
|
|
50
|
+
mode="single"
|
|
51
|
+
selected={selected ?? undefined}
|
|
52
|
+
onSelect={(date) => {
|
|
53
|
+
if (!date) return;
|
|
54
|
+
onSelect(date);
|
|
55
|
+
setOpen(false);
|
|
56
|
+
}}
|
|
57
|
+
defaultMonth={month}
|
|
58
|
+
className="mx-auto p-0"
|
|
59
|
+
/>
|
|
60
|
+
</CollapsibleContent>
|
|
61
|
+
</Collapsible>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { QuickDateOption } from '../lib/quick-dates';
|
|
2
|
+
import { cn } from '../lib/utils';
|
|
3
|
+
|
|
4
|
+
type DateQuickChipsProps = {
|
|
5
|
+
options: QuickDateOption[];
|
|
6
|
+
isSelected: (date: Date) => boolean;
|
|
7
|
+
onSelect: (date: Date) => void;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function DateQuickChips({
|
|
11
|
+
options,
|
|
12
|
+
isSelected,
|
|
13
|
+
onSelect,
|
|
14
|
+
}: DateQuickChipsProps) {
|
|
15
|
+
return (
|
|
16
|
+
<div className="flex gap-2 overflow-x-auto pb-2 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
|
17
|
+
{options.map((option) => {
|
|
18
|
+
const active = isSelected(option.date);
|
|
19
|
+
return (
|
|
20
|
+
<button
|
|
21
|
+
key={option.id}
|
|
22
|
+
type="button"
|
|
23
|
+
onClick={() => onSelect(option.date)}
|
|
24
|
+
className={cn(
|
|
25
|
+
'flex-shrink-0 rounded-xl border px-5 py-2.5 text-left transition-all active:scale-95',
|
|
26
|
+
active
|
|
27
|
+
? 'border-primary bg-primary text-primary-foreground shadow-sm ring-2 ring-primary/20'
|
|
28
|
+
: 'border-border bg-background hover:bg-accent',
|
|
29
|
+
)}
|
|
30
|
+
>
|
|
31
|
+
<span
|
|
32
|
+
className={cn(
|
|
33
|
+
'block text-[10px] font-bold uppercase tracking-widest',
|
|
34
|
+
active ? 'text-primary-foreground/80' : 'text-muted-foreground',
|
|
35
|
+
)}
|
|
36
|
+
>
|
|
37
|
+
{option.label}
|
|
38
|
+
</span>
|
|
39
|
+
<span className="block text-sm font-semibold">{option.sublabel}</span>
|
|
40
|
+
</button>
|
|
41
|
+
);
|
|
42
|
+
})}
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { format } from 'date-fns';
|
|
4
|
+
import { ArrowRightIcon } from 'lucide-react';
|
|
5
|
+
import { useMemo } from 'react';
|
|
6
|
+
|
|
7
|
+
import { getQuickDateOptions } from '../lib/quick-dates';
|
|
8
|
+
import {
|
|
9
|
+
generateTimeSlots,
|
|
10
|
+
type TimeSlotConfig,
|
|
11
|
+
} from '../lib/time-slots';
|
|
12
|
+
import { Button } from '../ui/button';
|
|
13
|
+
import { DateCalendarPanel } from './date-calendar-panel';
|
|
14
|
+
import { DateQuickChips } from './date-quick-chips';
|
|
15
|
+
import { TimeSlotGrid } from './time-slot-grid';
|
|
16
|
+
import { useDateTimeSelection } from './use-date-time-selection';
|
|
17
|
+
|
|
18
|
+
export type DateTimePickerContentProps = {
|
|
19
|
+
value: string;
|
|
20
|
+
open: boolean;
|
|
21
|
+
onConfirm: (value: string) => void;
|
|
22
|
+
timeSlots?: TimeSlotConfig;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function DateTimePickerContent({
|
|
26
|
+
value,
|
|
27
|
+
open,
|
|
28
|
+
onConfirm,
|
|
29
|
+
timeSlots,
|
|
30
|
+
}: DateTimePickerContentProps) {
|
|
31
|
+
const quickDates = useMemo(() => getQuickDateOptions(), []);
|
|
32
|
+
const slots = useMemo(() => generateTimeSlots(timeSlots), [timeSlots]);
|
|
33
|
+
|
|
34
|
+
const {
|
|
35
|
+
draft,
|
|
36
|
+
selectDate,
|
|
37
|
+
selectTime,
|
|
38
|
+
selectedTimeKey,
|
|
39
|
+
isDateSelected,
|
|
40
|
+
isDraftComplete,
|
|
41
|
+
draftValue,
|
|
42
|
+
matchesQuickDate,
|
|
43
|
+
} = useDateTimeSelection({ value, open });
|
|
44
|
+
|
|
45
|
+
const selectedDateLabel = draft.date
|
|
46
|
+
? format(draft.date, 'EEE, MMM d')
|
|
47
|
+
: null;
|
|
48
|
+
const selectedTimeLabel =
|
|
49
|
+
draft.hours != null && draft.minutes != null
|
|
50
|
+
? `${String(draft.hours).padStart(2, '0')}:${String(draft.minutes).padStart(2, '0')}`
|
|
51
|
+
: null;
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className="flex max-h-[min(80vh,640px)] flex-col">
|
|
55
|
+
<div className="flex-1 space-y-6 overflow-y-auto p-1 pr-2">
|
|
56
|
+
<section>
|
|
57
|
+
<div className="mb-4 flex items-center justify-between">
|
|
58
|
+
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
59
|
+
Step 1: Choose Date
|
|
60
|
+
</span>
|
|
61
|
+
{selectedDateLabel ? (
|
|
62
|
+
<span className="text-sm font-medium text-primary">
|
|
63
|
+
{selectedDateLabel}
|
|
64
|
+
</span>
|
|
65
|
+
) : null}
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<DateQuickChips
|
|
69
|
+
options={quickDates}
|
|
70
|
+
isSelected={matchesQuickDate}
|
|
71
|
+
onSelect={selectDate}
|
|
72
|
+
/>
|
|
73
|
+
|
|
74
|
+
<div className="mt-4">
|
|
75
|
+
<DateCalendarPanel selected={draft.date} onSelect={selectDate} />
|
|
76
|
+
</div>
|
|
77
|
+
</section>
|
|
78
|
+
|
|
79
|
+
<section
|
|
80
|
+
className={
|
|
81
|
+
isDateSelected
|
|
82
|
+
? 'opacity-100 transition-opacity duration-300'
|
|
83
|
+
: 'pointer-events-none opacity-40 transition-opacity duration-300'
|
|
84
|
+
}
|
|
85
|
+
>
|
|
86
|
+
<div className="mb-4 flex items-center justify-between">
|
|
87
|
+
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
88
|
+
Step 2: Choose Time
|
|
89
|
+
</span>
|
|
90
|
+
{selectedTimeLabel ? (
|
|
91
|
+
<span className="text-sm font-medium text-primary">
|
|
92
|
+
{selectedTimeLabel}
|
|
93
|
+
</span>
|
|
94
|
+
) : null}
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<TimeSlotGrid
|
|
98
|
+
slots={slots}
|
|
99
|
+
selectedKey={selectedTimeKey}
|
|
100
|
+
disabled={!isDateSelected}
|
|
101
|
+
onSelect={selectTime}
|
|
102
|
+
/>
|
|
103
|
+
</section>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<div className="mt-4 border-t border-border/60 pt-4">
|
|
107
|
+
<Button
|
|
108
|
+
type="button"
|
|
109
|
+
className="h-11 w-full rounded-xl text-sm font-semibold shadow-lg"
|
|
110
|
+
disabled={!isDraftComplete || !draftValue}
|
|
111
|
+
onClick={() => {
|
|
112
|
+
if (draftValue) onConfirm(draftValue);
|
|
113
|
+
}}
|
|
114
|
+
>
|
|
115
|
+
Confirm Selection
|
|
116
|
+
<ArrowRightIcon className="size-4" />
|
|
117
|
+
</Button>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { CalendarIcon, ClockIcon } from 'lucide-react';
|
|
4
|
+
import { useState } from 'react';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
formatLocalInputDisplay,
|
|
8
|
+
} from '../lib/local-input-value';
|
|
9
|
+
import type { TimeSlotConfig } from '../lib/time-slots';
|
|
10
|
+
import { cn } from '../lib/utils';
|
|
11
|
+
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
|
12
|
+
import {
|
|
13
|
+
DateTimePickerContent,
|
|
14
|
+
type DateTimePickerContentProps,
|
|
15
|
+
} from './date-time-picker-content';
|
|
16
|
+
|
|
17
|
+
export type DateTimePickerProps = {
|
|
18
|
+
/** datetime-local string: `YYYY-MM-DDTHH:mm` */
|
|
19
|
+
value: string;
|
|
20
|
+
onChange: (value: string) => void;
|
|
21
|
+
placeholder?: string;
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
className?: string;
|
|
24
|
+
id?: string;
|
|
25
|
+
size?: 'default' | 'sm';
|
|
26
|
+
timeSlots?: TimeSlotConfig;
|
|
27
|
+
align?: 'start' | 'center' | 'end';
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function DateTimePicker({
|
|
31
|
+
value,
|
|
32
|
+
onChange,
|
|
33
|
+
placeholder = 'Select date & time',
|
|
34
|
+
disabled = false,
|
|
35
|
+
className,
|
|
36
|
+
id,
|
|
37
|
+
size = 'default',
|
|
38
|
+
timeSlots,
|
|
39
|
+
align = 'start',
|
|
40
|
+
}: DateTimePickerProps) {
|
|
41
|
+
const [open, setOpen] = useState(false);
|
|
42
|
+
|
|
43
|
+
const handleConfirm: DateTimePickerContentProps['onConfirm'] = (next) => {
|
|
44
|
+
onChange(next);
|
|
45
|
+
setOpen(false);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
50
|
+
<PopoverTrigger asChild>
|
|
51
|
+
<button
|
|
52
|
+
id={id}
|
|
53
|
+
type="button"
|
|
54
|
+
disabled={disabled}
|
|
55
|
+
className={cn(
|
|
56
|
+
'flex w-full items-center justify-between gap-2 rounded-md border border-input bg-background text-left shadow-xs transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50',
|
|
57
|
+
size === 'sm'
|
|
58
|
+
? 'px-2 py-1 text-[11px]'
|
|
59
|
+
: 'px-2 py-1.5 text-xs',
|
|
60
|
+
className,
|
|
61
|
+
)}
|
|
62
|
+
>
|
|
63
|
+
<span className={cn('truncate', !value && 'text-muted-foreground')}>
|
|
64
|
+
{formatLocalInputDisplay(value, placeholder)}
|
|
65
|
+
</span>
|
|
66
|
+
<CalendarIcon
|
|
67
|
+
className={cn(
|
|
68
|
+
'shrink-0 text-muted-foreground',
|
|
69
|
+
size === 'sm' ? 'size-3.5' : 'size-4',
|
|
70
|
+
)}
|
|
71
|
+
/>
|
|
72
|
+
</button>
|
|
73
|
+
</PopoverTrigger>
|
|
74
|
+
<PopoverContent
|
|
75
|
+
align={align}
|
|
76
|
+
className="w-[min(calc(100vw-2rem),28rem)] p-4"
|
|
77
|
+
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
78
|
+
>
|
|
79
|
+
<div className="mb-4 flex items-center gap-2 border-b border-border/60 pb-3">
|
|
80
|
+
<ClockIcon className="size-5 text-primary" />
|
|
81
|
+
<h3 className="text-sm font-semibold">Select Date & Time</h3>
|
|
82
|
+
</div>
|
|
83
|
+
<DateTimePickerContent
|
|
84
|
+
value={value}
|
|
85
|
+
open={open}
|
|
86
|
+
onConfirm={handleConfirm}
|
|
87
|
+
timeSlots={timeSlots}
|
|
88
|
+
/>
|
|
89
|
+
</PopoverContent>
|
|
90
|
+
</Popover>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
|
|
4
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
5
|
+
|
|
6
|
+
import type { TimeSlot } from '../lib/time-slots';
|
|
7
|
+
import { timeSlotKey } from '../lib/time-slots';
|
|
8
|
+
import { cn } from '../lib/utils';
|
|
9
|
+
|
|
10
|
+
type TimeSlotGridProps = {
|
|
11
|
+
slots: TimeSlot[];
|
|
12
|
+
selectedKey: string | null;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
onSelect: (hours: number, minutes: number) => void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const SCROLL_STEP_PX = 128;
|
|
18
|
+
|
|
19
|
+
export function TimeSlotGrid({
|
|
20
|
+
slots,
|
|
21
|
+
selectedKey,
|
|
22
|
+
disabled = false,
|
|
23
|
+
onSelect,
|
|
24
|
+
}: TimeSlotGridProps) {
|
|
25
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
26
|
+
const [canScrollUp, setCanScrollUp] = useState(false);
|
|
27
|
+
const [canScrollDown, setCanScrollDown] = useState(false);
|
|
28
|
+
|
|
29
|
+
const updateScrollState = useCallback(() => {
|
|
30
|
+
const el = scrollRef.current;
|
|
31
|
+
if (!el) return;
|
|
32
|
+
setCanScrollUp(el.scrollTop > 1);
|
|
33
|
+
setCanScrollDown(el.scrollTop + el.clientHeight < el.scrollHeight - 1);
|
|
34
|
+
}, []);
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
updateScrollState();
|
|
38
|
+
}, [slots, updateScrollState]);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!selectedKey || !scrollRef.current) return;
|
|
42
|
+
const selected = scrollRef.current.querySelector(
|
|
43
|
+
`[data-slot-key="${selectedKey}"]`,
|
|
44
|
+
);
|
|
45
|
+
selected?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
46
|
+
const timer = window.setTimeout(updateScrollState, 200);
|
|
47
|
+
return () => window.clearTimeout(timer);
|
|
48
|
+
}, [selectedKey, updateScrollState]);
|
|
49
|
+
|
|
50
|
+
const scrollBy = (delta: number) => {
|
|
51
|
+
scrollRef.current?.scrollBy({ top: delta, behavior: 'smooth' });
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div
|
|
56
|
+
className={cn(
|
|
57
|
+
'overflow-hidden rounded-xl border border-border bg-background transition-opacity duration-300',
|
|
58
|
+
disabled && 'pointer-events-none opacity-40',
|
|
59
|
+
)}
|
|
60
|
+
>
|
|
61
|
+
<button
|
|
62
|
+
type="button"
|
|
63
|
+
disabled={disabled || !canScrollUp}
|
|
64
|
+
onClick={() => scrollBy(-SCROLL_STEP_PX)}
|
|
65
|
+
aria-label="Show earlier times"
|
|
66
|
+
className={cn(
|
|
67
|
+
'flex w-full items-center justify-center border-b border-border py-1.5 transition-colors',
|
|
68
|
+
canScrollUp && !disabled
|
|
69
|
+
? 'text-primary hover:bg-accent active:scale-95'
|
|
70
|
+
: 'cursor-default text-muted-foreground/30',
|
|
71
|
+
)}
|
|
72
|
+
>
|
|
73
|
+
<ChevronUpIcon className="size-5" />
|
|
74
|
+
</button>
|
|
75
|
+
|
|
76
|
+
<div
|
|
77
|
+
ref={scrollRef}
|
|
78
|
+
onScroll={updateScrollState}
|
|
79
|
+
className="max-h-52 overflow-y-auto px-2 py-2 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
80
|
+
>
|
|
81
|
+
<div className="grid grid-cols-3 gap-2">
|
|
82
|
+
{slots.map((slot) => {
|
|
83
|
+
const key = timeSlotKey(slot.hours, slot.minutes);
|
|
84
|
+
const active = selectedKey === key;
|
|
85
|
+
return (
|
|
86
|
+
<button
|
|
87
|
+
key={key}
|
|
88
|
+
type="button"
|
|
89
|
+
data-slot-key={key}
|
|
90
|
+
disabled={disabled}
|
|
91
|
+
onClick={() => onSelect(slot.hours, slot.minutes)}
|
|
92
|
+
className={cn(
|
|
93
|
+
'rounded-xl border py-3 text-sm font-medium transition-all active:scale-95',
|
|
94
|
+
active
|
|
95
|
+
? 'border-primary bg-primary text-primary-foreground shadow-md'
|
|
96
|
+
: 'border-border bg-background hover:border-primary hover:bg-accent',
|
|
97
|
+
)}
|
|
98
|
+
>
|
|
99
|
+
{slot.label}
|
|
100
|
+
</button>
|
|
101
|
+
);
|
|
102
|
+
})}
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<button
|
|
107
|
+
type="button"
|
|
108
|
+
disabled={disabled || !canScrollDown}
|
|
109
|
+
onClick={() => scrollBy(SCROLL_STEP_PX)}
|
|
110
|
+
aria-label="Show later times"
|
|
111
|
+
className={cn(
|
|
112
|
+
'flex w-full items-center justify-center border-t border-border py-1.5 transition-colors',
|
|
113
|
+
canScrollDown && !disabled
|
|
114
|
+
? 'text-primary hover:bg-accent active:scale-95'
|
|
115
|
+
: 'cursor-default text-muted-foreground/30',
|
|
116
|
+
)}
|
|
117
|
+
>
|
|
118
|
+
<ChevronDownIcon className="size-5" />
|
|
119
|
+
</button>
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { isSameDay, startOfDay } from 'date-fns';
|
|
2
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
combineLocalDateTime,
|
|
6
|
+
parseLocalInputValue,
|
|
7
|
+
} from '../lib/local-input-value';
|
|
8
|
+
import { timeSlotKey } from '../lib/time-slots';
|
|
9
|
+
|
|
10
|
+
export type DateTimeDraft = {
|
|
11
|
+
date: Date | null;
|
|
12
|
+
hours: number | null;
|
|
13
|
+
minutes: number | null;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function draftFromLocalValue(value: string): DateTimeDraft {
|
|
17
|
+
const parsed = parseLocalInputValue(value);
|
|
18
|
+
if (!parsed) return { date: null, hours: null, minutes: null };
|
|
19
|
+
return {
|
|
20
|
+
date: startOfDay(parsed),
|
|
21
|
+
hours: parsed.getHours(),
|
|
22
|
+
minutes: parsed.getMinutes(),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function draftToLocalValue(draft: DateTimeDraft): string | null {
|
|
27
|
+
if (draft.date == null || draft.hours == null || draft.minutes == null) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
return combineLocalDateTime(draft.date, draft.hours, draft.minutes);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isDraftComplete(draft: DateTimeDraft): boolean {
|
|
34
|
+
return draft.date != null && draft.hours != null && draft.minutes != null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type UseDateTimeSelectionOptions = {
|
|
38
|
+
value: string;
|
|
39
|
+
open: boolean;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function useDateTimeSelection({
|
|
43
|
+
value,
|
|
44
|
+
open,
|
|
45
|
+
}: UseDateTimeSelectionOptions) {
|
|
46
|
+
const committed = useMemo(() => draftFromLocalValue(value), [value]);
|
|
47
|
+
const [draft, setDraft] = useState<DateTimeDraft>(committed);
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (open) setDraft(committed);
|
|
51
|
+
}, [open, committed]);
|
|
52
|
+
|
|
53
|
+
const selectDate = useCallback((date: Date) => {
|
|
54
|
+
setDraft((prev) => ({ ...prev, date: startOfDay(date) }));
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
const selectTime = useCallback((hours: number, minutes: number) => {
|
|
58
|
+
setDraft((prev) => ({ ...prev, hours, minutes }));
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
const selectedTimeKey =
|
|
62
|
+
draft.hours != null && draft.minutes != null
|
|
63
|
+
? timeSlotKey(draft.hours, draft.minutes)
|
|
64
|
+
: null;
|
|
65
|
+
|
|
66
|
+
const isDateSelected = draft.date != null;
|
|
67
|
+
|
|
68
|
+
const matchesQuickDate = useCallback(
|
|
69
|
+
(date: Date) => draft.date != null && isSameDay(draft.date, date),
|
|
70
|
+
[draft.date],
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
draft,
|
|
75
|
+
selectDate,
|
|
76
|
+
selectTime,
|
|
77
|
+
selectedTimeKey,
|
|
78
|
+
isDateSelected,
|
|
79
|
+
isDraftComplete: isDraftComplete(draft),
|
|
80
|
+
draftValue: draftToLocalValue(draft),
|
|
81
|
+
matchesQuickDate,
|
|
82
|
+
};
|
|
83
|
+
}
|