@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.
- package/README.md +36 -26
- package/dist/full.css +3042 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- 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 +166 -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/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/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/FileUpload.tsx +198 -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/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/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/NotificationCenter.tsx +103 -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/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/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 +75 -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/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,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
|
+
}
|