@a4ui/core 0.11.1 → 0.13.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 (151) hide show
  1. package/README.md +80 -37
  2. package/dist/elements.css +79 -9
  3. package/dist/full.css +3112 -0
  4. package/dist/index.d.ts +13 -1
  5. package/dist/index.js +3392 -2537
  6. package/dist/lib/flyToCart.d.ts +25 -0
  7. package/dist/ui/Curtain.d.ts +44 -0
  8. package/dist/ui/Expandable.d.ts +34 -0
  9. package/dist/ui/FillText.d.ts +18 -0
  10. package/dist/ui/HoldToConfirm.d.ts +24 -0
  11. package/dist/ui/LoadingDots.d.ts +20 -0
  12. package/dist/ui/MultiStateBadge.d.ts +23 -0
  13. package/dist/ui/NotificationStack.d.ts +32 -0
  14. package/dist/ui/NowPlaying.d.ts +21 -0
  15. package/dist/ui/Parallax.d.ts +21 -0
  16. package/dist/ui/ScrambleText.d.ts +22 -0
  17. package/dist/ui/TextReveal.d.ts +23 -0
  18. package/package.json +8 -1
  19. package/src/charts/BarChart.tsx +55 -0
  20. package/src/charts/DonutChart.tsx +127 -0
  21. package/src/charts/Sparkline.tsx +101 -0
  22. package/src/charts/index.ts +5 -0
  23. package/src/commerce/CartLine.tsx +84 -0
  24. package/src/commerce/CartSummary.tsx +70 -0
  25. package/src/commerce/FilterGroup.tsx +70 -0
  26. package/src/commerce/PriceTag.tsx +62 -0
  27. package/src/commerce/ProductCard.tsx +121 -0
  28. package/src/commerce/ProductGrid.tsx +27 -0
  29. package/src/commerce/QuantityStepper.tsx +59 -0
  30. package/src/commerce/index.ts +10 -0
  31. package/src/elements.tsx +167 -0
  32. package/src/index.ts +186 -0
  33. package/src/layout/AppShell.tsx +102 -0
  34. package/src/layout/ChristmasBackground.tsx +177 -0
  35. package/src/layout/EffectsToggle.tsx +41 -0
  36. package/src/layout/NavGroup.tsx +45 -0
  37. package/src/layout/SnowScenery.tsx +76 -0
  38. package/src/layout/SpaceBackground.tsx +459 -0
  39. package/src/layout/ThemeToggle.tsx +40 -0
  40. package/src/layout/ThemedScenery.tsx +179 -0
  41. package/src/layout/sceneEffects.ts +49 -0
  42. package/src/lib/cn.ts +17 -0
  43. package/src/lib/effects.ts +71 -0
  44. package/src/lib/flyToCart.ts +103 -0
  45. package/src/lib/media.ts +27 -0
  46. package/src/lib/motion.ts +191 -0
  47. package/src/lib/theme.ts +113 -0
  48. package/src/lib/virtual.ts +33 -0
  49. package/src/styles/space.css +510 -0
  50. package/src/styles/tokens.css +213 -0
  51. package/src/themes/index.ts +102 -0
  52. package/src/themes/palettes.ts +368 -0
  53. package/src/ui/Accordion.tsx +65 -0
  54. package/src/ui/Affix.tsx +72 -0
  55. package/src/ui/Alert.tsx +55 -0
  56. package/src/ui/AlertDialog.tsx +58 -0
  57. package/src/ui/Anchor.tsx +100 -0
  58. package/src/ui/Avatar.tsx +34 -0
  59. package/src/ui/AvatarGroup.tsx +55 -0
  60. package/src/ui/BackToTop.tsx +51 -0
  61. package/src/ui/Badge.tsx +46 -0
  62. package/src/ui/BottomNavigation.tsx +65 -0
  63. package/src/ui/Breadcrumb.tsx +64 -0
  64. package/src/ui/Button.tsx +49 -0
  65. package/src/ui/Calendar.tsx +40 -0
  66. package/src/ui/CalendarHeatmap.tsx +180 -0
  67. package/src/ui/Card.tsx +84 -0
  68. package/src/ui/Carousel.tsx +121 -0
  69. package/src/ui/Cascader.tsx +158 -0
  70. package/src/ui/Checkbox.tsx +35 -0
  71. package/src/ui/Clock.tsx +221 -0
  72. package/src/ui/Collapse.tsx +48 -0
  73. package/src/ui/ColorPicker.tsx +89 -0
  74. package/src/ui/Combobox.tsx +67 -0
  75. package/src/ui/Command.tsx +158 -0
  76. package/src/ui/Comment.tsx +91 -0
  77. package/src/ui/ContextMenu.tsx +58 -0
  78. package/src/ui/Countdown.tsx +123 -0
  79. package/src/ui/Curtain.tsx +348 -0
  80. package/src/ui/DataGrid.tsx +177 -0
  81. package/src/ui/DateField.tsx +177 -0
  82. package/src/ui/DateRangePicker.tsx +204 -0
  83. package/src/ui/DateTimeField.tsx +68 -0
  84. package/src/ui/Descriptions.tsx +56 -0
  85. package/src/ui/Drawer.tsx +72 -0
  86. package/src/ui/Dropdown.tsx +71 -0
  87. package/src/ui/Dropzone.tsx +88 -0
  88. package/src/ui/Empty.tsx +53 -0
  89. package/src/ui/Expandable.tsx +229 -0
  90. package/src/ui/FileUpload.tsx +198 -0
  91. package/src/ui/FillText.tsx +63 -0
  92. package/src/ui/FloatingActionButton.tsx +48 -0
  93. package/src/ui/Form.tsx +108 -0
  94. package/src/ui/Highlight.tsx +53 -0
  95. package/src/ui/HoldToConfirm.tsx +141 -0
  96. package/src/ui/HoverCard.tsx +42 -0
  97. package/src/ui/Image.tsx +66 -0
  98. package/src/ui/Input.tsx +38 -0
  99. package/src/ui/Kbd.tsx +25 -0
  100. package/src/ui/List.tsx +71 -0
  101. package/src/ui/LoadingDots.tsx +75 -0
  102. package/src/ui/Marquee.tsx +48 -0
  103. package/src/ui/Mentions.tsx +170 -0
  104. package/src/ui/Meter.tsx +57 -0
  105. package/src/ui/Modal.tsx +106 -0
  106. package/src/ui/MultiSelect.tsx +236 -0
  107. package/src/ui/MultiStateBadge.tsx +93 -0
  108. package/src/ui/NotificationCenter.tsx +103 -0
  109. package/src/ui/NotificationStack.tsx +104 -0
  110. package/src/ui/NowPlaying.tsx +100 -0
  111. package/src/ui/NumberInput.tsx +48 -0
  112. package/src/ui/PageHeader.tsx +60 -0
  113. package/src/ui/Pagination.tsx +62 -0
  114. package/src/ui/Parallax.tsx +51 -0
  115. package/src/ui/Popover.tsx +42 -0
  116. package/src/ui/Portal.tsx +34 -0
  117. package/src/ui/Progress.tsx +55 -0
  118. package/src/ui/RadioGroup.tsx +65 -0
  119. package/src/ui/Rating.tsx +98 -0
  120. package/src/ui/Result.tsx +75 -0
  121. package/src/ui/RingProgress.tsx +75 -0
  122. package/src/ui/ScrambleText.tsx +95 -0
  123. package/src/ui/SegmentedControl.tsx +61 -0
  124. package/src/ui/Select.tsx +55 -0
  125. package/src/ui/Separator.tsx +39 -0
  126. package/src/ui/Skeleton.tsx +23 -0
  127. package/src/ui/Slider.tsx +57 -0
  128. package/src/ui/Sortable.tsx +174 -0
  129. package/src/ui/SpeedDial.tsx +85 -0
  130. package/src/ui/Spinner.tsx +33 -0
  131. package/src/ui/Splitter.tsx +115 -0
  132. package/src/ui/Stat.tsx +74 -0
  133. package/src/ui/Stepper.tsx +134 -0
  134. package/src/ui/Switch.tsx +44 -0
  135. package/src/ui/Table.tsx +128 -0
  136. package/src/ui/Tabs.tsx +72 -0
  137. package/src/ui/TagInput.tsx +85 -0
  138. package/src/ui/TextReveal.tsx +93 -0
  139. package/src/ui/Textarea.tsx +38 -0
  140. package/src/ui/TimeField.tsx +298 -0
  141. package/src/ui/Timeline.tsx +89 -0
  142. package/src/ui/Toast.tsx +68 -0
  143. package/src/ui/Toggle.tsx +41 -0
  144. package/src/ui/ToggleGroup.tsx +59 -0
  145. package/src/ui/Tooltip.tsx +42 -0
  146. package/src/ui/Tour.tsx +197 -0
  147. package/src/ui/Transfer.tsx +112 -0
  148. package/src/ui/Tree.tsx +100 -0
  149. package/src/ui/TreeSelect.tsx +151 -0
  150. package/src/ui/VirtualList.tsx +81 -0
  151. package/src/ui/internal/CalendarCore.tsx +276 -0
@@ -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
+ }
@@ -0,0 +1,204 @@
1
+ // Two-click range variant of DateField: same hand-rolled popover mechanism
2
+ // (portaled, fixed-positioned at the trigger's viewport rect, closing on
3
+ // outside click / Escape / scroll / resize), but driving `<CalendarCore>`'s
4
+ // `rangeStart`/`rangeEnd` highlight instead of a single `selected` day. Value/
5
+ // onChange speak plain `YYYY-MM-DD` strings (local, never `toISOString` which
6
+ // shifts by timezone).
7
+ import { CalendarDays } from 'lucide-solid'
8
+ import type { JSX } from 'solid-js'
9
+ import { createEffect, createMemo, createSignal, onCleanup, Show } 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 endpoint label, e.g. `15 Mar 2026`. */
47
+ function formatDay(d: Date, months: string[]): string {
48
+ return `${d.getDate()} ${months[d.getMonth()].slice(0, 3)} ${d.getFullYear()}`
49
+ }
50
+
51
+ export interface DateRange {
52
+ /** 'YYYY-MM-DD' (local) or '' if unset. */
53
+ start: string
54
+ end: string
55
+ }
56
+
57
+ export interface DateRangePickerProps {
58
+ value: DateRange
59
+ onChange: (value: DateRange) => void
60
+ /** Placeholder shown when nothing is selected. */
61
+ label?: string
62
+ disabled?: boolean
63
+ months?: string[]
64
+ weekdays?: string[]
65
+ class?: string
66
+ }
67
+
68
+ /**
69
+ * Compact hand-rolled range picker built on the same popover mechanism as
70
+ * `DateField`, but tracking a start/end pair instead of a single day. The
71
+ * first click begins a range, the second completes it (closing the popover);
72
+ * clicking again once complete starts a fresh range.
73
+ *
74
+ * @example
75
+ * ```tsx
76
+ * const [range, setRange] = createSignal<DateRange>({ start: '2026-03-15', end: '' })
77
+ * <DateRangePicker value={range()} onChange={setRange} label="Stay dates" />
78
+ * ```
79
+ */
80
+ export function DateRangePicker(props: DateRangePickerProps): JSX.Element {
81
+ const [open, setOpen] = createSignal(false)
82
+
83
+ let rootRef: HTMLDivElement | undefined
84
+ let btnRef: HTMLButtonElement | undefined
85
+ let popRef: HTMLDivElement | undefined
86
+ // The calendar is rendered in a <Portal> (so it escapes the glass card's
87
+ // backdrop-filter stacking context, which otherwise traps it behind later
88
+ // content). Being at <body>, it can't use `absolute` positioning relative to
89
+ // the trigger — position it with `fixed` at the trigger's viewport rect.
90
+ const [pos, setPos] = createSignal({ top: 0, left: 0 })
91
+ const POP_W = 288 // w-72
92
+
93
+ const place = () => {
94
+ if (!btnRef) return
95
+ const r = btnRef.getBoundingClientRect()
96
+ const left = Math.max(8, Math.min(r.left, window.innerWidth - POP_W - 8))
97
+ setPos({ top: r.bottom + 4, left })
98
+ }
99
+
100
+ const openPopover = () => {
101
+ if (props.disabled) return
102
+ place()
103
+ setOpen(true)
104
+ }
105
+
106
+ const close = () => setOpen(false)
107
+
108
+ const onDocPointer = (ev: MouseEvent) => {
109
+ const t = ev.target as Node
110
+ // Ignore clicks on the trigger (rootRef) AND the portaled popover (popRef).
111
+ if (rootRef && !rootRef.contains(t) && popRef && !popRef.contains(t)) close()
112
+ }
113
+ const onKey = (ev: KeyboardEvent) => {
114
+ if (ev.key === 'Escape') close()
115
+ }
116
+
117
+ // Wire/tear-down the global listeners strictly around the open state. Scroll/
118
+ // resize close the popover (its fixed position would otherwise drift).
119
+ const toggleListeners = (isOpen: boolean) => {
120
+ if (isOpen) {
121
+ document.addEventListener('mousedown', onDocPointer)
122
+ document.addEventListener('keydown', onKey)
123
+ window.addEventListener('scroll', close, true)
124
+ window.addEventListener('resize', close)
125
+ } else {
126
+ document.removeEventListener('mousedown', onDocPointer)
127
+ document.removeEventListener('keydown', onKey)
128
+ window.removeEventListener('scroll', close, true)
129
+ window.removeEventListener('resize', close)
130
+ }
131
+ }
132
+ // Reactively attach when open flips; always clean up on unmount.
133
+ createEffect(() => toggleListeners(open()))
134
+ onCleanup(() => toggleListeners(false))
135
+
136
+ const startDate = createMemo(() => parse(props.value.start))
137
+ const endDate = createMemo(() => parse(props.value.end))
138
+
139
+ const triggerLabel = createMemo(() => {
140
+ const months = props.months ?? MONTHS
141
+ const s = startDate()
142
+ const e = endDate()
143
+ if (s && e) return `${formatDay(s, months)} – ${formatDay(e, months)}`
144
+ if (s) return `${formatDay(s, months)} – …`
145
+ return null
146
+ })
147
+
148
+ const handlePick = (d: Date) => {
149
+ const { start, end } = props.value
150
+ const startD = start ? parse(start) : null
151
+
152
+ // Both endpoints set, or neither set: begin a fresh range and stay open.
153
+ if ((start && end) || (!start && !end) || !startD) {
154
+ props.onChange({ start: fmt(d), end: '' })
155
+ return
156
+ }
157
+
158
+ // Start set, end empty: complete the range if picking on/after start,
159
+ // otherwise restart from the newly picked (earlier) day.
160
+ if (d >= startD) {
161
+ props.onChange({ start, end: fmt(d) })
162
+ close()
163
+ } else {
164
+ props.onChange({ start: fmt(d), end: '' })
165
+ }
166
+ }
167
+
168
+ return (
169
+ <div ref={rootRef} class={cn('relative', props.class)}>
170
+ <button
171
+ ref={btnRef}
172
+ type="button"
173
+ disabled={props.disabled}
174
+ onClick={() => (open() ? close() : openPopover())}
175
+ 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"
176
+ >
177
+ <span class={triggerLabel() ? '' : 'text-muted-foreground'}>
178
+ {triggerLabel() ?? props.label ?? 'Select range'}
179
+ </span>
180
+ <CalendarDays class="h-4 w-4 shrink-0 text-muted-foreground" />
181
+ </button>
182
+
183
+ <Show when={open()}>
184
+ <Portal>
185
+ <div
186
+ ref={popRef}
187
+ style={{ position: 'fixed', top: `${pos().top}px`, left: `${pos().left}px` }}
188
+ class="z-50 w-72 rounded-lg border border-border bg-card p-3 text-card-foreground shadow-lg"
189
+ >
190
+ <CalendarCore
191
+ rangeStart={startDate() ?? undefined}
192
+ rangeEnd={endDate() ?? undefined}
193
+ initialView={startDate() ?? undefined}
194
+ onPick={handlePick}
195
+ weekStart={1}
196
+ months={props.months}
197
+ weekdays={props.weekdays ?? WEEKDAYS}
198
+ />
199
+ </div>
200
+ </Portal>
201
+ </Show>
202
+ </div>
203
+ )
204
+ }
@@ -0,0 +1,68 @@
1
+ // Combined date + time picker: a DateField and a TimeField side by side that
2
+ // read/write a single "YYYY-MM-DD HH:MM" string (local, no timezone shift). Each
3
+ // half keeps its own popover; this just splits the value on the way in and joins
4
+ // it on the way out, so either part can be filled independently.
5
+ import type { JSX } from 'solid-js'
6
+ import { createMemo } from 'solid-js'
7
+
8
+ import { cn } from '../lib/cn'
9
+ import { DateField } from './DateField'
10
+ import { TimeField } from './TimeField'
11
+
12
+ export interface DateTimeFieldProps {
13
+ /** Selected moment as `YYYY-MM-DD HH:MM` (local), or `''` for none. */
14
+ value: string
15
+ /** Called with the combined `YYYY-MM-DD HH:MM` (either part may be missing). */
16
+ onChange: (value: string) => void
17
+ /** 12-hour time display with AM/PM. @default false */
18
+ hour12?: boolean
19
+ /** Minute increment for the time options. @default 5 */
20
+ minuteStep?: number
21
+ /** Placeholder for the date half. */
22
+ dateLabel?: string
23
+ /** Placeholder for the time half. */
24
+ timeLabel?: string
25
+ disabled?: boolean
26
+ class?: string
27
+ }
28
+
29
+ const DATE_RE = /\d{4}-\d{2}-\d{2}/
30
+ const TIME_RE = /\d{2}:\d{2}/
31
+
32
+ const join = (date: string, time: string): string => [date, time].filter(Boolean).join(' ')
33
+
34
+ /**
35
+ * Date + time picker built from {@link DateField} and {@link TimeField}. Speaks a
36
+ * single `YYYY-MM-DD HH:MM` string; the two halves fill in independently.
37
+ *
38
+ * @example
39
+ * ```tsx
40
+ * const [when, setWhen] = createSignal('2026-07-15 09:30')
41
+ * <DateTimeField value={when()} onChange={setWhen} hour12 />
42
+ * ```
43
+ */
44
+ export function DateTimeField(props: DateTimeFieldProps): JSX.Element {
45
+ const datePart = createMemo(() => DATE_RE.exec(props.value)?.[0] ?? '')
46
+ const timePart = createMemo(() => TIME_RE.exec(props.value)?.[0] ?? '')
47
+
48
+ return (
49
+ <div class={cn('flex flex-wrap items-start gap-2', props.class)}>
50
+ <DateField
51
+ value={datePart()}
52
+ onChange={(d) => props.onChange(join(d, timePart()))}
53
+ label={props.dateLabel ?? 'Select date'}
54
+ disabled={props.disabled}
55
+ class="min-w-40 flex-1"
56
+ />
57
+ <TimeField
58
+ value={timePart()}
59
+ onChange={(t) => props.onChange(join(datePart(), t))}
60
+ hour12={props.hour12}
61
+ minuteStep={props.minuteStep}
62
+ label={props.timeLabel ?? 'Select time'}
63
+ disabled={props.disabled}
64
+ class="min-w-32"
65
+ />
66
+ </div>
67
+ )
68
+ }
@@ -0,0 +1,56 @@
1
+ // Semantic key/value detail list — a <dl> laid out as a responsive grid. For
2
+ // showing an entity's attributes (a "properties" / "details" panel). Values are
3
+ // JSX so they can carry badges, links, or formatted content; all colors come
4
+ // from semantic theme tokens so it stays theme-agnostic.
5
+ import { For, type JSX } from 'solid-js'
6
+
7
+ import { cn } from '../lib/cn'
8
+
9
+ /** A single label/value row in a {@link Descriptions} list. */
10
+ export interface DescriptionItem {
11
+ label: string
12
+ value: JSX.Element
13
+ }
14
+
15
+ export interface DescriptionsProps {
16
+ items: DescriptionItem[]
17
+ /** Number of columns on `sm`+ screens (1, 2, or 3). Default: 1. */
18
+ columns?: number
19
+ class?: string
20
+ }
21
+
22
+ const COLUMN_CLASSES: Record<number, string> = {
23
+ 1: 'grid-cols-1',
24
+ 2: 'sm:grid-cols-2',
25
+ 3: 'sm:grid-cols-3',
26
+ }
27
+
28
+ /**
29
+ * Renders a set of label/value pairs as a semantic `<dl>` in a responsive grid.
30
+ * Use `columns` to fan the pairs out into 2 or 3 columns on wider screens.
31
+ *
32
+ * @example
33
+ * ```tsx
34
+ * <Descriptions
35
+ * columns={2}
36
+ * items={[
37
+ * { label: 'Status', value: <Badge tone="success">Active</Badge> },
38
+ * { label: 'Owner', value: 'Ada Lovelace' },
39
+ * ]}
40
+ * />
41
+ * ```
42
+ */
43
+ export function Descriptions(props: DescriptionsProps): JSX.Element {
44
+ return (
45
+ <dl class={cn('grid gap-x-6', COLUMN_CLASSES[props.columns ?? 1] ?? COLUMN_CLASSES[1], props.class)}>
46
+ <For each={props.items}>
47
+ {(item) => (
48
+ <div class="border-b border-border py-2">
49
+ <dt class="text-xs uppercase tracking-wide text-muted-foreground">{item.label}</dt>
50
+ <dd class="mt-0.5 text-sm text-foreground">{item.value}</dd>
51
+ </div>
52
+ )}
53
+ </For>
54
+ </dl>
55
+ )
56
+ }