@a4ui/core 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/README.md +36 -26
  2. package/dist/full.css +3042 -0
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +1 -1
  5. package/package.json +8 -1
  6. package/src/charts/BarChart.tsx +55 -0
  7. package/src/charts/DonutChart.tsx +127 -0
  8. package/src/charts/Sparkline.tsx +101 -0
  9. package/src/charts/index.ts +5 -0
  10. package/src/commerce/CartLine.tsx +84 -0
  11. package/src/commerce/CartSummary.tsx +70 -0
  12. package/src/commerce/FilterGroup.tsx +70 -0
  13. package/src/commerce/PriceTag.tsx +62 -0
  14. package/src/commerce/ProductCard.tsx +121 -0
  15. package/src/commerce/ProductGrid.tsx +27 -0
  16. package/src/commerce/QuantityStepper.tsx +59 -0
  17. package/src/commerce/index.ts +10 -0
  18. package/src/elements.tsx +167 -0
  19. package/src/index.ts +166 -0
  20. package/src/layout/AppShell.tsx +102 -0
  21. package/src/layout/ChristmasBackground.tsx +177 -0
  22. package/src/layout/EffectsToggle.tsx +41 -0
  23. package/src/layout/NavGroup.tsx +45 -0
  24. package/src/layout/SnowScenery.tsx +76 -0
  25. package/src/layout/SpaceBackground.tsx +459 -0
  26. package/src/layout/ThemeToggle.tsx +40 -0
  27. package/src/layout/ThemedScenery.tsx +179 -0
  28. package/src/layout/sceneEffects.ts +49 -0
  29. package/src/lib/cn.ts +17 -0
  30. package/src/lib/effects.ts +71 -0
  31. package/src/lib/media.ts +27 -0
  32. package/src/lib/motion.ts +191 -0
  33. package/src/lib/theme.ts +113 -0
  34. package/src/lib/virtual.ts +33 -0
  35. package/src/styles/space.css +510 -0
  36. package/src/styles/tokens.css +213 -0
  37. package/src/themes/index.ts +102 -0
  38. package/src/themes/palettes.ts +368 -0
  39. package/src/ui/Accordion.tsx +65 -0
  40. package/src/ui/Affix.tsx +72 -0
  41. package/src/ui/Alert.tsx +55 -0
  42. package/src/ui/AlertDialog.tsx +58 -0
  43. package/src/ui/Anchor.tsx +100 -0
  44. package/src/ui/Avatar.tsx +34 -0
  45. package/src/ui/AvatarGroup.tsx +55 -0
  46. package/src/ui/BackToTop.tsx +51 -0
  47. package/src/ui/Badge.tsx +46 -0
  48. package/src/ui/BottomNavigation.tsx +65 -0
  49. package/src/ui/Breadcrumb.tsx +64 -0
  50. package/src/ui/Button.tsx +49 -0
  51. package/src/ui/Calendar.tsx +40 -0
  52. package/src/ui/CalendarHeatmap.tsx +180 -0
  53. package/src/ui/Card.tsx +84 -0
  54. package/src/ui/Carousel.tsx +121 -0
  55. package/src/ui/Cascader.tsx +158 -0
  56. package/src/ui/Checkbox.tsx +35 -0
  57. package/src/ui/Clock.tsx +221 -0
  58. package/src/ui/Collapse.tsx +48 -0
  59. package/src/ui/ColorPicker.tsx +89 -0
  60. package/src/ui/Combobox.tsx +67 -0
  61. package/src/ui/Command.tsx +158 -0
  62. package/src/ui/Comment.tsx +91 -0
  63. package/src/ui/ContextMenu.tsx +58 -0
  64. package/src/ui/Countdown.tsx +123 -0
  65. package/src/ui/DataGrid.tsx +177 -0
  66. package/src/ui/DateField.tsx +177 -0
  67. package/src/ui/DateRangePicker.tsx +204 -0
  68. package/src/ui/DateTimeField.tsx +68 -0
  69. package/src/ui/Descriptions.tsx +56 -0
  70. package/src/ui/Drawer.tsx +72 -0
  71. package/src/ui/Dropdown.tsx +71 -0
  72. package/src/ui/Dropzone.tsx +88 -0
  73. package/src/ui/Empty.tsx +53 -0
  74. package/src/ui/FileUpload.tsx +198 -0
  75. package/src/ui/FloatingActionButton.tsx +48 -0
  76. package/src/ui/Form.tsx +108 -0
  77. package/src/ui/Highlight.tsx +53 -0
  78. package/src/ui/HoverCard.tsx +42 -0
  79. package/src/ui/Image.tsx +66 -0
  80. package/src/ui/Input.tsx +38 -0
  81. package/src/ui/Kbd.tsx +25 -0
  82. package/src/ui/List.tsx +71 -0
  83. package/src/ui/Marquee.tsx +48 -0
  84. package/src/ui/Mentions.tsx +170 -0
  85. package/src/ui/Meter.tsx +57 -0
  86. package/src/ui/Modal.tsx +106 -0
  87. package/src/ui/MultiSelect.tsx +236 -0
  88. package/src/ui/NotificationCenter.tsx +103 -0
  89. package/src/ui/NumberInput.tsx +48 -0
  90. package/src/ui/PageHeader.tsx +60 -0
  91. package/src/ui/Pagination.tsx +62 -0
  92. package/src/ui/Popover.tsx +42 -0
  93. package/src/ui/Portal.tsx +34 -0
  94. package/src/ui/Progress.tsx +55 -0
  95. package/src/ui/RadioGroup.tsx +65 -0
  96. package/src/ui/Rating.tsx +98 -0
  97. package/src/ui/Result.tsx +75 -0
  98. package/src/ui/RingProgress.tsx +75 -0
  99. package/src/ui/SegmentedControl.tsx +61 -0
  100. package/src/ui/Select.tsx +55 -0
  101. package/src/ui/Separator.tsx +39 -0
  102. package/src/ui/Skeleton.tsx +23 -0
  103. package/src/ui/Slider.tsx +57 -0
  104. package/src/ui/Sortable.tsx +174 -0
  105. package/src/ui/SpeedDial.tsx +75 -0
  106. package/src/ui/Spinner.tsx +33 -0
  107. package/src/ui/Splitter.tsx +115 -0
  108. package/src/ui/Stat.tsx +74 -0
  109. package/src/ui/Stepper.tsx +134 -0
  110. package/src/ui/Switch.tsx +44 -0
  111. package/src/ui/Table.tsx +128 -0
  112. package/src/ui/Tabs.tsx +72 -0
  113. package/src/ui/TagInput.tsx +85 -0
  114. package/src/ui/Textarea.tsx +38 -0
  115. package/src/ui/TimeField.tsx +298 -0
  116. package/src/ui/Timeline.tsx +89 -0
  117. package/src/ui/Toast.tsx +68 -0
  118. package/src/ui/Toggle.tsx +41 -0
  119. package/src/ui/ToggleGroup.tsx +59 -0
  120. package/src/ui/Tooltip.tsx +42 -0
  121. package/src/ui/Tour.tsx +197 -0
  122. package/src/ui/Transfer.tsx +112 -0
  123. package/src/ui/Tree.tsx +100 -0
  124. package/src/ui/TreeSelect.tsx +151 -0
  125. package/src/ui/VirtualList.tsx +81 -0
  126. package/src/ui/internal/CalendarCore.tsx +276 -0
@@ -0,0 +1,58 @@
1
+ // Right-click menu on Kobalte's ContextMenu primitive. `children` is the target.
2
+ import { ContextMenu as KContextMenu } from '@kobalte/core/context-menu'
3
+ import type { JSX, ParentProps } from 'solid-js'
4
+ import { For } from 'solid-js'
5
+
6
+ import { cn } from '../lib/cn'
7
+
8
+ /** A single actionable row rendered by {@link ContextMenu}. */
9
+ export interface ContextMenuItem {
10
+ label: string
11
+ /** Called when the item is chosen (click or keyboard activation). */
12
+ onSelect: () => void
13
+ /** Styles the label as a destructive action (e.g. "Delete"). */
14
+ destructive?: boolean
15
+ disabled?: boolean
16
+ }
17
+
18
+ interface ContextMenuProps extends ParentProps {
19
+ items: ContextMenuItem[]
20
+ class?: string
21
+ }
22
+
23
+ /**
24
+ * Right-click (or long-press) menu built on Kobalte's `ContextMenu`
25
+ * primitive. `children` is the element that acts as the trigger/target area.
26
+ *
27
+ * @example
28
+ * ```tsx
29
+ * <ContextMenu items={[{ label: 'Rename', onSelect: rename }, { label: 'Delete', onSelect: remove, destructive: true }]}>
30
+ * <div class="rounded border p-4">Right-click me</div>
31
+ * </ContextMenu>
32
+ * ```
33
+ */
34
+ export function ContextMenu(props: ContextMenuProps): JSX.Element {
35
+ return (
36
+ <KContextMenu>
37
+ <KContextMenu.Trigger class={cn('inline-flex', props.class)}>{props.children}</KContextMenu.Trigger>
38
+ <KContextMenu.Portal>
39
+ <KContextMenu.Content class="z-50 min-w-[10rem] overflow-hidden rounded-md border border-border bg-card p-1 text-card-foreground shadow-sm">
40
+ <For each={props.items}>
41
+ {(item) => (
42
+ <KContextMenu.Item
43
+ class={cn(
44
+ 'cursor-pointer select-none rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
45
+ item.destructive && 'text-destructive',
46
+ )}
47
+ disabled={item.disabled}
48
+ onSelect={item.onSelect}
49
+ >
50
+ {item.label}
51
+ </KContextMenu.Item>
52
+ )}
53
+ </For>
54
+ </KContextMenu.Content>
55
+ </KContextMenu.Portal>
56
+ </KContextMenu>
57
+ )
58
+ }
@@ -0,0 +1,123 @@
1
+ // Live countdown to a target Date, rendered as four Days/Hours/Mins/Secs cells.
2
+ // A single setInterval (started in onMount, cleared in onCleanup) ticks a signal
3
+ // once per second; remaining ms is clamped at 0 and `onComplete` fires exactly
4
+ // once when the target is reached. Each digit is an odometer-style roller: the
5
+ // column of 0–9 slides so the active digit sits in the viewport, so a decreasing
6
+ // countdown rolls the new number in from the top — like an old flip clock.
7
+ //
8
+ // Digit/cell slots use <Index> (keyed by position, not value) so they persist
9
+ // across ticks and the CSS transform transition actually animates instead of the
10
+ // node being torn down and recreated each second.
11
+ //
12
+ // The target instant is captured once (createMemo): a parent JSX expression like
13
+ // `to={new Date(Date.now() + X)}` compiles to a getter Solid re-evaluates on each
14
+ // access, which would otherwise pin "remaining" at X forever. Memoizing reads it
15
+ // a single time (and still updates if `to` is backed by a real signal).
16
+ import { createMemo, createSignal, Index, onCleanup, onMount, type JSX } from 'solid-js'
17
+
18
+ import { cn } from '../lib/cn'
19
+
20
+ export interface CountdownProps {
21
+ /** Target moment the countdown runs toward. */
22
+ to: Date
23
+ /** Called once, the moment the countdown reaches zero. */
24
+ onComplete?: () => void
25
+ class?: string
26
+ }
27
+
28
+ const MS_PER_SECOND = 1000
29
+ const MS_PER_MINUTE = 60 * MS_PER_SECOND
30
+ const MS_PER_HOUR = 60 * MS_PER_MINUTE
31
+ const MS_PER_DAY = 24 * MS_PER_HOUR
32
+ const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
33
+
34
+ const pad = (n: number): string => String(n).padStart(2, '0')
35
+
36
+ /** One odometer digit: a 0–9 column shifted so `char`'s row fills the viewport. */
37
+ function Digit(props: { char: string }): JSX.Element {
38
+ const digit = () => (props.char >= '0' && props.char <= '9' ? Number(props.char) : 0)
39
+ return (
40
+ <span class="relative inline-block h-[1em] w-[0.62em] overflow-hidden align-top">
41
+ <span
42
+ class="absolute inset-x-0 top-0 flex flex-col transition-transform duration-500 ease-out"
43
+ style={{ transform: `translateY(${digit() * -10}%)` }}
44
+ >
45
+ <Index each={DIGITS}>
46
+ {(d) => <span class="block h-[1em] text-center leading-none">{d()}</span>}
47
+ </Index>
48
+ </span>
49
+ </span>
50
+ )
51
+ }
52
+
53
+ /** Render a value string as a row of rolling digits (stable slots per position). */
54
+ function Rollers(props: { value: string }): JSX.Element {
55
+ const chars = () => props.value.split('')
56
+ return (
57
+ <span class="flex text-2xl font-bold tabular-nums text-foreground">
58
+ <Index each={chars()}>{(char) => <Digit char={char()} />}</Index>
59
+ </span>
60
+ )
61
+ }
62
+
63
+ /**
64
+ * Live countdown to `props.to`, shown as four cells (Days / Hours / Mins /
65
+ * Secs). Updates every second and calls `props.onComplete` once when it hits
66
+ * zero. Each digit rolls like an old flip clock as it changes. Hours, minutes,
67
+ * and seconds are zero-padded to two digits.
68
+ *
69
+ * @example
70
+ * ```tsx
71
+ * <Countdown to={new Date(Date.now() + 86_400_000)} onComplete={() => launch()} />
72
+ * ```
73
+ */
74
+ export function Countdown(props: CountdownProps): JSX.Element {
75
+ // Read the target instant once (see file header on why this must be memoized).
76
+ const target = createMemo(() => props.to.getTime())
77
+ const remainingOf = () => Math.max(0, target() - Date.now())
78
+ const [remaining, setRemaining] = createSignal(remainingOf())
79
+
80
+ let completed = false
81
+ const notifyIfDone = (ms: number) => {
82
+ if (ms <= 0 && !completed) {
83
+ completed = true
84
+ props.onComplete?.()
85
+ }
86
+ }
87
+ // eslint-disable-next-line solid/reactivity -- one-time check at setup for an already-elapsed target
88
+ notifyIfDone(remaining())
89
+
90
+ onMount(() => {
91
+ const id = setInterval(() => {
92
+ const ms = remainingOf()
93
+ setRemaining(ms)
94
+ notifyIfDone(ms)
95
+ }, MS_PER_SECOND)
96
+ onCleanup(() => clearInterval(id))
97
+ })
98
+
99
+ const days = () => Math.floor(remaining() / MS_PER_DAY)
100
+ const hours = () => Math.floor((remaining() % MS_PER_DAY) / MS_PER_HOUR)
101
+ const minutes = () => Math.floor((remaining() % MS_PER_HOUR) / MS_PER_MINUTE)
102
+ const seconds = () => Math.floor((remaining() % MS_PER_MINUTE) / MS_PER_SECOND)
103
+
104
+ const cells = (): { label: string; value: string }[] => [
105
+ { label: 'Days', value: String(days()) },
106
+ { label: 'Hours', value: pad(hours()) },
107
+ { label: 'Mins', value: pad(minutes()) },
108
+ { label: 'Secs', value: pad(seconds()) },
109
+ ]
110
+
111
+ return (
112
+ <div class={cn('flex gap-4', props.class)}>
113
+ <Index each={cells()}>
114
+ {(cell) => (
115
+ <div class="flex flex-col items-center">
116
+ <Rollers value={cell().value} />
117
+ <span class="text-[10px] uppercase tracking-wide text-muted-foreground">{cell().label}</span>
118
+ </div>
119
+ )}
120
+ </Index>
121
+ </div>
122
+ )
123
+ }
@@ -0,0 +1,177 @@
1
+ // Sortable + filterable data table built on the Table + Pagination primitives.
2
+ // Fully client-side and self-contained: the grid owns its own filter, sort, and
3
+ // page signals and derives the visible rows with a single createMemo pipeline
4
+ // (filter → sort → paginate). For huge datasets, prefer a server-driven table.
5
+ import { ChevronDown, ChevronsUpDown, ChevronUp } from 'lucide-solid'
6
+ import type { JSX } from 'solid-js'
7
+ import { createMemo, createSignal, For } from 'solid-js'
8
+
9
+ import { cn } from '../lib/cn'
10
+ import { Input } from './Input'
11
+ import { Pagination } from './Pagination'
12
+ import { Table, TableBody, TableCell, TableHead, TableHeadCell, TableRow } from './Table'
13
+
14
+ /** A single column definition for {@link DataGrid}. */
15
+ export interface DataGridColumn {
16
+ /** Row property this column reads (also used as the sort key). */
17
+ key: string
18
+ /** Column heading text. */
19
+ header: string
20
+ /** When true, the header becomes a button that cycles asc → desc → none. */
21
+ sortable?: boolean
22
+ /** Custom cell renderer; falls back to `String(row[key])` when omitted. */
23
+ render?: (row: Record<string, unknown>) => JSX.Element
24
+ }
25
+
26
+ /** Props for {@link DataGrid}. */
27
+ export interface DataGridProps {
28
+ columns: DataGridColumn[]
29
+ rows: Record<string, unknown>[]
30
+ /** Rows per page. Defaults to 10. */
31
+ pageSize?: number
32
+ /** Show the global text filter above the table. Defaults to true. */
33
+ filterable?: boolean
34
+ class?: string
35
+ }
36
+
37
+ type SortDir = 'asc' | 'desc'
38
+ type SortState = { key: string; dir: SortDir } | null
39
+
40
+ function compareValues(a: unknown, b: unknown): number {
41
+ if (typeof a === 'number' && typeof b === 'number') return a - b
42
+ return String(a ?? '').localeCompare(String(b ?? ''))
43
+ }
44
+
45
+ /**
46
+ * Sortable + filterable table. Provide `columns` and `rows`; the grid handles
47
+ * global text filtering, per-column sorting (click a sortable header to cycle
48
+ * ascending → descending → unsorted), and pagination on its own.
49
+ *
50
+ * @example
51
+ * ```tsx
52
+ * <DataGrid
53
+ * columns={[
54
+ * { key: 'name', header: 'Name', sortable: true },
55
+ * { key: 'age', header: 'Age', sortable: true },
56
+ * ]}
57
+ * rows={[{ name: 'Alfredo', age: 30 }, { name: 'Sonora', age: 12 }]}
58
+ * pageSize={25}
59
+ * />
60
+ * ```
61
+ */
62
+ export function DataGrid(props: DataGridProps): JSX.Element {
63
+ const [query, setQuery] = createSignal('')
64
+ const [sort, setSort] = createSignal<SortState>(null)
65
+ const [page, setPage] = createSignal(1)
66
+
67
+ const pageSize = () => props.pageSize ?? 10
68
+ const filterable = () => props.filterable ?? true
69
+
70
+ function cycleSort(key: string) {
71
+ setPage(1)
72
+ setSort((prev) => {
73
+ if (!prev || prev.key !== key) return { key, dir: 'asc' }
74
+ if (prev.dir === 'asc') return { key, dir: 'desc' }
75
+ return null
76
+ })
77
+ }
78
+
79
+ const filtered = createMemo(() => {
80
+ const q = query().trim().toLowerCase()
81
+ if (!q) return props.rows
82
+ return props.rows.filter((row) =>
83
+ Object.values(row).some((v) =>
84
+ String(v ?? '')
85
+ .toLowerCase()
86
+ .includes(q),
87
+ ),
88
+ )
89
+ })
90
+
91
+ const sorted = createMemo(() => {
92
+ const s = sort()
93
+ if (!s) return filtered()
94
+ const factor = s.dir === 'asc' ? 1 : -1
95
+ return [...filtered()].sort((a, b) => compareValues(a[s.key], b[s.key]) * factor)
96
+ })
97
+
98
+ const totalPages = createMemo(() => Math.max(1, Math.ceil(sorted().length / pageSize())))
99
+
100
+ const paged = createMemo(() => {
101
+ const current = Math.min(page(), totalPages())
102
+ const start = (current - 1) * pageSize()
103
+ return sorted().slice(start, start + pageSize())
104
+ })
105
+
106
+ function ariaSort(col: DataGridColumn): JSX.AriaAttributes['aria-sort'] {
107
+ if (!col.sortable) return undefined
108
+ const s = sort()
109
+ if (!s || s.key !== col.key) return 'none'
110
+ return s.dir === 'asc' ? 'ascending' : 'descending'
111
+ }
112
+
113
+ function SortIcon(colProps: { col: DataGridColumn }) {
114
+ const s = sort()
115
+ if (!s || s.key !== colProps.col.key) return <ChevronsUpDown class="h-4 w-4 opacity-60" />
116
+ return <>{s.dir === 'asc' ? <ChevronUp class="h-4 w-4" /> : <ChevronDown class="h-4 w-4" />}</>
117
+ }
118
+
119
+ return (
120
+ <div class={cn('flex w-full flex-col rounded-lg border border-border bg-background', props.class)}>
121
+ {filterable() && (
122
+ <div class="p-3">
123
+ <Input
124
+ value={query()}
125
+ onInput={(v) => {
126
+ setQuery(v)
127
+ setPage(1)
128
+ }}
129
+ placeholder="Filter…"
130
+ aria-label="Filter rows"
131
+ />
132
+ </div>
133
+ )}
134
+ <Table>
135
+ <TableHead>
136
+ <TableRow>
137
+ <For each={props.columns}>
138
+ {(col) => (
139
+ <TableHeadCell aria-sort={ariaSort(col)}>
140
+ {col.sortable ? (
141
+ <button
142
+ type="button"
143
+ class="inline-flex items-center gap-1 uppercase tracking-wide transition-colors hover:text-foreground"
144
+ onClick={() => cycleSort(col.key)}
145
+ >
146
+ {col.header}
147
+ <SortIcon col={col} />
148
+ </button>
149
+ ) : (
150
+ col.header
151
+ )}
152
+ </TableHeadCell>
153
+ )}
154
+ </For>
155
+ </TableRow>
156
+ </TableHead>
157
+ <TableBody>
158
+ <For each={paged()}>
159
+ {(row) => (
160
+ <TableRow>
161
+ <For each={props.columns}>
162
+ {(col) => <TableCell>{col.render?.(row) ?? String(row[col.key] ?? '')}</TableCell>}
163
+ </For>
164
+ </TableRow>
165
+ )}
166
+ </For>
167
+ </TableBody>
168
+ </Table>
169
+ <Pagination
170
+ page={Math.min(page(), totalPages())}
171
+ totalPages={totalPages()}
172
+ onChange={setPage}
173
+ summary={`${sorted().length} rows`}
174
+ />
175
+ </div>
176
+ )
177
+ }
@@ -0,0 +1,177 @@
1
+ // Compact custom date picker — Kobalte 0.13.x ships no DatePicker, so this is a
2
+ // small hand-rolled month-grid calendar in plain Solid + theme tokens (works in
3
+ // light/dark). No new deps: outside-click is a document listener wired on open
4
+ // and torn down via onCleanup; the popover is absolutely positioned under the
5
+ // trigger. Value/onChange speak `YYYY-MM-DD` (local, never toISOString which
6
+ // shifts by timezone).
7
+ import { CalendarDays } from 'lucide-solid'
8
+ import type { JSX } from 'solid-js'
9
+ import { Show, createEffect, createMemo, createSignal, onCleanup } from 'solid-js'
10
+ import { Portal } from 'solid-js/web'
11
+
12
+ import { cn } from '../lib/cn'
13
+ import { CalendarCore } from './internal/CalendarCore'
14
+
15
+ const WEEKDAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
16
+ const MONTHS = [
17
+ 'January',
18
+ 'February',
19
+ 'March',
20
+ 'April',
21
+ 'May',
22
+ 'June',
23
+ 'July',
24
+ 'August',
25
+ 'September',
26
+ 'October',
27
+ 'November',
28
+ 'December',
29
+ ]
30
+
31
+ /** Format a local Date as `YYYY-MM-DD` (zero-padded, no timezone shift). */
32
+ function fmt(d: Date): string {
33
+ const y = d.getFullYear()
34
+ const m = `${d.getMonth() + 1}`.padStart(2, '0')
35
+ const day = `${d.getDate()}`.padStart(2, '0')
36
+ return `${y}-${m}-${day}`
37
+ }
38
+
39
+ /** Parse `YYYY-MM-DD` into a local-midnight Date; null if malformed. */
40
+ function parse(value: string): Date | null {
41
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
42
+ if (!m) return null
43
+ return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
44
+ }
45
+
46
+ /** Human-readable trigger label, e.g. `15 Mar 2026`. */
47
+ function displayLabel(value: string, months: string[]): string | null {
48
+ const d = parse(value)
49
+ if (!d) return null
50
+ return `${d.getDate()} ${months[d.getMonth()].slice(0, 3)} ${d.getFullYear()}`
51
+ }
52
+
53
+ interface DateFieldProps {
54
+ /** Selected date as `YYYY-MM-DD` (local, no timezone shift), or `''` for none. */
55
+ value: string
56
+ /** Called with the newly picked date as `YYYY-MM-DD`. */
57
+ onChange: (value: string) => void
58
+ /** Placeholder shown on the trigger when `value` is empty. */
59
+ label?: string
60
+ disabled?: boolean
61
+ class?: string
62
+ /** Full month names, January … December order (12 entries). */
63
+ months?: string[]
64
+ /** Weekday headers, Monday-first (7 entries). */
65
+ weekdays?: string[]
66
+ }
67
+
68
+ /**
69
+ * Compact hand-rolled month-grid date picker (no Kobalte primitive covers this yet).
70
+ * Trigger button opens a portaled popover calendar; closes on outside click, Escape,
71
+ * scroll, or resize. Speaks plain `YYYY-MM-DD` strings, never `Date`/`toISOString`.
72
+ *
73
+ * @example
74
+ * ```tsx
75
+ * const [date, setDate] = createSignal('2026-07-14')
76
+ * <DateField value={date()} onChange={setDate} label="Due date" />
77
+ * ```
78
+ */
79
+ export function DateField(props: DateFieldProps): JSX.Element {
80
+ const [open, setOpen] = createSignal(false)
81
+
82
+ let rootRef: HTMLDivElement | undefined
83
+ let btnRef: HTMLButtonElement | undefined
84
+ let popRef: HTMLDivElement | undefined
85
+ // The calendar is rendered in a <Portal> (so it escapes the glass card's
86
+ // backdrop-filter stacking context, which otherwise traps it behind later
87
+ // content). Being at <body>, it can't use `absolute` positioning relative to
88
+ // the trigger — position it with `fixed` at the trigger's viewport rect.
89
+ const [pos, setPos] = createSignal({ top: 0, left: 0 })
90
+ const POP_W = 288 // w-72
91
+
92
+ const place = () => {
93
+ if (!btnRef) return
94
+ const r = btnRef.getBoundingClientRect()
95
+ const left = Math.max(8, Math.min(r.left, window.innerWidth - POP_W - 8))
96
+ setPos({ top: r.bottom + 4, left })
97
+ }
98
+
99
+ const openPopover = () => {
100
+ if (props.disabled) return
101
+ place()
102
+ setOpen(true)
103
+ }
104
+
105
+ const close = () => setOpen(false)
106
+
107
+ const onDocPointer = (ev: MouseEvent) => {
108
+ const t = ev.target as Node
109
+ // Ignore clicks on the trigger (rootRef) AND the portaled popover (popRef).
110
+ if (rootRef && !rootRef.contains(t) && popRef && !popRef.contains(t)) close()
111
+ }
112
+ const onKey = (ev: KeyboardEvent) => {
113
+ if (ev.key === 'Escape') close()
114
+ }
115
+
116
+ // Wire/tear-down the global listeners strictly around the open state. Scroll/
117
+ // resize close the popover (its fixed position would otherwise drift).
118
+ const toggleListeners = (isOpen: boolean) => {
119
+ if (isOpen) {
120
+ document.addEventListener('mousedown', onDocPointer)
121
+ document.addEventListener('keydown', onKey)
122
+ window.addEventListener('scroll', close, true)
123
+ window.addEventListener('resize', close)
124
+ } else {
125
+ document.removeEventListener('mousedown', onDocPointer)
126
+ document.removeEventListener('keydown', onKey)
127
+ window.removeEventListener('scroll', close, true)
128
+ window.removeEventListener('resize', close)
129
+ }
130
+ }
131
+ // Reactively attach when open flips; always clean up on unmount.
132
+ createEffect(() => toggleListeners(open()))
133
+ onCleanup(() => toggleListeners(false))
134
+
135
+ const selected = createMemo(() => parse(props.value))
136
+
137
+ const pick = (d: Date) => {
138
+ props.onChange(fmt(d))
139
+ close()
140
+ }
141
+
142
+ return (
143
+ <div ref={rootRef} class={cn('relative', props.class)}>
144
+ <button
145
+ ref={btnRef}
146
+ type="button"
147
+ disabled={props.disabled}
148
+ onClick={() => (open() ? close() : openPopover())}
149
+ class="flex w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-3 py-2 text-left text-sm text-foreground outline-none transition-colors a4-field disabled:cursor-not-allowed disabled:opacity-50"
150
+ >
151
+ <span class={displayLabel(props.value, props.months ?? MONTHS) ? '' : 'text-muted-foreground'}>
152
+ {displayLabel(props.value, props.months ?? MONTHS) ?? props.label ?? 'Select date'}
153
+ </span>
154
+ <CalendarDays class="h-4 w-4 shrink-0 text-muted-foreground" />
155
+ </button>
156
+
157
+ <Show when={open()}>
158
+ <Portal>
159
+ <div
160
+ ref={popRef}
161
+ style={{ position: 'fixed', top: `${pos().top}px`, left: `${pos().left}px` }}
162
+ class="z-50 w-72 rounded-lg border border-border bg-card p-3 text-card-foreground shadow-lg"
163
+ >
164
+ <CalendarCore
165
+ selected={selected() ?? undefined}
166
+ initialView={selected() ?? undefined}
167
+ onPick={pick}
168
+ weekStart={1}
169
+ months={props.months}
170
+ weekdays={props.weekdays ?? WEEKDAYS}
171
+ />
172
+ </div>
173
+ </Portal>
174
+ </Show>
175
+ </div>
176
+ )
177
+ }