@djangocfg/ui-core 2.1.511 → 2.1.513
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 +2 -2
- 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/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,14 +51,14 @@ 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
64
|
Imports stay flat — group folders are organisational.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-core",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.513",
|
|
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.513",
|
|
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.513",
|
|
210
|
+
"@djangocfg/typescript-config": "^2.1.513",
|
|
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,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';
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local-date <-> string helpers for the native fields.
|
|
3
|
+
*
|
|
4
|
+
* Native `type="date"` / `type="datetime-local"` speak plain, timezone-less
|
|
5
|
+
* wall-clock strings (`YYYY-MM-DD`, `YYYY-MM-DDTHH:mm`). We parse/format them
|
|
6
|
+
* MANUALLY rather than via `new Date(str)` / `.toISOString()` — those go through
|
|
7
|
+
* UTC and shift the calendar day for users west/east of UTC (the classic
|
|
8
|
+
* "picked the 5th, got the 4th" bug). The `Calendar` popover works in `Date`, so
|
|
9
|
+
* we bridge with these local-safe converters.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
function pad(n: number): string {
|
|
13
|
+
return String(n).padStart(2, '0');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** `YYYY-MM-DD` -> local `Date` (midnight local). Empty/invalid -> undefined. */
|
|
17
|
+
export function parseDateString(value: string): Date | undefined {
|
|
18
|
+
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
|
19
|
+
if (!match) return undefined;
|
|
20
|
+
const [, y, m, d] = match;
|
|
21
|
+
return new Date(Number(y), Number(m) - 1, Number(d));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** local `Date` -> `YYYY-MM-DD`. */
|
|
25
|
+
export function formatDateString(date: Date): string {
|
|
26
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** `YYYY-MM-DDTHH:mm` -> { date, time } split. */
|
|
30
|
+
export function splitDateTime(value: string): { date: string; time: string } {
|
|
31
|
+
const [date = '', time = ''] = value.split('T');
|
|
32
|
+
return { date, time };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Join a `YYYY-MM-DD` date and `HH:mm` time into `YYYY-MM-DDTHH:mm`. Returns
|
|
36
|
+
* empty string when the date is missing (a time alone has no valid datetime). */
|
|
37
|
+
export function joinDateTime(date: string, time: string): string {
|
|
38
|
+
if (!date) return '';
|
|
39
|
+
return `${date}T${time || '00:00'}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `YYYY-MM-DDTHH:mm` -> local `Date`. Empty/invalid -> undefined. */
|
|
43
|
+
export function parseDateTimeString(value: string): Date | undefined {
|
|
44
|
+
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
|
|
45
|
+
if (!match) return undefined;
|
|
46
|
+
const [, y, mo, d, h, mi] = match;
|
|
47
|
+
return new Date(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi));
|
|
48
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native-engine date/time fields.
|
|
3
|
+
*
|
|
4
|
+
* A typeable, token-skinned field family built on the browser's native
|
|
5
|
+
* `<input type="date|time|datetime-local">` engine, with an optional
|
|
6
|
+
* calendar/columns popover for pointer users. See
|
|
7
|
+
* `@dev/planned/ui-core-datetime-fields/PLAN.md` for the rationale.
|
|
8
|
+
*
|
|
9
|
+
* @module datetime-field
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export { DateField } from './date-field';
|
|
13
|
+
export type { DateFieldProps } from './date-field';
|
|
14
|
+
|
|
15
|
+
export { TimeField } from './time-field';
|
|
16
|
+
export type { TimeFieldProps } from './time-field';
|
|
17
|
+
|
|
18
|
+
export { DateTimeField } from './date-time-field';
|
|
19
|
+
export type { DateTimeFieldProps } from './date-time-field';
|
|
20
|
+
|
|
21
|
+
// Low-level building blocks, exported for advanced composition.
|
|
22
|
+
export { NativeFieldShell, NATIVE_FIELD_INPUT_CLASS } from './native-field-shell';
|
|
23
|
+
export type { NativeFieldShellProps } from './native-field-shell';
|
|
24
|
+
export {
|
|
25
|
+
parseDateString,
|
|
26
|
+
formatDateString,
|
|
27
|
+
parseDateTimeString,
|
|
28
|
+
splitDateTime,
|
|
29
|
+
joinDateTime,
|
|
30
|
+
} from './date-time-utils';
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* NativeFieldShell — the shared visual layer for the native-engine date/time
|
|
5
|
+
* fields (`DateField`, `TimeField`, `DateTimeField`).
|
|
6
|
+
*
|
|
7
|
+
* The strategy (see @dev/planned/ui-core-datetime-fields/PLAN.md): the browser's
|
|
8
|
+
* native `<input type="date|time|datetime-local">` is the *input engine* — it
|
|
9
|
+
* gives segmented keyboard entry, locale-aware order, 12/24h, arrow-key
|
|
10
|
+
* increment, screen-reader support, and OS wheels on mobile for free. This shell
|
|
11
|
+
* owns everything the native input CANNOT do to our standard: the token-styled
|
|
12
|
+
* box (`bg-input`, `border-border`, `--ring` focus, `--radius`), a leading
|
|
13
|
+
* lucide icon, invalid/disabled states, and an OPTIONAL trailing popover trigger
|
|
14
|
+
* for pointer users.
|
|
15
|
+
*
|
|
16
|
+
* The native browser indicator (the clock/calendar glyph) is suppressed via
|
|
17
|
+
* `native-datetime-input` (see datetime-field.css) so there is exactly one
|
|
18
|
+
* icon — ours — and one picker — ours. Typing in the field and opening the
|
|
19
|
+
* popover both write the same value.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import * as React from 'react';
|
|
23
|
+
|
|
24
|
+
import { cn } from '../../../lib/utils';
|
|
25
|
+
|
|
26
|
+
export interface NativeFieldShellProps {
|
|
27
|
+
/** Leading icon (lucide element). */
|
|
28
|
+
icon?: React.ReactNode;
|
|
29
|
+
/**
|
|
30
|
+
* When true, the leading icon is a real, sizeable click target that opens the
|
|
31
|
+
* popover — not a decorative glyph. The field wraps the shell in a
|
|
32
|
+
* `PopoverTrigger asChild` around the icon so tapping the icon (a much bigger,
|
|
33
|
+
* left-edge target than the small chevron) opens the picker. The centre of the
|
|
34
|
+
* field still belongs to the native `<input>` for typing.
|
|
35
|
+
*/
|
|
36
|
+
iconOpensPopover?: boolean;
|
|
37
|
+
/** Invalid state — wires `aria-invalid` and a destructive border. Usually
|
|
38
|
+
* driven by the surrounding `Field` / `Form` layer. */
|
|
39
|
+
invalid?: boolean;
|
|
40
|
+
disabled?: boolean;
|
|
41
|
+
className?: string;
|
|
42
|
+
/** The native `<input>` element, already typed and wired by the field. */
|
|
43
|
+
children: React.ReactNode;
|
|
44
|
+
/**
|
|
45
|
+
* Optional popover affordance. When provided, a trailing chevron button is
|
|
46
|
+
* shown; `open`/`onOpenChange` are controlled by the field so the icon and
|
|
47
|
+
* the button share one popover.
|
|
48
|
+
*/
|
|
49
|
+
popoverTrigger?: React.ReactNode;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The class applied to the native `<input>` INSIDE the shell. Mirrors the
|
|
54
|
+
* standard `Input` surface/focus (bg-input, border, --ring) but drops the
|
|
55
|
+
* focus ring here — the ring is drawn on the shell so it wraps the icon and
|
|
56
|
+
* popover button too, reading as one control.
|
|
57
|
+
*/
|
|
58
|
+
export const NATIVE_FIELD_INPUT_CLASS = cn(
|
|
59
|
+
'native-datetime-input',
|
|
60
|
+
'peer w-full bg-transparent text-base md:text-sm outline-none',
|
|
61
|
+
'text-foreground placeholder:text-muted-foreground',
|
|
62
|
+
'disabled:cursor-not-allowed',
|
|
63
|
+
// Empty native date/time inputs render their placeholder segments muted so an
|
|
64
|
+
// unset field reads like a placeholder, not a value.
|
|
65
|
+
'[&:invalid]:text-muted-foreground',
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The outer box. Draws the token surface + the crisp `--ring` focus edge when
|
|
70
|
+
* anything inside is focused (`focus-within`), matching the standard Input's
|
|
71
|
+
* sharp Vercel/Linear look rather than a blurry halo.
|
|
72
|
+
*/
|
|
73
|
+
export const NativeFieldShell = React.forwardRef<HTMLDivElement, NativeFieldShellProps>(
|
|
74
|
+
({ icon, iconOpensPopover, invalid, disabled, className, children, popoverTrigger }, ref) => {
|
|
75
|
+
return (
|
|
76
|
+
<div
|
|
77
|
+
ref={ref}
|
|
78
|
+
data-invalid={invalid ? '' : undefined}
|
|
79
|
+
data-disabled={disabled ? '' : undefined}
|
|
80
|
+
aria-invalid={invalid || undefined}
|
|
81
|
+
className={cn(
|
|
82
|
+
'flex h-10 w-full items-center gap-2 rounded-[var(--radius)] border bg-input px-3 shadow-sm',
|
|
83
|
+
'transition-[color,background-color,border-color,box-shadow]',
|
|
84
|
+
'border-border',
|
|
85
|
+
// Crisp focus edge on the whole shell (border + 1px ring = ~2px clean
|
|
86
|
+
// outline). `:focus-within` so it lights whether the user tabs into
|
|
87
|
+
// the input or the popover button.
|
|
88
|
+
'focus-within:border-ring focus-within:ring-1 focus-within:ring-ring',
|
|
89
|
+
invalid &&
|
|
90
|
+
'border-destructive focus-within:border-destructive focus-within:ring-destructive',
|
|
91
|
+
disabled && 'cursor-not-allowed opacity-50',
|
|
92
|
+
className,
|
|
93
|
+
)}
|
|
94
|
+
>
|
|
95
|
+
{icon ? (
|
|
96
|
+
<span
|
|
97
|
+
className={cn(
|
|
98
|
+
'flex shrink-0 text-muted-foreground [&_svg]:size-4',
|
|
99
|
+
// Decorative by default (clicks fall through to the input). When
|
|
100
|
+
// the icon is a real popover trigger, it must receive clicks.
|
|
101
|
+
!iconOpensPopover && 'pointer-events-none',
|
|
102
|
+
)}
|
|
103
|
+
>
|
|
104
|
+
{icon}
|
|
105
|
+
</span>
|
|
106
|
+
) : null}
|
|
107
|
+
{children}
|
|
108
|
+
{popoverTrigger}
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
NativeFieldShell.displayName = 'NativeFieldShell';
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared popover triggers for the date/time fields, so the two large click
|
|
5
|
+
* targets (leading icon + trailing chevron) look and behave identically across
|
|
6
|
+
* all three fields. Both are `PopoverTrigger asChild` children, so the shell's
|
|
7
|
+
* `focus-within` ring covers them.
|
|
8
|
+
*
|
|
9
|
+
* Why two targets: the CENTRE of the field is the native `<input>` (for typing),
|
|
10
|
+
* so the popover can't open from there without stealing keyboard entry. The
|
|
11
|
+
* icon and the chevron are the two edges that DON'T type, so both open the
|
|
12
|
+
* picker — the icon in particular is a big, obvious left-edge target, far easier
|
|
13
|
+
* to hit than a lone chevron.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { ChevronDown } from 'lucide-react';
|
|
17
|
+
import * as React from 'react';
|
|
18
|
+
|
|
19
|
+
import { cn } from '../../../lib/utils';
|
|
20
|
+
|
|
21
|
+
export const PopoverChevron = React.forwardRef<
|
|
22
|
+
HTMLButtonElement,
|
|
23
|
+
React.ButtonHTMLAttributes<HTMLButtonElement> & { label?: string }
|
|
24
|
+
>(({ className, label = 'Open picker', ...props }, ref) => (
|
|
25
|
+
<button
|
|
26
|
+
ref={ref}
|
|
27
|
+
type="button"
|
|
28
|
+
aria-label={label}
|
|
29
|
+
className={cn(
|
|
30
|
+
'flex size-6 shrink-0 items-center justify-center rounded-[var(--radius-sm)] text-muted-foreground',
|
|
31
|
+
'transition-colors hover:bg-accent hover:text-foreground',
|
|
32
|
+
'focus-visible:outline-none disabled:pointer-events-none',
|
|
33
|
+
className,
|
|
34
|
+
)}
|
|
35
|
+
{...props}
|
|
36
|
+
>
|
|
37
|
+
<ChevronDown className="size-4" aria-hidden />
|
|
38
|
+
</button>
|
|
39
|
+
));
|
|
40
|
+
PopoverChevron.displayName = 'PopoverChevron';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* IconTrigger — the leading icon rendered as a real, comfortably-sized popover
|
|
44
|
+
* button. It hugs the left edge (`-ml-1` reclaims the shell's px-3) and carries
|
|
45
|
+
* a generous padded hit area so the target is far bigger than the glyph itself.
|
|
46
|
+
*/
|
|
47
|
+
export const IconTrigger = React.forwardRef<
|
|
48
|
+
HTMLButtonElement,
|
|
49
|
+
React.ButtonHTMLAttributes<HTMLButtonElement> & { label?: string }
|
|
50
|
+
>(({ className, label = 'Open picker', children, ...props }, ref) => (
|
|
51
|
+
<button
|
|
52
|
+
ref={ref}
|
|
53
|
+
type="button"
|
|
54
|
+
aria-label={label}
|
|
55
|
+
className={cn(
|
|
56
|
+
'-ml-1 flex h-8 shrink-0 items-center rounded-[var(--radius-sm)] px-1.5 text-muted-foreground',
|
|
57
|
+
'transition-colors hover:bg-accent hover:text-foreground',
|
|
58
|
+
'focus-visible:outline-none disabled:pointer-events-none [&_svg]:size-4',
|
|
59
|
+
className,
|
|
60
|
+
)}
|
|
61
|
+
{...props}
|
|
62
|
+
>
|
|
63
|
+
{children}
|
|
64
|
+
</button>
|
|
65
|
+
));
|
|
66
|
+
IconTrigger.displayName = 'IconTrigger';
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TimeField — native `<input type="time">` in our token skin. Type `14:30`
|
|
5
|
+
* straight in (segmented keyboard entry, arrow increment); on touch the OS time
|
|
6
|
+
* wheel opens on tap. AM/PM vs 24-hour is decided by the browser from the user's
|
|
7
|
+
* locale — we don't render our own picker, so there's nothing to desync.
|
|
8
|
+
*
|
|
9
|
+
* Wire value is `HH:mm` (24h) — or `HH:mm:ss` when `step` yields seconds —
|
|
10
|
+
* matching the native input and the old `TimePicker` contract, so migration from
|
|
11
|
+
* the click-only `TimePicker` (kept as a back-compat alias) is mechanical.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Clock } from 'lucide-react';
|
|
15
|
+
import * as React from 'react';
|
|
16
|
+
|
|
17
|
+
import { NativeFieldShell, NATIVE_FIELD_INPUT_CLASS } from './native-field-shell';
|
|
18
|
+
|
|
19
|
+
export interface TimeFieldProps {
|
|
20
|
+
/** Value as `HH:mm` (24h). Controlled. */
|
|
21
|
+
value?: string;
|
|
22
|
+
/** Uncontrolled initial value. */
|
|
23
|
+
defaultValue?: string;
|
|
24
|
+
onChange?: (value: string) => void;
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
/** Invalid state — usually driven by the `Field` / `Form` layer. */
|
|
27
|
+
invalid?: boolean;
|
|
28
|
+
/** Native min/max (`HH:mm`). */
|
|
29
|
+
min?: string;
|
|
30
|
+
max?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Native `step` in seconds. `60` (default) → HH:mm; smaller values expose a
|
|
33
|
+
* seconds segment. Controls the arrow-key increment too.
|
|
34
|
+
*/
|
|
35
|
+
step?: number;
|
|
36
|
+
/** Form field name — the native input IS the form control. */
|
|
37
|
+
name?: string;
|
|
38
|
+
/**
|
|
39
|
+
* DOM id for the native control, so an external `<label htmlFor>` binds to
|
|
40
|
+
* the field a user actually focuses.
|
|
41
|
+
*/
|
|
42
|
+
id?: string;
|
|
43
|
+
className?: string;
|
|
44
|
+
'aria-label'?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const TimeField = React.forwardRef<HTMLInputElement, TimeFieldProps>(
|
|
48
|
+
(
|
|
49
|
+
{
|
|
50
|
+
value: controlledValue,
|
|
51
|
+
defaultValue,
|
|
52
|
+
onChange,
|
|
53
|
+
disabled = false,
|
|
54
|
+
invalid = false,
|
|
55
|
+
min,
|
|
56
|
+
max,
|
|
57
|
+
step,
|
|
58
|
+
name,
|
|
59
|
+
id,
|
|
60
|
+
className,
|
|
61
|
+
'aria-label': ariaLabel,
|
|
62
|
+
},
|
|
63
|
+
ref,
|
|
64
|
+
) => {
|
|
65
|
+
const isControlled = controlledValue !== undefined;
|
|
66
|
+
const [internal, setInternal] = React.useState(defaultValue ?? '');
|
|
67
|
+
const value = isControlled ? controlledValue : internal;
|
|
68
|
+
|
|
69
|
+
const commit = React.useCallback(
|
|
70
|
+
(next: string) => {
|
|
71
|
+
if (!isControlled) setInternal(next);
|
|
72
|
+
onChange?.(next);
|
|
73
|
+
},
|
|
74
|
+
[isControlled, onChange],
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<NativeFieldShell
|
|
79
|
+
icon={<Clock aria-hidden />}
|
|
80
|
+
invalid={invalid}
|
|
81
|
+
disabled={disabled}
|
|
82
|
+
className={className}
|
|
83
|
+
>
|
|
84
|
+
<input
|
|
85
|
+
ref={ref}
|
|
86
|
+
type="time"
|
|
87
|
+
className={NATIVE_FIELD_INPUT_CLASS}
|
|
88
|
+
value={value}
|
|
89
|
+
onChange={(e) => commit(e.target.value)}
|
|
90
|
+
disabled={disabled}
|
|
91
|
+
min={min}
|
|
92
|
+
max={max}
|
|
93
|
+
step={step}
|
|
94
|
+
name={name}
|
|
95
|
+
id={id}
|
|
96
|
+
aria-label={ariaLabel}
|
|
97
|
+
aria-invalid={invalid || undefined}
|
|
98
|
+
/>
|
|
99
|
+
</NativeFieldShell>
|
|
100
|
+
);
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
TimeField.displayName = 'TimeField';
|
|
@@ -7,8 +7,16 @@ import * as LabelPrimitive from '@radix-ui/react-label';
|
|
|
7
7
|
|
|
8
8
|
import { cn } from '../../../lib/utils';
|
|
9
9
|
|
|
10
|
+
// `select-none` is not cosmetic here.
|
|
11
|
+
//
|
|
12
|
+
// A label is text whose whole purpose is to be CLICKED, and a click that lands
|
|
13
|
+
// slightly off — or a click that drags a pixel — leaves the word highlighted in
|
|
14
|
+
// the selection colour. In a filter rail that reads as a selected FILTER rather
|
|
15
|
+
// than selected TEXT, so the control reports a state the user never set. The
|
|
16
|
+
// same shape appears in every checkbox list, radio group and switch row, which
|
|
17
|
+
// is why it belongs to the primitive and not to any one screen.
|
|
10
18
|
const labelVariants = cva(
|
|
11
|
-
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
|
19
|
+
"select-none text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
|
12
20
|
)
|
|
13
21
|
|
|
14
22
|
const Label = React.forwardRef<
|
|
@@ -29,7 +29,9 @@ const RadioGroupItem = React.forwardRef<
|
|
|
29
29
|
<RadioGroupPrimitive.Item
|
|
30
30
|
ref={ref}
|
|
31
31
|
className={cn(
|
|
32
|
-
|
|
32
|
+
// `select-none` for the same reason as Checkbox and Switch: the dot is
|
|
33
|
+
// a click target, and a click that drags must not select text.
|
|
34
|
+
"aspect-square h-4 w-4 shrink-0 select-none rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
|
33
35
|
className
|
|
34
36
|
)}
|
|
35
37
|
{...props}
|
|
@@ -13,7 +13,9 @@ const Switch = React.forwardRef<
|
|
|
13
13
|
<SwitchPrimitives.Root
|
|
14
14
|
className={cn(
|
|
15
15
|
// Track: generous Cloudflare-style pill (44×24) with an inset thumb.
|
|
16
|
-
|
|
16
|
+
// `select-none`: a drag starting on the pill must toggle or do nothing —
|
|
17
|
+
// never paint a text selection across the row the switch sits in.
|
|
18
|
+
"peer group relative inline-flex h-6 w-11 shrink-0 cursor-pointer select-none items-center rounded-full",
|
|
17
19
|
"p-0.5 outline-none transition-colors duration-200 ease-out",
|
|
18
20
|
// On/off surfaces — all on-token so every preset & theme follows along.
|
|
19
21
|
"data-[state=unchecked]:bg-input data-[state=checked]:bg-primary",
|
package/src/components/index.ts
CHANGED
|
@@ -42,10 +42,30 @@ export type { SegmentedInputProps } from './forms/segmented-input';
|
|
|
42
42
|
export { TagsInput, TagsInputInput, TagsInputItem, TagsInputItemText, TagsInputItemDelete } from './forms/tags-input';
|
|
43
43
|
export type { TagsInputRootProps, TagsInputInputProps, TagsInputItemProps, TagsInputItemTextProps, TagsInputItemDeleteProps } from './forms/tags-input';
|
|
44
44
|
|
|
45
|
-
// Time Picker
|
|
45
|
+
// Time Picker (click-only legacy; prefer the native-engine TimeField below).
|
|
46
46
|
export { TimePicker } from './forms/time-picker';
|
|
47
47
|
export type { TimePickerProps } from './forms/time-picker';
|
|
48
48
|
|
|
49
|
+
// Native-engine date/time fields — typeable, token-skinned, optional popover.
|
|
50
|
+
export {
|
|
51
|
+
DateField,
|
|
52
|
+
TimeField,
|
|
53
|
+
DateTimeField,
|
|
54
|
+
NativeFieldShell,
|
|
55
|
+
NATIVE_FIELD_INPUT_CLASS,
|
|
56
|
+
parseDateString,
|
|
57
|
+
formatDateString,
|
|
58
|
+
parseDateTimeString,
|
|
59
|
+
splitDateTime,
|
|
60
|
+
joinDateTime,
|
|
61
|
+
} from './forms/datetime-field';
|
|
62
|
+
export type {
|
|
63
|
+
DateFieldProps,
|
|
64
|
+
TimeFieldProps,
|
|
65
|
+
DateTimeFieldProps,
|
|
66
|
+
NativeFieldShellProps,
|
|
67
|
+
} from './forms/datetime-field';
|
|
68
|
+
|
|
49
69
|
// Editable
|
|
50
70
|
export { Editable, EditablePreview, EditableInput, EditableTextarea } from './forms/editable';
|
|
51
71
|
export type { EditableRootProps, EditablePreviewProps, EditableInputProps, EditableTextareaProps } from './forms/editable';
|
|
@@ -34,6 +34,13 @@ const PopoverContent = React.forwardRef<
|
|
|
34
34
|
<PopoverPrimitive.Content
|
|
35
35
|
forceMount={forceMount}
|
|
36
36
|
ref={ref}
|
|
37
|
+
// `data-slot` lets descendants detect they're inside a popover surface.
|
|
38
|
+
// The Calendar keys its `bg-transparent` override off
|
|
39
|
+
// `[[data-slot=popover-content]_&]` so a calendar rendered in a popover
|
|
40
|
+
// drops its own `bg-background` panel and inherits the popover surface —
|
|
41
|
+
// without this attribute that rule never matches and the calendar shows a
|
|
42
|
+
// mismatched darker rectangle inside the popover.
|
|
43
|
+
data-slot="popover-content"
|
|
37
44
|
aria-describedby={undefined}
|
|
38
45
|
align={align}
|
|
39
46
|
sideOffset={sideOffset}
|
|
@@ -21,9 +21,20 @@
|
|
|
21
21
|
--radius: 0.25rem;
|
|
22
22
|
--border: hsl(0 0% 24%);
|
|
23
23
|
--input: hsl(0 0% 24%);
|
|
24
|
-
|
|
24
|
+
/* 0.10, not 0.18. A divider separates rows INSIDE a panel, so it must be
|
|
25
|
+
weaker than that panel's own --border — otherwise the five lines between
|
|
26
|
+
rows shout as loudly as the one frame around them, which is what was
|
|
27
|
+
left of "кондово" after the fills were calmed. Over a 12% card this
|
|
28
|
+
lands at 15.4% (step 3.4) against the border's 18% (step 6.0): a clear
|
|
29
|
+
hierarchy, frame first. */
|
|
30
|
+
--divider: hsl(0 0% 46% / 0.10);
|
|
25
31
|
--muted: hsl(0 0% 12%);
|
|
26
|
-
--
|
|
32
|
+
/* 12%, against an INHERITED --background of 14.5% from dark.css — this
|
|
33
|
+
preset declares no background of its own, so the card sits DARKER than the
|
|
34
|
+
page here rather than lighter. Direction is not the contract; the size of
|
|
35
|
+
the step is. Capped by MAXIMUM_RESTING_SURFACE_GAP in
|
|
36
|
+
check-preset-contrast.mjs. */
|
|
37
|
+
--card: hsl(0 0% 12%);
|
|
27
38
|
/* 20%, not 14%: --background is 14.5%, so this was HALF a point — the
|
|
28
39
|
hover was effectively invisible. */
|
|
29
40
|
--accent: hsl(0 0% 20%);
|
|
@@ -62,7 +62,11 @@
|
|
|
62
62
|
.dark {
|
|
63
63
|
--background: hsl(240 6% 10%);
|
|
64
64
|
--foreground: hsl(0 0% 96%);
|
|
65
|
-
|
|
65
|
+
/* Three points above --background, not seven. A resting surface is
|
|
66
|
+
separated by its BORDER; when the fill does that job the page reads as
|
|
67
|
+
stacked plates and a table looks like a sticker laid over it. Capped by
|
|
68
|
+
MAXIMUM_RESTING_SURFACE_GAP in check-preset-contrast.mjs. */
|
|
69
|
+
--card: hsl(240 6% 14%);
|
|
66
70
|
--card-foreground: hsl(0 0% 96%);
|
|
67
71
|
--popover: hsl(240 6% 20%);
|
|
68
72
|
--popover-foreground: hsl(0 0% 96%);
|
|
@@ -76,10 +80,20 @@
|
|
|
76
80
|
--accent-foreground: hsl(0 0% 96%);
|
|
77
81
|
--destructive: hsl(0 100% 67%);
|
|
78
82
|
--destructive-foreground: hsl(0 0% 100%);
|
|
79
|
-
|
|
83
|
+
/* A hairline divides, it does not glow. Ten points over --background is a
|
|
84
|
+
definite edge at one device pixel without becoming the loudest thing on
|
|
85
|
+
the page — which it was once the fills stopped separating surfaces.
|
|
86
|
+
Capped by MAXIMUM_BORDER_GAP in check-preset-contrast.mjs. */
|
|
87
|
+
--border: hsl(240 5% 20%);
|
|
80
88
|
--input: hsl(240 5% 22%);
|
|
81
89
|
--ring: hsl(211 100% 55%);
|
|
82
|
-
|
|
90
|
+
/* 0.10, not 0.18. A divider separates rows INSIDE a panel, so it must be
|
|
91
|
+
weaker than that panel's own --border — otherwise the five lines between
|
|
92
|
+
rows shout as loudly as the one frame around them, which is what was
|
|
93
|
+
left of "кондово" after the fills were calmed. Over a 12% card this
|
|
94
|
+
lands at 15.4% (step 3.4) against the border's 18% (step 6.0): a clear
|
|
95
|
+
hierarchy, frame first. */
|
|
96
|
+
--divider: hsl(240 5% 48% / 0.10);
|
|
83
97
|
--radius: 0.75rem;
|
|
84
98
|
--overlay: hsl(0 0% 0% / 0.55);
|
|
85
99
|
--sidebar-background: hsl(240 6% 8%);
|
|
@@ -83,7 +83,11 @@
|
|
|
83
83
|
.dark {
|
|
84
84
|
--background: hsl(240 5% 8%);
|
|
85
85
|
--foreground: hsl(0 0% 95%);
|
|
86
|
-
|
|
86
|
+
/* Three points above --background, not seven. A resting surface is
|
|
87
|
+
separated by its BORDER; when the fill does that job the page reads as
|
|
88
|
+
stacked plates and a table looks like a sticker laid over it. Capped by
|
|
89
|
+
MAXIMUM_RESTING_SURFACE_GAP in check-preset-contrast.mjs. */
|
|
90
|
+
--card: hsl(240 3% 12%);
|
|
87
91
|
--card-foreground: hsl(0 0% 95%);
|
|
88
92
|
--popover: hsl(240 3% 20%);
|
|
89
93
|
--popover-foreground: hsl(0 0% 95%);
|
|
@@ -99,9 +103,19 @@
|
|
|
99
103
|
--accent-foreground: hsl(211 100% 72%);
|
|
100
104
|
--destructive: hsl(3 100% 62%);
|
|
101
105
|
--destructive-foreground: hsl(0 0% 9%);
|
|
102
|
-
|
|
106
|
+
/* A hairline divides, it does not glow. Ten points over --background is a
|
|
107
|
+
definite edge at one device pixel without becoming the loudest thing on
|
|
108
|
+
the page — which it was once the fills stopped separating surfaces.
|
|
109
|
+
Capped by MAXIMUM_BORDER_GAP in check-preset-contrast.mjs. */
|
|
110
|
+
--border: hsl(240 3% 18%);
|
|
103
111
|
--input: hsl(240 3% 22%);
|
|
104
|
-
|
|
112
|
+
/* 0.10, not 0.18. A divider separates rows INSIDE a panel, so it must be
|
|
113
|
+
weaker than that panel's own --border — otherwise the five lines between
|
|
114
|
+
rows shout as loudly as the one frame around them, which is what was
|
|
115
|
+
left of "кондово" after the fills were calmed. Over a 12% card this
|
|
116
|
+
lands at 15.4% (step 3.4) against the border's 18% (step 6.0): a clear
|
|
117
|
+
hierarchy, frame first. */
|
|
118
|
+
--divider: hsl(240 4% 46% / 0.10);
|
|
105
119
|
--ring: hsl(211 100% 58%);
|
|
106
120
|
--radius: 0.625rem;
|
|
107
121
|
--overlay: hsl(0 0% 0% / 0.55);
|
|
@@ -34,9 +34,19 @@
|
|
|
34
34
|
--muted-foreground: hsl(240 5% 64%);
|
|
35
35
|
--accent: hsl(240 5% 16%);
|
|
36
36
|
--accent-foreground: hsl(0 0% 96%);
|
|
37
|
-
|
|
37
|
+
/* A hairline divides, it does not glow. Ten points over --background is a
|
|
38
|
+
definite edge at one device pixel without becoming the loudest thing on
|
|
39
|
+
the page — which it was once the fills stopped separating surfaces.
|
|
40
|
+
Capped by MAXIMUM_BORDER_GAP in check-preset-contrast.mjs. */
|
|
41
|
+
--border: hsl(240 5% 18%);
|
|
38
42
|
--input: hsl(240 5% 20%);
|
|
39
|
-
|
|
43
|
+
/* 0.10, not 0.18. A divider separates rows INSIDE a panel, so it must be
|
|
44
|
+
weaker than that panel's own --border — otherwise the five lines between
|
|
45
|
+
rows shout as loudly as the one frame around them, which is what was
|
|
46
|
+
left of "кондово" after the fills were calmed. Over a 12% card this
|
|
47
|
+
lands at 15.4% (step 3.4) against the border's 18% (step 6.0): a clear
|
|
48
|
+
hierarchy, frame first. */
|
|
49
|
+
--divider: hsl(240 5% 48% / 0.10);
|
|
40
50
|
--radius: 1rem;
|
|
41
51
|
--sidebar-background: hsl(240 6% 6%);
|
|
42
52
|
--sidebar-accent: hsl(240 5% 14%);
|
|
@@ -92,9 +92,19 @@
|
|
|
92
92
|
--accent-foreground: hsl(200 100% 75%);
|
|
93
93
|
--destructive: hsl(0 90% 62%);
|
|
94
94
|
--destructive-foreground: hsl(0 0% 100%);
|
|
95
|
-
|
|
95
|
+
/* A hairline divides, it does not glow. Ten points over --background is a
|
|
96
|
+
definite edge at one device pixel without becoming the loudest thing on
|
|
97
|
+
the page — which it was once the fills stopped separating surfaces.
|
|
98
|
+
Capped by MAXIMUM_BORDER_GAP in check-preset-contrast.mjs. */
|
|
99
|
+
--border: hsl(0 0% 23%);
|
|
96
100
|
--input: hsl(0 0% 24%);
|
|
97
|
-
|
|
101
|
+
/* 0.10, not 0.18. A divider separates rows INSIDE a panel, so it must be
|
|
102
|
+
weaker than that panel's own --border — otherwise the five lines between
|
|
103
|
+
rows shout as loudly as the one frame around them, which is what was
|
|
104
|
+
left of "кондово" after the fills were calmed. Over a 12% card this
|
|
105
|
+
lands at 15.4% (step 3.4) against the border's 18% (step 6.0): a clear
|
|
106
|
+
hierarchy, frame first. */
|
|
107
|
+
--divider: hsl(0 0% 46% / 0.10);
|
|
98
108
|
--ring: hsl(200 100% 69%);
|
|
99
109
|
--overlay: hsl(0 0% 0% / 0.55);
|
|
100
110
|
--radius: 0.375rem;
|
|
@@ -44,14 +44,24 @@
|
|
|
44
44
|
--destructive-foreground: hsl(0 0% 98%);
|
|
45
45
|
/* Soft warm border — Claude draws hairlines from a light border at low
|
|
46
46
|
* contrast, so a mid warm-gray keeps lines visible but never harsh. */
|
|
47
|
-
|
|
47
|
+
/* A hairline divides, it does not glow. Ten points over --background is a
|
|
48
|
+
definite edge at one device pixel without becoming the loudest thing on
|
|
49
|
+
the page — which it was once the fills stopped separating surfaces.
|
|
50
|
+
Capped by MAXIMUM_BORDER_GAP in check-preset-contrast.mjs. */
|
|
51
|
+
--border: hsl(48 3% 24%);
|
|
48
52
|
/* Input surface — a notch above card so fields read as raised controls. */
|
|
49
53
|
--input: hsl(48 2.5% 24%);
|
|
50
54
|
/* Divider — TRANSLUCENT hairline (Apple-style): a light warm-gray at low alpha
|
|
51
55
|
* that dissolves into whatever sits behind it, so structural separators
|
|
52
56
|
* (columns, headers, rows) stay quiet on the dark page instead of reading as a
|
|
53
57
|
* hard opaque rule. Backs default + django-cfg (they don't re-declare it). */
|
|
54
|
-
|
|
58
|
+
/* 0.10, not 0.18. A divider separates rows INSIDE a panel, so it must be
|
|
59
|
+
weaker than that panel's own --border — otherwise the five lines between
|
|
60
|
+
rows shout as loudly as the one frame around them, which is what was
|
|
61
|
+
left of "кондово" after the fills were calmed. Over a 12% card this
|
|
62
|
+
lands at 15.4% (step 3.4) against the border's 18% (step 6.0): a clear
|
|
63
|
+
hierarchy, frame first. */
|
|
64
|
+
--divider: hsl(48 4% 46% / 0.10);
|
|
55
65
|
/* Overlay — modal scrim / backdrop behind dialogs, drawers, sheets. Black in
|
|
56
66
|
* both themes; slightly darker here so it still reads on the dark page. */
|
|
57
67
|
--overlay: hsl(0 0% 0% / 0.7);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native date/time field — chrome suppression.
|
|
3
|
+
*
|
|
4
|
+
* The native-engine fields (`DateField` / `TimeField` / `DateTimeField`) use a
|
|
5
|
+
* real `<input type="date|time|datetime-local">` as the input engine and wrap it
|
|
6
|
+
* in `NativeFieldShell`, which supplies OUR icon and OUR popover. So the
|
|
7
|
+
* browser's built-in indicator glyph and inner spin buttons must be hidden —
|
|
8
|
+
* otherwise there are two icons and a double-picker.
|
|
9
|
+
*
|
|
10
|
+
* The `.native-datetime-input` class is applied by the field to the raw input.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
.native-datetime-input::-webkit-calendar-picker-indicator {
|
|
14
|
+
/* Kill the browser clock/calendar button entirely. We suppress it rather than
|
|
15
|
+
* repurpose it because clicking it opens the OS picker, which would race our
|
|
16
|
+
* own popover. Our leading icon / chevron drives our popover instead. */
|
|
17
|
+
display: none;
|
|
18
|
+
-webkit-appearance: none;
|
|
19
|
+
appearance: none;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.native-datetime-input::-webkit-inner-spin-button,
|
|
23
|
+
.native-datetime-input::-webkit-outer-spin-button {
|
|
24
|
+
display: none;
|
|
25
|
+
-webkit-appearance: none;
|
|
26
|
+
appearance: none;
|
|
27
|
+
margin: 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* Firefox draws a clear ("×") button on filled date inputs — hide it so the
|
|
31
|
+
* clearing affordance stays inside our own UI, consistent across browsers. */
|
|
32
|
+
.native-datetime-input::-moz-clear {
|
|
33
|
+
display: none;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* Segment text follows the active theme. (No blanket `padding:0` here — the
|
|
37
|
+
* in-shell inputs already sit flush via their own flex layout, and the standalone
|
|
38
|
+
* time input in the DateTimeField popover NEEDS its px-* padding; a global reset
|
|
39
|
+
* would fight it.) */
|
|
40
|
+
.native-datetime-input {
|
|
41
|
+
color-scheme: light dark;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* Horizontal centering of a native time/date input's VALUE.
|
|
45
|
+
*
|
|
46
|
+
* `text-align: center` on the input alone does NOT center a `type="time"` value:
|
|
47
|
+
* the value is a group of segments (HH, MM, …) the browser lays out inside
|
|
48
|
+
* `::-webkit-datetime-edit`, which is left-aligned and only as wide as its
|
|
49
|
+
* content. The reliable cross-engine fix (verified against MDN + WebKit
|
|
50
|
+
* internals) is to make the INPUT ITSELF a centering flex container — that
|
|
51
|
+
* centers `::-webkit-datetime-edit` as a flex child regardless of text-align —
|
|
52
|
+
* and keep `text-align:center` for Firefox (which ignores `::-webkit-*` and
|
|
53
|
+
* renders the value as plain inline text). Opt in with
|
|
54
|
+
* `.native-datetime-input--center`. */
|
|
55
|
+
.native-datetime-input--center {
|
|
56
|
+
display: inline-flex;
|
|
57
|
+
justify-content: center;
|
|
58
|
+
text-align: center; /* Firefox path */
|
|
59
|
+
}
|
|
60
|
+
.native-datetime-input--center::-webkit-datetime-edit {
|
|
61
|
+
text-align: center; /* Chrome/Safari: centers the segment group */
|
|
62
|
+
}
|
|
63
|
+
.native-datetime-input--center::-webkit-datetime-edit-fields-wrapper {
|
|
64
|
+
justify-content: center; /* belt-and-braces for older Safari */
|
|
65
|
+
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
@import './utilities/divider.css';
|
|
11
11
|
@import './utilities/overlay.css';
|
|
12
12
|
@import './utilities/controls.css';
|
|
13
|
+
@import './utilities/datetime-field.css';
|
|
13
14
|
@import './utilities/step.css';
|
|
14
15
|
@import './utilities/animations.css';
|
|
15
16
|
@import './utilities/glass.css';
|