@djangocfg/ui-core 2.1.512 → 2.1.514
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/README.md +5 -3
- package/package.json +4 -4
- package/src/components/data/calendar/calendar.tsx +38 -6
- package/src/components/data/table/index.tsx +8 -1
- package/src/components/forms/checkbox/index.tsx +3 -1
- package/src/components/forms/datetime-field/README.md +80 -0
- package/src/components/forms/datetime-field/date-field.tsx +165 -0
- package/src/components/forms/datetime-field/date-time-field.tsx +214 -0
- package/src/components/forms/datetime-field/date-time-utils.ts +48 -0
- package/src/components/forms/datetime-field/index.ts +30 -0
- package/src/components/forms/datetime-field/native-field-shell.tsx +113 -0
- package/src/components/forms/datetime-field/popover-chevron.tsx +66 -0
- package/src/components/forms/datetime-field/time-field.tsx +103 -0
- package/src/components/forms/label/index.tsx +9 -1
- package/src/components/forms/radio-group/index.tsx +3 -1
- package/src/components/forms/switch/index.tsx +3 -1
- package/src/components/index.ts +21 -1
- package/src/components/overlay/popover/index.tsx +7 -0
- package/src/styles/css/presets/dense.css +13 -2
- package/src/styles/css/presets/ios.css +17 -3
- package/src/styles/css/presets/macos.css +17 -3
- package/src/styles/css/presets/soft.css +12 -2
- package/src/styles/css/presets/windows.css +12 -2
- package/src/styles/css/theme/dark.css +12 -2
- package/src/styles/css/utilities/datetime-field.css +65 -0
- package/src/styles/css/utilities.css +1 -0
package/README.md
CHANGED
|
@@ -51,17 +51,19 @@ import { UiProviders, Button, Card } from '@djangocfg/ui-core';
|
|
|
51
51
|
| Group | Examples |
|
|
52
52
|
|---|---|
|
|
53
53
|
| `components/data/` | Avatar · Badge · Card · Table · BalancedText · Skeleton |
|
|
54
|
-
| `components/forms/` | Button · Input · Textarea · Select · Switch · Checkbox · Slider · Form |
|
|
54
|
+
| `components/forms/` | Button · Input · Textarea · Select · Switch · Checkbox · Slider · Form · DateField · TimeField · DateTimeField |
|
|
55
55
|
| `components/feedback/` | Alert · Toast · Banner · Progress · Spinner |
|
|
56
56
|
| `components/overlay/` | Dialog · Drawer · Popover · Tooltip · HoverCard · Sheet · ContextMenu · DropdownMenu |
|
|
57
57
|
| `components/navigation/` | Sidebar · Tabs · Breadcrumb · Pagination · NavigationMenu · Command · Disclosure |
|
|
58
58
|
| `components/layout/` | Container · Grid · Stack · Separator · ScrollArea · Sticky |
|
|
59
59
|
| `components/select/` | Combobox · MultiSelect |
|
|
60
60
|
| `components/effects/` | Glass · Marquee · Backdrop |
|
|
61
|
-
| `components/specialized/` | Accordion · Collapsible · Toggle · Calendar · DatePicker |
|
|
61
|
+
| `components/specialized/` | Accordion · Collapsible · Toggle · Calendar · DatePicker (legacy — prefer forms/DateField) |
|
|
62
62
|
| `components/boundary/` | ErrorBoundary |
|
|
63
63
|
|
|
64
|
-
Imports stay flat — group folders are organisational.
|
|
64
|
+
Imports stay flat — group folders are organisational. Native-engine date/time
|
|
65
|
+
fields (`DateField` / `TimeField` / `DateTimeField`) have their own reference in
|
|
66
|
+
[`forms/datetime-field/README.md`](src/components/forms/datetime-field/README.md).
|
|
65
67
|
|
|
66
68
|
## Hooks (`/hooks`)
|
|
67
69
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-core",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.514",
|
|
4
4
|
"description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ui-components",
|
|
@@ -128,7 +128,7 @@
|
|
|
128
128
|
"check:contrast": "node scripts/check-preset-contrast.mjs"
|
|
129
129
|
},
|
|
130
130
|
"peerDependencies": {
|
|
131
|
-
"@djangocfg/i18n": "^2.1.
|
|
131
|
+
"@djangocfg/i18n": "^2.1.514",
|
|
132
132
|
"consola": "^3.4.2",
|
|
133
133
|
"lucide-react": "^0.545.0",
|
|
134
134
|
"moment": "^2.30.1",
|
|
@@ -206,8 +206,8 @@
|
|
|
206
206
|
"@chenglou/pretext": "^0.0.8"
|
|
207
207
|
},
|
|
208
208
|
"devDependencies": {
|
|
209
|
-
"@djangocfg/i18n": "^2.1.
|
|
210
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
209
|
+
"@djangocfg/i18n": "^2.1.514",
|
|
210
|
+
"@djangocfg/typescript-config": "^2.1.514",
|
|
211
211
|
"@types/node": "^24.13.3",
|
|
212
212
|
"@types/react": "19.2.15",
|
|
213
213
|
"@types/react-dom": "19.2.3",
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
+
import { enUS, ko, ru, type Locale } from 'date-fns/locale';
|
|
3
4
|
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
|
4
5
|
import * as React from 'react';
|
|
5
6
|
import { DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker';
|
|
6
7
|
|
|
8
|
+
import { useLocaleOptional } from '@djangocfg/i18n';
|
|
7
9
|
import { cn } from '../../../lib/utils';
|
|
8
10
|
import { Button, buttonVariants } from '../../forms/button';
|
|
9
11
|
|
|
12
|
+
/** App locale code → date-fns Locale for react-day-picker (month/weekday
|
|
13
|
+
* names). `useLocaleOptional` returns null with no I18nProvider, so the
|
|
14
|
+
* calendar still works standalone (falls back to English). */
|
|
15
|
+
const DATE_FNS_LOCALES: Record<string, Locale> = { en: enUS, ru, ko };
|
|
16
|
+
|
|
10
17
|
function Calendar({
|
|
11
18
|
className,
|
|
12
19
|
classNames,
|
|
@@ -15,17 +22,27 @@ function Calendar({
|
|
|
15
22
|
buttonVariant = "ghost",
|
|
16
23
|
formatters,
|
|
17
24
|
components,
|
|
25
|
+
locale,
|
|
18
26
|
...props
|
|
19
27
|
}: React.ComponentProps<typeof DayPicker> & {
|
|
20
28
|
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
|
21
29
|
}) {
|
|
22
30
|
const defaultClassNames = getDefaultClassNames()
|
|
23
31
|
|
|
32
|
+
// Localize month/weekday names from the app locale. An explicit `locale` prop
|
|
33
|
+
// still wins (the caller can override), and a bare calendar with no provider
|
|
34
|
+
// falls back to English.
|
|
35
|
+
const appLocale = useLocaleOptional()
|
|
36
|
+
const resolvedLocale = locale ?? (appLocale ? DATE_FNS_LOCALES[appLocale] : undefined)
|
|
37
|
+
|
|
24
38
|
return (
|
|
25
39
|
<DayPicker
|
|
26
40
|
showOutsideDays={showOutsideDays}
|
|
41
|
+
locale={resolvedLocale}
|
|
42
|
+
// Cell size: compact by default (fits a narrow popover); roomier on ≥sm
|
|
43
|
+
// so the desktop popover isn't cramped.
|
|
27
44
|
className={cn(
|
|
28
|
-
"bg-background group/calendar p-3 [--cell-size:
|
|
45
|
+
"bg-background group/calendar p-3 [--cell-size:1.75rem] sm:[--cell-size:2.25rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
|
29
46
|
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
|
30
47
|
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
|
31
48
|
className
|
|
@@ -96,17 +113,27 @@ function Calendar({
|
|
|
96
113
|
defaultClassNames.week_number
|
|
97
114
|
),
|
|
98
115
|
day: cn(
|
|
99
|
-
|
|
116
|
+
// Range end-caps use `rounded-lg` to MATCH the single-day / today
|
|
117
|
+
// radius (`rounded-lg` on the button). Previously these were
|
|
118
|
+
// `rounded-l-md`/`rounded-r-md`, which stamped a 6px radius onto just
|
|
119
|
+
// the left OR right corners of a cell — so today/selected days at a
|
|
120
|
+
// row edge came out asymmetric (sharp one side, round the other).
|
|
121
|
+
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-lg [&:last-child[data-selected=true]_button]:rounded-r-lg",
|
|
100
122
|
defaultClassNames.day
|
|
101
123
|
),
|
|
102
124
|
range_start: cn(
|
|
103
|
-
"bg-accent rounded-l-
|
|
125
|
+
"bg-accent rounded-l-lg",
|
|
104
126
|
defaultClassNames.range_start
|
|
105
127
|
),
|
|
106
128
|
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
|
107
|
-
range_end: cn("bg-accent rounded-r-
|
|
129
|
+
range_end: cn("bg-accent rounded-r-lg", defaultClassNames.range_end),
|
|
108
130
|
today: cn(
|
|
109
|
-
"
|
|
131
|
+
// "Today" ring only when NOT the selected day — otherwise the accent
|
|
132
|
+
// panel bled out behind the primary selection and `rounded-none`
|
|
133
|
+
// squared off one cell, so a selected "today" looked lop-sided vs
|
|
134
|
+
// other selected days. When selected, the selection styles own the
|
|
135
|
+
// cell entirely (full `rounded-md`, primary fill).
|
|
136
|
+
"rounded-lg data-[selected=true]:bg-transparent data-[selected=true]:text-inherit data-[selected=false]:bg-accent data-[selected=false]:text-accent-foreground",
|
|
110
137
|
defaultClassNames.today
|
|
111
138
|
),
|
|
112
139
|
outside: cn(
|
|
@@ -197,7 +224,12 @@ function CalendarDayButton({
|
|
|
197
224
|
data-range-end={modifiers.range_end}
|
|
198
225
|
data-range-middle={modifiers.range_middle}
|
|
199
226
|
className={cn(
|
|
200
|
-
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring
|
|
227
|
+
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring",
|
|
228
|
+
// Centered content + a radius that scales with the (now larger) cell so
|
|
229
|
+
// the selected day reads as a rounded square, not a hard box.
|
|
230
|
+
"flex aspect-square h-auto w-full min-w-[--cell-size] flex-col items-center justify-center gap-1 rounded-lg font-normal leading-none",
|
|
231
|
+
"data-[range-end=true]:rounded-lg data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-lg",
|
|
232
|
+
"group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-1 [&>span]:text-xs [&>span]:opacity-70",
|
|
201
233
|
defaultClassNames.day,
|
|
202
234
|
className
|
|
203
235
|
)}
|
|
@@ -47,6 +47,9 @@ const TableHeader = React.forwardRef<
|
|
|
47
47
|
HTMLTableSectionElement,
|
|
48
48
|
React.HTMLAttributes<HTMLTableSectionElement> & { sticky?: boolean }
|
|
49
49
|
>(({ className, sticky = false, ...props }, ref) => (
|
|
50
|
+
// The header band KEEPS `--border`: it divides labels from data, which is a
|
|
51
|
+
// structural edge rather than one row from the next. Frame and header are
|
|
52
|
+
// the two strong lines; everything between rows is quieter.
|
|
50
53
|
// Tinted header band with a solid bottom border so the column labels
|
|
51
54
|
// separate clearly from the body in both light and dark.
|
|
52
55
|
// When `sticky`, the band must be fully opaque (solid `bg-muted`, not the
|
|
@@ -104,7 +107,11 @@ const TableRow = React.forwardRef<
|
|
|
104
107
|
<tr
|
|
105
108
|
ref={ref}
|
|
106
109
|
className={cn(
|
|
107
|
-
|
|
110
|
+
// `--divider`, not `--border`. A row separator lives INSIDE the panel, so
|
|
111
|
+
// it must read as weaker than the frame around it; both drawing
|
|
112
|
+
// `--border` made five internal lines shout as loudly as the one edge,
|
|
113
|
+
// and the table read as a grid of boxes rather than a list.
|
|
114
|
+
"border-b border-divider transition-colors hover:bg-muted/50 data-[state=selected]:bg-accent",
|
|
108
115
|
className
|
|
109
116
|
)}
|
|
110
117
|
{...props}
|
|
@@ -14,7 +14,9 @@ const Checkbox = React.forwardRef<
|
|
|
14
14
|
<CheckboxPrimitive.Root
|
|
15
15
|
ref={ref}
|
|
16
16
|
className={cn(
|
|
17
|
-
|
|
17
|
+
// `select-none`: the box itself is a click target, and a drag that starts
|
|
18
|
+
// on it must not paint a text selection across the row it lives in.
|
|
19
|
+
"peer h-[1.125rem] w-[1.125rem] shrink-0 select-none rounded-[4px] border border-input bg-background shadow-none",
|
|
18
20
|
"transition-colors duration-150",
|
|
19
21
|
"focus-visible:border-ring focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
|
20
22
|
"disabled:cursor-not-allowed disabled:opacity-40",
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Date/time fields
|
|
2
|
+
|
|
3
|
+
`DateField` · `TimeField` · `DateTimeField` — native-engine, token-skinned
|
|
4
|
+
date/time inputs.
|
|
5
|
+
|
|
6
|
+
The browser's `<input type="date|time|datetime-local">` **is** the input engine:
|
|
7
|
+
segmented keyboard entry, locale-aware order, 12/24-hour by locale, arrow
|
|
8
|
+
increment, and the OS wheel on touch — all for free. This layer adds the
|
|
9
|
+
token-styled box, a leading icon, and (for date fields) a calendar popover for
|
|
10
|
+
mouse users. Values are ISO strings, so there's no timezone ambiguity at the
|
|
11
|
+
boundary.
|
|
12
|
+
|
|
13
|
+
| Component | `value` / `onChange` | Popover |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| `TimeField` | `"HH:mm"` (24h; `"HH:mm:ss"` when `step` < 60) | none — pure native segments |
|
|
16
|
+
| `DateField` | `"YYYY-MM-DD"` | `Calendar` (mouse; touch → OS wheel) |
|
|
17
|
+
| `DateTimeField` | `"YYYY-MM-DDTHH:mm"` | `Calendar` + a native `<input type="time">` |
|
|
18
|
+
|
|
19
|
+
## Props
|
|
20
|
+
|
|
21
|
+
**Shared:**
|
|
22
|
+
|
|
23
|
+
| Prop | Type | Notes |
|
|
24
|
+
|---|---|---|
|
|
25
|
+
| `value` / `defaultValue` | `string` | Controlled / uncontrolled (ISO, see table). |
|
|
26
|
+
| `onChange` | `(value: string) => void` | Emits the ISO string. |
|
|
27
|
+
| `disabled` | `boolean` | |
|
|
28
|
+
| `invalid` | `boolean` | Wires `aria-invalid` + destructive border. Usually driven by the `Field` / `Form` layer. |
|
|
29
|
+
| `min` / `max` | `string` | Native bounds, same ISO shape as the value. |
|
|
30
|
+
| `name` / `id` | `string` | The native input **is** the form control — no hidden-input shim. `id` lets an external `<label htmlFor>` bind to the real control. |
|
|
31
|
+
| `className` | `string` | Applied to the field shell. |
|
|
32
|
+
| `aria-label` | `string` | |
|
|
33
|
+
|
|
34
|
+
**`TimeField` only:** `step?: number` — native `step` in seconds (`60` → HH:mm;
|
|
35
|
+
smaller exposes a seconds segment; controls arrow increment).
|
|
36
|
+
|
|
37
|
+
**`DateField` / `DateTimeField` only:**
|
|
38
|
+
|
|
39
|
+
- `align?: 'start' | 'center' | 'end'` — popover alignment (default `'start'`,
|
|
40
|
+
which keeps it under the field's left edge; Radix auto-flips on collision).
|
|
41
|
+
- `showPopover?: boolean` — default **AUTO**: shown on fine-pointer (mouse)
|
|
42
|
+
devices, hidden on coarse-pointer (touch) ones, where tapping the field opens
|
|
43
|
+
the OS date/time wheel — a better target than a mouse-first grid. Pass an
|
|
44
|
+
explicit `true` / `false` to force it. (`DateField`'s popover is the calendar;
|
|
45
|
+
`DateTimeField`'s is the calendar + a native time input.)
|
|
46
|
+
|
|
47
|
+
## Behaviour notes
|
|
48
|
+
|
|
49
|
+
- **AM/PM vs 24-hour is the browser's call**, from the user's OS locale. We never
|
|
50
|
+
render our own time picker, so there's nothing to force or to desync. The wire
|
|
51
|
+
value is always 24h `HH:mm`.
|
|
52
|
+
- **Calendar localization**: month/weekday names come from the app locale via
|
|
53
|
+
`useLocaleOptional()` (`@djangocfg/i18n`) → date-fns locale. A bare calendar
|
|
54
|
+
with no `I18nProvider` falls back to English.
|
|
55
|
+
- **Local-safe dates**: `date-time-utils.ts` parses/formats `YYYY-MM-DD`
|
|
56
|
+
manually (not via `new Date(str)` / `toISOString()`), so the calendar day never
|
|
57
|
+
shifts by timezone.
|
|
58
|
+
- **Popover fit**: `collisionPadding` keeps it off the viewport/modal edge; there
|
|
59
|
+
is deliberately **no** `maxHeight` cap (that used the trigger-to-viewport gap
|
|
60
|
+
and clipped the calendar when the field sat low) — Radix's default
|
|
61
|
+
`avoidCollisions` flips the short popover above the field instead. A Popover
|
|
62
|
+
inside a Dialog can additionally be handed a `collisionBoundary` by the caller.
|
|
63
|
+
- **Native chrome**: the browser's own clock/calendar glyph and spin buttons are
|
|
64
|
+
suppressed via `.native-datetime-input` in
|
|
65
|
+
`styles/css/utilities/datetime-field.css`; `.native-datetime-input--center`
|
|
66
|
+
centers a value's segments cross-engine.
|
|
67
|
+
|
|
68
|
+
## Migration
|
|
69
|
+
|
|
70
|
+
The old click-only `TimePicker` / `DatePicker` stay exported as back-compat
|
|
71
|
+
aliases (zero consumer churn). Prefer these `*Field` components. `DateRangePicker`
|
|
72
|
+
is unchanged (still the react-day-picker range grid).
|
|
73
|
+
|
|
74
|
+
## Files
|
|
75
|
+
|
|
76
|
+
- `native-field-shell.tsx` — the shared token-styled box (surface, focus ring,
|
|
77
|
+
icon slot, invalid/disabled).
|
|
78
|
+
- `date-field.tsx` / `time-field.tsx` / `date-time-field.tsx` — the three fields.
|
|
79
|
+
- `popover-chevron.tsx` — the leading `IconTrigger` + trailing `PopoverChevron`.
|
|
80
|
+
- `date-time-utils.ts` — local-safe ISO ↔ `Date` helpers.
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DateField — native `<input type="date">` engine + our skin + optional
|
|
5
|
+
* `Calendar` popover. Type the date, or click the chevron to pick from the grid.
|
|
6
|
+
* Wire value is `YYYY-MM-DD` (native semantics, timezone-less).
|
|
7
|
+
*
|
|
8
|
+
* Replaces the click-only `DatePicker` (kept as a back-compat alias).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { CalendarIcon } from 'lucide-react';
|
|
12
|
+
import * as React from 'react';
|
|
13
|
+
|
|
14
|
+
import { useIsTouch } from '../../../hooks/media';
|
|
15
|
+
import { Popover, PopoverContent, PopoverTrigger } from '../../overlay/popover';
|
|
16
|
+
import { Calendar } from '../../data/calendar/calendar';
|
|
17
|
+
import { formatDateString, parseDateString } from './date-time-utils';
|
|
18
|
+
import { NativeFieldShell, NATIVE_FIELD_INPUT_CLASS } from './native-field-shell';
|
|
19
|
+
import { IconTrigger, PopoverChevron } from './popover-chevron';
|
|
20
|
+
|
|
21
|
+
export interface DateFieldProps {
|
|
22
|
+
/** Value as `YYYY-MM-DD`. Controlled. */
|
|
23
|
+
value?: string;
|
|
24
|
+
/** Uncontrolled initial value. */
|
|
25
|
+
defaultValue?: string;
|
|
26
|
+
onChange?: (value: string) => void;
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
/** Invalid state — usually driven by the `Field` / `Form` layer. */
|
|
29
|
+
invalid?: boolean;
|
|
30
|
+
/** Native min/max (`YYYY-MM-DD`); also bound the calendar grid. */
|
|
31
|
+
min?: string;
|
|
32
|
+
max?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Show the calendar chevron + grid.
|
|
35
|
+
*
|
|
36
|
+
* Default `undefined` = AUTO: shown on fine-pointer (mouse) devices, hidden on
|
|
37
|
+
* coarse-pointer (touch) ones, which get the OS date wheel from the native
|
|
38
|
+
* input instead — a bigger, better tap target than our mouse-first grid. Pass
|
|
39
|
+
* an explicit `true`/`false` to force it.
|
|
40
|
+
*/
|
|
41
|
+
showPopover?: boolean;
|
|
42
|
+
/** Form field name — the native input IS the form control. */
|
|
43
|
+
name?: string;
|
|
44
|
+
/**
|
|
45
|
+
* DOM id for the native control, so an external `<label htmlFor>` binds to
|
|
46
|
+
* the field a user actually focuses. Without it a caller replacing a bare
|
|
47
|
+
* `<input id=…>` with this component silently breaks its own label: the
|
|
48
|
+
* text still renders, but clicking it focuses nothing.
|
|
49
|
+
*/
|
|
50
|
+
id?: string;
|
|
51
|
+
|
|
52
|
+
className?: string;
|
|
53
|
+
align?: 'start' | 'center' | 'end';
|
|
54
|
+
'aria-label'?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const DateField = React.forwardRef<HTMLInputElement, DateFieldProps>(
|
|
58
|
+
(
|
|
59
|
+
{
|
|
60
|
+
value: controlledValue,
|
|
61
|
+
defaultValue,
|
|
62
|
+
onChange,
|
|
63
|
+
disabled = false,
|
|
64
|
+
invalid = false,
|
|
65
|
+
min,
|
|
66
|
+
max,
|
|
67
|
+
showPopover,
|
|
68
|
+
name,
|
|
69
|
+
id,
|
|
70
|
+
className,
|
|
71
|
+
align = 'start',
|
|
72
|
+
'aria-label': ariaLabel,
|
|
73
|
+
},
|
|
74
|
+
ref,
|
|
75
|
+
) => {
|
|
76
|
+
const isControlled = controlledValue !== undefined;
|
|
77
|
+
const [internal, setInternal] = React.useState(defaultValue ?? '');
|
|
78
|
+
const value = isControlled ? controlledValue : internal;
|
|
79
|
+
|
|
80
|
+
const [open, setOpen] = React.useState(false);
|
|
81
|
+
|
|
82
|
+
// AUTO popover — see the showPopover prop doc. Touch → OS date wheel.
|
|
83
|
+
const isTouch = useIsTouch();
|
|
84
|
+
const withPopover = showPopover ?? !isTouch;
|
|
85
|
+
|
|
86
|
+
const commit = React.useCallback(
|
|
87
|
+
(next: string) => {
|
|
88
|
+
if (!isControlled) setInternal(next);
|
|
89
|
+
onChange?.(next);
|
|
90
|
+
},
|
|
91
|
+
[isControlled, onChange],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const selected = parseDateString(value);
|
|
95
|
+
const fromDate = min ? parseDateString(min) : undefined;
|
|
96
|
+
const toDate = max ? parseDateString(max) : undefined;
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
100
|
+
<NativeFieldShell
|
|
101
|
+
icon={
|
|
102
|
+
withPopover ? (
|
|
103
|
+
<PopoverTrigger asChild>
|
|
104
|
+
<IconTrigger disabled={disabled} label="Open calendar">
|
|
105
|
+
<CalendarIcon aria-hidden />
|
|
106
|
+
</IconTrigger>
|
|
107
|
+
</PopoverTrigger>
|
|
108
|
+
) : (
|
|
109
|
+
<CalendarIcon aria-hidden />
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
iconOpensPopover={withPopover}
|
|
113
|
+
invalid={invalid}
|
|
114
|
+
disabled={disabled}
|
|
115
|
+
className={className}
|
|
116
|
+
popoverTrigger={
|
|
117
|
+
withPopover ? (
|
|
118
|
+
<PopoverTrigger asChild>
|
|
119
|
+
<PopoverChevron disabled={disabled} label="Open calendar" />
|
|
120
|
+
</PopoverTrigger>
|
|
121
|
+
) : null
|
|
122
|
+
}
|
|
123
|
+
>
|
|
124
|
+
<input
|
|
125
|
+
ref={ref}
|
|
126
|
+
type="date"
|
|
127
|
+
className={NATIVE_FIELD_INPUT_CLASS}
|
|
128
|
+
value={value}
|
|
129
|
+
onChange={(e) => commit(e.target.value)}
|
|
130
|
+
disabled={disabled}
|
|
131
|
+
min={min}
|
|
132
|
+
max={max}
|
|
133
|
+
name={name}
|
|
134
|
+
id={id}
|
|
135
|
+
aria-label={ariaLabel}
|
|
136
|
+
aria-invalid={invalid || undefined}
|
|
137
|
+
/>
|
|
138
|
+
</NativeFieldShell>
|
|
139
|
+
{withPopover ? (
|
|
140
|
+
<PopoverContent
|
|
141
|
+
// Short popover (calendar only) → no height cap; let Radix flip it
|
|
142
|
+
// above the field when there's no room below, rather than clipping.
|
|
143
|
+
className="w-auto p-0"
|
|
144
|
+
align={align}
|
|
145
|
+
collisionPadding={12}
|
|
146
|
+
>
|
|
147
|
+
<Calendar
|
|
148
|
+
mode="single"
|
|
149
|
+
selected={selected}
|
|
150
|
+
onSelect={(date) => {
|
|
151
|
+
commit(date ? formatDateString(date) : '');
|
|
152
|
+
setOpen(false);
|
|
153
|
+
}}
|
|
154
|
+
disabled={disabled}
|
|
155
|
+
fromDate={fromDate}
|
|
156
|
+
toDate={toDate}
|
|
157
|
+
initialFocus
|
|
158
|
+
/>
|
|
159
|
+
</PopoverContent>
|
|
160
|
+
) : null}
|
|
161
|
+
</Popover>
|
|
162
|
+
);
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
DateField.displayName = 'DateField';
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DateTimeField — the combined date+time field that ui-core was missing. Native
|
|
5
|
+
* `<input type="datetime-local">` engine + our skin; the popover pairs the
|
|
6
|
+
* `Calendar` grid with the time columns so a scheduler UI is one control, not a
|
|
7
|
+
* hand-wired DatePicker + TimePicker.
|
|
8
|
+
*
|
|
9
|
+
* Wire value is `YYYY-MM-DDTHH:mm` (native `datetime-local` semantics,
|
|
10
|
+
* timezone-less wall-clock).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { CalendarClock, Clock } from 'lucide-react';
|
|
14
|
+
import * as React from 'react';
|
|
15
|
+
|
|
16
|
+
import { cn } from '../../../lib/utils';
|
|
17
|
+
import { useIsTouch } from '../../../hooks/media';
|
|
18
|
+
import { Popover, PopoverContent, PopoverTrigger } from '../../overlay/popover';
|
|
19
|
+
import { Calendar } from '../../data/calendar/calendar';
|
|
20
|
+
import {
|
|
21
|
+
formatDateString,
|
|
22
|
+
joinDateTime,
|
|
23
|
+
parseDateString,
|
|
24
|
+
splitDateTime,
|
|
25
|
+
} from './date-time-utils';
|
|
26
|
+
import { NativeFieldShell, NATIVE_FIELD_INPUT_CLASS } from './native-field-shell';
|
|
27
|
+
import { IconTrigger, PopoverChevron } from './popover-chevron';
|
|
28
|
+
|
|
29
|
+
export interface DateTimeFieldProps {
|
|
30
|
+
/** Value as `YYYY-MM-DDTHH:mm`. Controlled. */
|
|
31
|
+
value?: string;
|
|
32
|
+
/** Uncontrolled initial value. */
|
|
33
|
+
defaultValue?: string;
|
|
34
|
+
onChange?: (value: string) => void;
|
|
35
|
+
disabled?: boolean;
|
|
36
|
+
/** Invalid state — usually driven by the `Field` / `Form` layer. */
|
|
37
|
+
invalid?: boolean;
|
|
38
|
+
/** Native min/max (`YYYY-MM-DDTHH:mm`). */
|
|
39
|
+
min?: string;
|
|
40
|
+
max?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Show the popover chevron + combined calendar/time picker.
|
|
43
|
+
*
|
|
44
|
+
* Default `undefined` = AUTO: shown on fine-pointer (mouse) devices, hidden on
|
|
45
|
+
* coarse-pointer (touch) ones. On touch the native `datetime-local` input
|
|
46
|
+
* opens the OS date+time wheel — a better tap target than our tall combined
|
|
47
|
+
* grid, which is also the hardest of the three to fit on a small screen. Pass
|
|
48
|
+
* an explicit `true`/`false` to force it.
|
|
49
|
+
*/
|
|
50
|
+
showPopover?: boolean;
|
|
51
|
+
/** Form field name — the native input IS the form control. */
|
|
52
|
+
name?: string;
|
|
53
|
+
/**
|
|
54
|
+
* DOM id for the native control, so an external `<label htmlFor>` binds to
|
|
55
|
+
* the field a user actually focuses. Without it a caller replacing a bare
|
|
56
|
+
* `<input id=…>` with this component silently breaks its own label: the
|
|
57
|
+
* text still renders, but clicking it focuses nothing.
|
|
58
|
+
*/
|
|
59
|
+
id?: string;
|
|
60
|
+
|
|
61
|
+
className?: string;
|
|
62
|
+
align?: 'start' | 'center' | 'end';
|
|
63
|
+
'aria-label'?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const DateTimeField = React.forwardRef<HTMLInputElement, DateTimeFieldProps>(
|
|
67
|
+
(
|
|
68
|
+
{
|
|
69
|
+
value: controlledValue,
|
|
70
|
+
defaultValue,
|
|
71
|
+
onChange,
|
|
72
|
+
disabled = false,
|
|
73
|
+
invalid = false,
|
|
74
|
+
min,
|
|
75
|
+
max,
|
|
76
|
+
showPopover,
|
|
77
|
+
name,
|
|
78
|
+
id,
|
|
79
|
+
className,
|
|
80
|
+
align = 'start',
|
|
81
|
+
'aria-label': ariaLabel,
|
|
82
|
+
},
|
|
83
|
+
ref,
|
|
84
|
+
) => {
|
|
85
|
+
const isControlled = controlledValue !== undefined;
|
|
86
|
+
const [internal, setInternal] = React.useState(defaultValue ?? '');
|
|
87
|
+
const value = isControlled ? controlledValue : internal;
|
|
88
|
+
|
|
89
|
+
const [open, setOpen] = React.useState(false);
|
|
90
|
+
|
|
91
|
+
// AUTO popover — see the showPopover prop doc. Touch → OS date+time wheel.
|
|
92
|
+
const isTouch = useIsTouch();
|
|
93
|
+
const withPopover = showPopover ?? !isTouch;
|
|
94
|
+
|
|
95
|
+
const commit = React.useCallback(
|
|
96
|
+
(next: string) => {
|
|
97
|
+
if (!isControlled) setInternal(next);
|
|
98
|
+
onChange?.(next);
|
|
99
|
+
},
|
|
100
|
+
[isControlled, onChange],
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const { date, time } = splitDateTime(value);
|
|
104
|
+
const selected = parseDateString(date);
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
108
|
+
<NativeFieldShell
|
|
109
|
+
icon={
|
|
110
|
+
withPopover ? (
|
|
111
|
+
<PopoverTrigger asChild>
|
|
112
|
+
<IconTrigger disabled={disabled} label="Open date & time picker">
|
|
113
|
+
<CalendarClock aria-hidden />
|
|
114
|
+
</IconTrigger>
|
|
115
|
+
</PopoverTrigger>
|
|
116
|
+
) : (
|
|
117
|
+
<CalendarClock aria-hidden />
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
iconOpensPopover={withPopover}
|
|
121
|
+
invalid={invalid}
|
|
122
|
+
disabled={disabled}
|
|
123
|
+
className={className}
|
|
124
|
+
popoverTrigger={
|
|
125
|
+
withPopover ? (
|
|
126
|
+
<PopoverTrigger asChild>
|
|
127
|
+
<PopoverChevron disabled={disabled} label="Open date & time picker" />
|
|
128
|
+
</PopoverTrigger>
|
|
129
|
+
) : null
|
|
130
|
+
}
|
|
131
|
+
>
|
|
132
|
+
<input
|
|
133
|
+
ref={ref}
|
|
134
|
+
type="datetime-local"
|
|
135
|
+
className={NATIVE_FIELD_INPUT_CLASS}
|
|
136
|
+
value={value}
|
|
137
|
+
onChange={(e) => commit(e.target.value)}
|
|
138
|
+
disabled={disabled}
|
|
139
|
+
min={min}
|
|
140
|
+
max={max}
|
|
141
|
+
name={name}
|
|
142
|
+
id={id}
|
|
143
|
+
aria-label={ariaLabel}
|
|
144
|
+
aria-invalid={invalid || undefined}
|
|
145
|
+
/>
|
|
146
|
+
</NativeFieldShell>
|
|
147
|
+
{withPopover ? (
|
|
148
|
+
// The official shadcn-2026 shape: the popover holds ONLY the calendar
|
|
149
|
+
// (date), and time is a small NATIVE `<input type="time">` beneath it.
|
|
150
|
+
// No scroll columns — those were the source of the pill/height/width
|
|
151
|
+
// churn. `align="start"` (not `align` default center) is what stops
|
|
152
|
+
// the popover sliding off the right edge on a narrow surface; Radix
|
|
153
|
+
// still auto-flips on collision. `w-auto p-0` sizes to the calendar.
|
|
154
|
+
<PopoverContent
|
|
155
|
+
// `w-auto` sizes to the calendar; `align="start"` keeps it under the
|
|
156
|
+
// left edge so it doesn't slide off-screen. NO `maxHeight` cap here:
|
|
157
|
+
// the calendar + time input is a SHORT popover, and
|
|
158
|
+
// `--radix-popover-content-available-height` is the gap from the
|
|
159
|
+
// trigger to the viewport edge — when the field sits low on screen
|
|
160
|
+
// that gap is tiny, so the cap was CLIPPING the calendar (and the
|
|
161
|
+
// time row under it). Instead we let Radix's default avoidCollisions
|
|
162
|
+
// flip the whole popover ABOVE the field when there's no room below.
|
|
163
|
+
// `collisionPadding` just keeps it off the very edge.
|
|
164
|
+
className="w-auto p-0"
|
|
165
|
+
align="start"
|
|
166
|
+
collisionPadding={12}
|
|
167
|
+
>
|
|
168
|
+
<Calendar
|
|
169
|
+
mode="single"
|
|
170
|
+
selected={selected}
|
|
171
|
+
onSelect={(picked) => {
|
|
172
|
+
// Keep the time; swap the date. Default to 00:00 on a first pick
|
|
173
|
+
// of an empty field so the result is a valid datetime.
|
|
174
|
+
const nextDate = picked ? formatDateString(picked) : '';
|
|
175
|
+
commit(joinDateTime(nextDate, time || '00:00'));
|
|
176
|
+
}}
|
|
177
|
+
disabled={disabled}
|
|
178
|
+
initialFocus
|
|
179
|
+
/>
|
|
180
|
+
{/* Time row: a full, comfortable field — not a cramped chip. The
|
|
181
|
+
* label + clock icon sit left; the native time input fills the
|
|
182
|
+
* rest at the same height as the main field (h-10) so it's an easy
|
|
183
|
+
* tap/click target and reads as a real input. */}
|
|
184
|
+
<div className="flex items-center gap-2 border-t p-3">
|
|
185
|
+
<Clock className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
|
186
|
+
<span className="text-sm font-medium text-muted-foreground">Time</span>
|
|
187
|
+
<input
|
|
188
|
+
type="time"
|
|
189
|
+
aria-label="Time"
|
|
190
|
+
// Explicit width, not min-w on a flex — a native `<input>` is a
|
|
191
|
+
// replaced element and won't reliably grow to a flex min-width,
|
|
192
|
+
// which is why the earlier `min-w-[8rem]` did nothing. A fixed
|
|
193
|
+
// width + centered segments gives the value real breathing room.
|
|
194
|
+
style={{ width: '8.5rem' }}
|
|
195
|
+
className={cn(
|
|
196
|
+
// `--center` variant centers the native segments (see
|
|
197
|
+
// datetime-field.css); it sets its own display/justify.
|
|
198
|
+
'native-datetime-input native-datetime-input--center ml-auto h-10 rounded-[var(--radius)]',
|
|
199
|
+
'border border-border bg-input px-4 text-base tabular-nums text-foreground shadow-sm md:text-sm',
|
|
200
|
+
'focus-visible:border-ring focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
|
|
201
|
+
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
202
|
+
)}
|
|
203
|
+
value={time}
|
|
204
|
+
onChange={(e) => commit(joinDateTime(date, e.target.value))}
|
|
205
|
+
disabled={disabled}
|
|
206
|
+
/>
|
|
207
|
+
</div>
|
|
208
|
+
</PopoverContent>
|
|
209
|
+
) : null}
|
|
210
|
+
</Popover>
|
|
211
|
+
);
|
|
212
|
+
},
|
|
213
|
+
);
|
|
214
|
+
DateTimeField.displayName = 'DateTimeField';
|