@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.
- package/README.md +80 -37
- package/dist/elements.css +79 -9
- package/dist/full.css +3112 -0
- package/dist/index.d.ts +13 -1
- package/dist/index.js +3392 -2537
- package/dist/lib/flyToCart.d.ts +25 -0
- package/dist/ui/Curtain.d.ts +44 -0
- package/dist/ui/Expandable.d.ts +34 -0
- package/dist/ui/FillText.d.ts +18 -0
- package/dist/ui/HoldToConfirm.d.ts +24 -0
- package/dist/ui/LoadingDots.d.ts +20 -0
- package/dist/ui/MultiStateBadge.d.ts +23 -0
- package/dist/ui/NotificationStack.d.ts +32 -0
- package/dist/ui/NowPlaying.d.ts +21 -0
- package/dist/ui/Parallax.d.ts +21 -0
- package/dist/ui/ScrambleText.d.ts +22 -0
- package/dist/ui/TextReveal.d.ts +23 -0
- package/package.json +8 -1
- package/src/charts/BarChart.tsx +55 -0
- package/src/charts/DonutChart.tsx +127 -0
- package/src/charts/Sparkline.tsx +101 -0
- package/src/charts/index.ts +5 -0
- package/src/commerce/CartLine.tsx +84 -0
- package/src/commerce/CartSummary.tsx +70 -0
- package/src/commerce/FilterGroup.tsx +70 -0
- package/src/commerce/PriceTag.tsx +62 -0
- package/src/commerce/ProductCard.tsx +121 -0
- package/src/commerce/ProductGrid.tsx +27 -0
- package/src/commerce/QuantityStepper.tsx +59 -0
- package/src/commerce/index.ts +10 -0
- package/src/elements.tsx +167 -0
- package/src/index.ts +186 -0
- package/src/layout/AppShell.tsx +102 -0
- package/src/layout/ChristmasBackground.tsx +177 -0
- package/src/layout/EffectsToggle.tsx +41 -0
- package/src/layout/NavGroup.tsx +45 -0
- package/src/layout/SnowScenery.tsx +76 -0
- package/src/layout/SpaceBackground.tsx +459 -0
- package/src/layout/ThemeToggle.tsx +40 -0
- package/src/layout/ThemedScenery.tsx +179 -0
- package/src/layout/sceneEffects.ts +49 -0
- package/src/lib/cn.ts +17 -0
- package/src/lib/effects.ts +71 -0
- package/src/lib/flyToCart.ts +103 -0
- package/src/lib/media.ts +27 -0
- package/src/lib/motion.ts +191 -0
- package/src/lib/theme.ts +113 -0
- package/src/lib/virtual.ts +33 -0
- package/src/styles/space.css +510 -0
- package/src/styles/tokens.css +213 -0
- package/src/themes/index.ts +102 -0
- package/src/themes/palettes.ts +368 -0
- package/src/ui/Accordion.tsx +65 -0
- package/src/ui/Affix.tsx +72 -0
- package/src/ui/Alert.tsx +55 -0
- package/src/ui/AlertDialog.tsx +58 -0
- package/src/ui/Anchor.tsx +100 -0
- package/src/ui/Avatar.tsx +34 -0
- package/src/ui/AvatarGroup.tsx +55 -0
- package/src/ui/BackToTop.tsx +51 -0
- package/src/ui/Badge.tsx +46 -0
- package/src/ui/BottomNavigation.tsx +65 -0
- package/src/ui/Breadcrumb.tsx +64 -0
- package/src/ui/Button.tsx +49 -0
- package/src/ui/Calendar.tsx +40 -0
- package/src/ui/CalendarHeatmap.tsx +180 -0
- package/src/ui/Card.tsx +84 -0
- package/src/ui/Carousel.tsx +121 -0
- package/src/ui/Cascader.tsx +158 -0
- package/src/ui/Checkbox.tsx +35 -0
- package/src/ui/Clock.tsx +221 -0
- package/src/ui/Collapse.tsx +48 -0
- package/src/ui/ColorPicker.tsx +89 -0
- package/src/ui/Combobox.tsx +67 -0
- package/src/ui/Command.tsx +158 -0
- package/src/ui/Comment.tsx +91 -0
- package/src/ui/ContextMenu.tsx +58 -0
- package/src/ui/Countdown.tsx +123 -0
- package/src/ui/Curtain.tsx +348 -0
- package/src/ui/DataGrid.tsx +177 -0
- package/src/ui/DateField.tsx +177 -0
- package/src/ui/DateRangePicker.tsx +204 -0
- package/src/ui/DateTimeField.tsx +68 -0
- package/src/ui/Descriptions.tsx +56 -0
- package/src/ui/Drawer.tsx +72 -0
- package/src/ui/Dropdown.tsx +71 -0
- package/src/ui/Dropzone.tsx +88 -0
- package/src/ui/Empty.tsx +53 -0
- package/src/ui/Expandable.tsx +229 -0
- package/src/ui/FileUpload.tsx +198 -0
- package/src/ui/FillText.tsx +63 -0
- package/src/ui/FloatingActionButton.tsx +48 -0
- package/src/ui/Form.tsx +108 -0
- package/src/ui/Highlight.tsx +53 -0
- package/src/ui/HoldToConfirm.tsx +141 -0
- package/src/ui/HoverCard.tsx +42 -0
- package/src/ui/Image.tsx +66 -0
- package/src/ui/Input.tsx +38 -0
- package/src/ui/Kbd.tsx +25 -0
- package/src/ui/List.tsx +71 -0
- package/src/ui/LoadingDots.tsx +75 -0
- package/src/ui/Marquee.tsx +48 -0
- package/src/ui/Mentions.tsx +170 -0
- package/src/ui/Meter.tsx +57 -0
- package/src/ui/Modal.tsx +106 -0
- package/src/ui/MultiSelect.tsx +236 -0
- package/src/ui/MultiStateBadge.tsx +93 -0
- package/src/ui/NotificationCenter.tsx +103 -0
- package/src/ui/NotificationStack.tsx +104 -0
- package/src/ui/NowPlaying.tsx +100 -0
- package/src/ui/NumberInput.tsx +48 -0
- package/src/ui/PageHeader.tsx +60 -0
- package/src/ui/Pagination.tsx +62 -0
- package/src/ui/Parallax.tsx +51 -0
- package/src/ui/Popover.tsx +42 -0
- package/src/ui/Portal.tsx +34 -0
- package/src/ui/Progress.tsx +55 -0
- package/src/ui/RadioGroup.tsx +65 -0
- package/src/ui/Rating.tsx +98 -0
- package/src/ui/Result.tsx +75 -0
- package/src/ui/RingProgress.tsx +75 -0
- package/src/ui/ScrambleText.tsx +95 -0
- package/src/ui/SegmentedControl.tsx +61 -0
- package/src/ui/Select.tsx +55 -0
- package/src/ui/Separator.tsx +39 -0
- package/src/ui/Skeleton.tsx +23 -0
- package/src/ui/Slider.tsx +57 -0
- package/src/ui/Sortable.tsx +174 -0
- package/src/ui/SpeedDial.tsx +85 -0
- package/src/ui/Spinner.tsx +33 -0
- package/src/ui/Splitter.tsx +115 -0
- package/src/ui/Stat.tsx +74 -0
- package/src/ui/Stepper.tsx +134 -0
- package/src/ui/Switch.tsx +44 -0
- package/src/ui/Table.tsx +128 -0
- package/src/ui/Tabs.tsx +72 -0
- package/src/ui/TagInput.tsx +85 -0
- package/src/ui/TextReveal.tsx +93 -0
- package/src/ui/Textarea.tsx +38 -0
- package/src/ui/TimeField.tsx +298 -0
- package/src/ui/Timeline.tsx +89 -0
- package/src/ui/Toast.tsx +68 -0
- package/src/ui/Toggle.tsx +41 -0
- package/src/ui/ToggleGroup.tsx +59 -0
- package/src/ui/Tooltip.tsx +42 -0
- package/src/ui/Tour.tsx +197 -0
- package/src/ui/Transfer.tsx +112 -0
- package/src/ui/Tree.tsx +100 -0
- package/src/ui/TreeSelect.tsx +151 -0
- package/src/ui/VirtualList.tsx +81 -0
- 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
|
+
}
|