@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,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
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Right-anchored slide-over on Kobalte's Dialog primitive (focus trap +
|
|
2
|
+
// presence for free, like Modal). The drawer-content class drives the
|
|
3
|
+
// slide-in/out keyframes in styles.css off Kobalte's data-expanded/data-closed.
|
|
4
|
+
// Pass a `title` for the sticky glass header, or omit it and render your own
|
|
5
|
+
// inside `children`.
|
|
6
|
+
import { Dialog } from '@kobalte/core/dialog'
|
|
7
|
+
import { X } from 'lucide-solid'
|
|
8
|
+
import type { JSX, ParentProps } from 'solid-js'
|
|
9
|
+
import { Show } from 'solid-js'
|
|
10
|
+
|
|
11
|
+
import { cn } from '../lib/cn'
|
|
12
|
+
|
|
13
|
+
interface DrawerProps extends ParentProps {
|
|
14
|
+
open: boolean
|
|
15
|
+
onOpenChange: (open: boolean) => void
|
|
16
|
+
/** Sticky glass header title. Omit to render fully custom header content in `children`. */
|
|
17
|
+
title?: string
|
|
18
|
+
/** Small muted line under `title`; only shown when `title` is also set. */
|
|
19
|
+
subtitle?: string
|
|
20
|
+
class?: string
|
|
21
|
+
/** Accessible label for the close button. @default 'Close' */
|
|
22
|
+
closeLabel?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Right-anchored slide-over panel built on Kobalte's `Dialog` (focus trap, portal,
|
|
27
|
+
* and open/close presence handled for you). Use for create/edit forms or detail
|
|
28
|
+
* panels that need more room than a centered `Modal`.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```tsx
|
|
32
|
+
* const [open, setOpen] = createSignal(false)
|
|
33
|
+
* <Drawer open={open()} onOpenChange={setOpen} title="Edit invoice">
|
|
34
|
+
* <p>Drawer content…</p>
|
|
35
|
+
* </Drawer>
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export function Drawer(props: DrawerProps): JSX.Element {
|
|
39
|
+
return (
|
|
40
|
+
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
|
|
41
|
+
<Dialog.Portal>
|
|
42
|
+
<Dialog.Overlay class="modal-overlay fixed inset-0 z-40 bg-black/50 backdrop-blur-[2px]" />
|
|
43
|
+
<div class="fixed inset-0 z-50 flex justify-end">
|
|
44
|
+
<Dialog.Content
|
|
45
|
+
class={cn(
|
|
46
|
+
'drawer-content flex h-full w-full max-w-[520px] flex-col overflow-y-auto border-l border-border bg-glass shadow-2xl',
|
|
47
|
+
props.class,
|
|
48
|
+
)}
|
|
49
|
+
>
|
|
50
|
+
<Show when={props.title}>
|
|
51
|
+
<div class="sticky top-0 z-10 flex items-center justify-between border-b border-border bg-background/90 px-5 py-4 backdrop-blur">
|
|
52
|
+
<div>
|
|
53
|
+
<Dialog.Title class="text-[16px] font-bold leading-tight">{props.title}</Dialog.Title>
|
|
54
|
+
<Show when={props.subtitle}>
|
|
55
|
+
<p class="text-[12px] text-muted-foreground">{props.subtitle}</p>
|
|
56
|
+
</Show>
|
|
57
|
+
</div>
|
|
58
|
+
<Dialog.CloseButton
|
|
59
|
+
class="grid h-9 w-9 place-items-center rounded-lg text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
|
60
|
+
aria-label={props.closeLabel ?? 'Close'}
|
|
61
|
+
>
|
|
62
|
+
<X class="h-5 w-5" />
|
|
63
|
+
</Dialog.CloseButton>
|
|
64
|
+
</div>
|
|
65
|
+
</Show>
|
|
66
|
+
{props.children}
|
|
67
|
+
</Dialog.Content>
|
|
68
|
+
</div>
|
|
69
|
+
</Dialog.Portal>
|
|
70
|
+
</Dialog>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Accessible dropdown menu on Kobalte's DropdownMenu primitive.
|
|
2
|
+
import { DropdownMenu } from '@kobalte/core/dropdown-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 selectable row in a {@link Dropdown} menu. */
|
|
9
|
+
export interface DropdownItem {
|
|
10
|
+
label: string
|
|
11
|
+
onSelect: () => void
|
|
12
|
+
/** Styles the label in the destructive color (e.g. "Delete"). */
|
|
13
|
+
destructive?: boolean
|
|
14
|
+
disabled?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface DropdownProps extends ParentProps {
|
|
18
|
+
items: DropdownItem[]
|
|
19
|
+
class?: string
|
|
20
|
+
/** Accessible name for the trigger. Pass this instead of nesting a `<button>`
|
|
21
|
+
inside — the Trigger IS the button, so its children must be non-interactive. */
|
|
22
|
+
label?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Accessible dropdown/context menu on Kobalte's `DropdownMenu` primitive.
|
|
27
|
+
* `props.children` is the trigger's (non-interactive) content — the Trigger
|
|
28
|
+
* itself renders as the button, so don't nest another `<button>` inside it.
|
|
29
|
+
* Unlike a Dialog, it does not lock page scroll while open.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```tsx
|
|
33
|
+
* <Dropdown label="Row actions" items={[
|
|
34
|
+
* { label: 'Edit', onSelect: () => edit(row) },
|
|
35
|
+
* { label: 'Delete', destructive: true, onSelect: () => remove(row) },
|
|
36
|
+
* ]}>
|
|
37
|
+
* <MoreVertical class="h-4 w-4" />
|
|
38
|
+
* </Dropdown>
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export function Dropdown(props: DropdownProps): JSX.Element {
|
|
42
|
+
return (
|
|
43
|
+
// preventScroll defaults to true in Kobalte's menu — that locks page scroll
|
|
44
|
+
// (body overflow:hidden + scrollbar-width compensation) while the menu is
|
|
45
|
+
// open, which is wrong for a small contextual menu (it closes on scroll
|
|
46
|
+
// anyway). Dialogs (Modal/Drawer) keep the lock; a dropdown shouldn't.
|
|
47
|
+
<DropdownMenu preventScroll={false}>
|
|
48
|
+
<DropdownMenu.Trigger class={cn('inline-flex', props.class)} aria-label={props.label}>
|
|
49
|
+
{props.children}
|
|
50
|
+
</DropdownMenu.Trigger>
|
|
51
|
+
<DropdownMenu.Portal>
|
|
52
|
+
<DropdownMenu.Content class="z-50 min-w-[10rem] overflow-hidden rounded-md border border-border bg-card p-1 text-card-foreground shadow-sm">
|
|
53
|
+
<For each={props.items}>
|
|
54
|
+
{(item) => (
|
|
55
|
+
<DropdownMenu.Item
|
|
56
|
+
class={cn(
|
|
57
|
+
'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',
|
|
58
|
+
item.destructive && 'text-destructive',
|
|
59
|
+
)}
|
|
60
|
+
disabled={item.disabled}
|
|
61
|
+
onSelect={item.onSelect}
|
|
62
|
+
>
|
|
63
|
+
{item.label}
|
|
64
|
+
</DropdownMenu.Item>
|
|
65
|
+
)}
|
|
66
|
+
</For>
|
|
67
|
+
</DropdownMenu.Content>
|
|
68
|
+
</DropdownMenu.Portal>
|
|
69
|
+
</DropdownMenu>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Drag-and-drop (or click-to-browse) file dropzone. Purely presentational: it
|
|
2
|
+
// collects File[] and hands them to `onFiles` — the caller owns upload/mutation.
|
|
3
|
+
import { UploadCloud } from 'lucide-solid'
|
|
4
|
+
import { type JSX, Show, createSignal } from 'solid-js'
|
|
5
|
+
|
|
6
|
+
interface DropzoneProps {
|
|
7
|
+
/** Called with the dropped or browsed files; the caller owns upload/validation. */
|
|
8
|
+
onFiles: (files: File[]) => void
|
|
9
|
+
/** `accept` attribute for the hidden input, e.g. ".xml,.zip". */
|
|
10
|
+
accept?: string
|
|
11
|
+
multiple?: boolean
|
|
12
|
+
disabled?: boolean
|
|
13
|
+
/** Optional hint line under the primary label. */
|
|
14
|
+
hint?: string
|
|
15
|
+
label?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Presentational drag-and-drop (or click-to-browse) file dropzone. Not a Kobalte
|
|
20
|
+
* wrapper — just a styled `role="button"` region plus a hidden `<input type="file">`.
|
|
21
|
+
* It only collects `File[]` via `onFiles`; it does not upload or store anything.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```tsx
|
|
25
|
+
* <Dropzone accept=".xml,.zip" multiple onFiles={(files) => upload(files)} />
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export function Dropzone(props: DropzoneProps): JSX.Element {
|
|
29
|
+
const [dragOver, setDragOver] = createSignal(false)
|
|
30
|
+
let input: HTMLInputElement | undefined
|
|
31
|
+
|
|
32
|
+
const emit = (list: FileList | null | undefined) => {
|
|
33
|
+
if (props.disabled || !list) return
|
|
34
|
+
const files = Array.from(list)
|
|
35
|
+
if (files.length) props.onFiles(files)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div
|
|
40
|
+
role="button"
|
|
41
|
+
tabindex={props.disabled ? -1 : 0}
|
|
42
|
+
aria-disabled={props.disabled}
|
|
43
|
+
onClick={() => !props.disabled && input?.click()}
|
|
44
|
+
onKeyDown={(e) => {
|
|
45
|
+
if (props.disabled) return
|
|
46
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
47
|
+
e.preventDefault()
|
|
48
|
+
input?.click()
|
|
49
|
+
}
|
|
50
|
+
}}
|
|
51
|
+
onDragOver={(e) => {
|
|
52
|
+
if (props.disabled) return
|
|
53
|
+
e.preventDefault()
|
|
54
|
+
setDragOver(true)
|
|
55
|
+
}}
|
|
56
|
+
onDragLeave={() => setDragOver(false)}
|
|
57
|
+
onDrop={(e) => {
|
|
58
|
+
e.preventDefault()
|
|
59
|
+
setDragOver(false)
|
|
60
|
+
emit(e.dataTransfer?.files)
|
|
61
|
+
}}
|
|
62
|
+
class="flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed px-6 py-10 text-center transition"
|
|
63
|
+
classList={{
|
|
64
|
+
'border-ring bg-primary/5': dragOver(),
|
|
65
|
+
'border-border bg-glass hover:border-ring/60': !dragOver() && !props.disabled,
|
|
66
|
+
'cursor-not-allowed opacity-60': props.disabled,
|
|
67
|
+
}}
|
|
68
|
+
>
|
|
69
|
+
<UploadCloud class="h-8 w-8 text-muted-foreground" />
|
|
70
|
+
<p class="text-sm font-medium text-foreground">{props.label ?? 'Drag files here or click to browse'}</p>
|
|
71
|
+
<Show when={props.hint}>
|
|
72
|
+
<p class="text-xs text-muted-foreground">{props.hint}</p>
|
|
73
|
+
</Show>
|
|
74
|
+
<input
|
|
75
|
+
ref={input}
|
|
76
|
+
type="file"
|
|
77
|
+
class="hidden"
|
|
78
|
+
accept={props.accept}
|
|
79
|
+
multiple={props.multiple}
|
|
80
|
+
disabled={props.disabled}
|
|
81
|
+
onChange={(e) => {
|
|
82
|
+
emit(e.currentTarget.files)
|
|
83
|
+
e.currentTarget.value = '' // allow re-selecting the same file
|
|
84
|
+
}}
|
|
85
|
+
/>
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
}
|
package/src/ui/Empty.tsx
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Empty-state placeholder: a centered vertical stack for "nothing here yet"
|
|
2
|
+
// surfaces (empty lists, no search results, first-run views). The icon sits in a
|
|
3
|
+
// muted circle; everything uses semantic tokens so it stays theme-agnostic in
|
|
4
|
+
// both dark and light themes.
|
|
5
|
+
import { Inbox } from 'lucide-solid'
|
|
6
|
+
import { Show, type JSX } from 'solid-js'
|
|
7
|
+
|
|
8
|
+
import { cn } from '../lib/cn'
|
|
9
|
+
|
|
10
|
+
export interface EmptyProps {
|
|
11
|
+
/** Bold headline describing the empty state. */
|
|
12
|
+
title: string
|
|
13
|
+
/** Optional supporting line under the title. */
|
|
14
|
+
description?: string
|
|
15
|
+
/** Optional custom icon; falls back to a lucide-solid `Inbox`. */
|
|
16
|
+
icon?: JSX.Element
|
|
17
|
+
/** Optional action slot (e.g. a Button) rendered below the text. */
|
|
18
|
+
action?: JSX.Element
|
|
19
|
+
class?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Empty-state placeholder — a centered icon, title, optional description, and an
|
|
24
|
+
* optional action slot — for lists, search results, or first-run views that have
|
|
25
|
+
* nothing to show yet. Falls back to an `Inbox` icon when none is provided.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```tsx
|
|
29
|
+
* <Empty
|
|
30
|
+
* title="No projects yet"
|
|
31
|
+
* description="Create your first project to get started."
|
|
32
|
+
* action={<Button>New project</Button>}
|
|
33
|
+
* />
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export function Empty(props: EmptyProps): JSX.Element {
|
|
37
|
+
return (
|
|
38
|
+
<div class={cn('flex flex-col items-center py-12 text-center', props.class)}>
|
|
39
|
+
<div class="grid h-12 w-12 place-items-center rounded-full bg-muted text-muted-foreground">
|
|
40
|
+
<Show when={props.icon} fallback={<Inbox class="h-6 w-6" />}>
|
|
41
|
+
{props.icon}
|
|
42
|
+
</Show>
|
|
43
|
+
</div>
|
|
44
|
+
<p class="mt-4 font-semibold text-foreground">{props.title}</p>
|
|
45
|
+
<Show when={props.description}>
|
|
46
|
+
<p class="mt-1 max-w-sm text-sm text-muted-foreground">{props.description}</p>
|
|
47
|
+
</Show>
|
|
48
|
+
<Show when={props.action}>
|
|
49
|
+
<div class="mt-4">{props.action}</div>
|
|
50
|
+
</Show>
|
|
51
|
+
</div>
|
|
52
|
+
)
|
|
53
|
+
}
|