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.
Files changed (26) hide show
  1. package/INSTALL_AND_USAGE.md +288 -0
  2. package/PUBLISHING.md +306 -0
  3. package/README.md +35 -0
  4. package/package.json +22 -0
  5. package/packages/date-time-picker/README.md +78 -0
  6. package/packages/date-time-picker/package.json +82 -0
  7. package/packages/date-time-picker/src/components/date-calendar-panel.tsx +63 -0
  8. package/packages/date-time-picker/src/components/date-quick-chips.tsx +45 -0
  9. package/packages/date-time-picker/src/components/date-time-picker-content.tsx +121 -0
  10. package/packages/date-time-picker/src/components/date-time-picker.tsx +92 -0
  11. package/packages/date-time-picker/src/components/time-slot-grid.tsx +122 -0
  12. package/packages/date-time-picker/src/components/use-date-time-selection.ts +83 -0
  13. package/packages/date-time-picker/src/index.ts +19 -0
  14. package/packages/date-time-picker/src/lib/local-input-value.ts +45 -0
  15. package/packages/date-time-picker/src/lib/quick-dates.ts +59 -0
  16. package/packages/date-time-picker/src/lib/time-slots.ts +46 -0
  17. package/packages/date-time-picker/src/lib/utils.ts +6 -0
  18. package/packages/date-time-picker/src/styles.css +19 -0
  19. package/packages/date-time-picker/src/ui/button.tsx +51 -0
  20. package/packages/date-time-picker/src/ui/calendar.tsx +159 -0
  21. package/packages/date-time-picker/src/ui/collapsible.tsx +23 -0
  22. package/packages/date-time-picker/src/ui/popover.tsx +41 -0
  23. package/packages/date-time-picker/tsconfig.json +8 -0
  24. package/packages/date-time-picker/tsup.config.ts +23 -0
  25. package/pnpm-workspace.yaml +2 -0
  26. 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 &amp; 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
+ }