@a4ui/core 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/README.md +36 -26
  2. package/dist/full.css +3042 -0
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +1 -1
  5. package/package.json +8 -1
  6. package/src/charts/BarChart.tsx +55 -0
  7. package/src/charts/DonutChart.tsx +127 -0
  8. package/src/charts/Sparkline.tsx +101 -0
  9. package/src/charts/index.ts +5 -0
  10. package/src/commerce/CartLine.tsx +84 -0
  11. package/src/commerce/CartSummary.tsx +70 -0
  12. package/src/commerce/FilterGroup.tsx +70 -0
  13. package/src/commerce/PriceTag.tsx +62 -0
  14. package/src/commerce/ProductCard.tsx +121 -0
  15. package/src/commerce/ProductGrid.tsx +27 -0
  16. package/src/commerce/QuantityStepper.tsx +59 -0
  17. package/src/commerce/index.ts +10 -0
  18. package/src/elements.tsx +167 -0
  19. package/src/index.ts +166 -0
  20. package/src/layout/AppShell.tsx +102 -0
  21. package/src/layout/ChristmasBackground.tsx +177 -0
  22. package/src/layout/EffectsToggle.tsx +41 -0
  23. package/src/layout/NavGroup.tsx +45 -0
  24. package/src/layout/SnowScenery.tsx +76 -0
  25. package/src/layout/SpaceBackground.tsx +459 -0
  26. package/src/layout/ThemeToggle.tsx +40 -0
  27. package/src/layout/ThemedScenery.tsx +179 -0
  28. package/src/layout/sceneEffects.ts +49 -0
  29. package/src/lib/cn.ts +17 -0
  30. package/src/lib/effects.ts +71 -0
  31. package/src/lib/media.ts +27 -0
  32. package/src/lib/motion.ts +191 -0
  33. package/src/lib/theme.ts +113 -0
  34. package/src/lib/virtual.ts +33 -0
  35. package/src/styles/space.css +510 -0
  36. package/src/styles/tokens.css +213 -0
  37. package/src/themes/index.ts +102 -0
  38. package/src/themes/palettes.ts +368 -0
  39. package/src/ui/Accordion.tsx +65 -0
  40. package/src/ui/Affix.tsx +72 -0
  41. package/src/ui/Alert.tsx +55 -0
  42. package/src/ui/AlertDialog.tsx +58 -0
  43. package/src/ui/Anchor.tsx +100 -0
  44. package/src/ui/Avatar.tsx +34 -0
  45. package/src/ui/AvatarGroup.tsx +55 -0
  46. package/src/ui/BackToTop.tsx +51 -0
  47. package/src/ui/Badge.tsx +46 -0
  48. package/src/ui/BottomNavigation.tsx +65 -0
  49. package/src/ui/Breadcrumb.tsx +64 -0
  50. package/src/ui/Button.tsx +49 -0
  51. package/src/ui/Calendar.tsx +40 -0
  52. package/src/ui/CalendarHeatmap.tsx +180 -0
  53. package/src/ui/Card.tsx +84 -0
  54. package/src/ui/Carousel.tsx +121 -0
  55. package/src/ui/Cascader.tsx +158 -0
  56. package/src/ui/Checkbox.tsx +35 -0
  57. package/src/ui/Clock.tsx +221 -0
  58. package/src/ui/Collapse.tsx +48 -0
  59. package/src/ui/ColorPicker.tsx +89 -0
  60. package/src/ui/Combobox.tsx +67 -0
  61. package/src/ui/Command.tsx +158 -0
  62. package/src/ui/Comment.tsx +91 -0
  63. package/src/ui/ContextMenu.tsx +58 -0
  64. package/src/ui/Countdown.tsx +123 -0
  65. package/src/ui/DataGrid.tsx +177 -0
  66. package/src/ui/DateField.tsx +177 -0
  67. package/src/ui/DateRangePicker.tsx +204 -0
  68. package/src/ui/DateTimeField.tsx +68 -0
  69. package/src/ui/Descriptions.tsx +56 -0
  70. package/src/ui/Drawer.tsx +72 -0
  71. package/src/ui/Dropdown.tsx +71 -0
  72. package/src/ui/Dropzone.tsx +88 -0
  73. package/src/ui/Empty.tsx +53 -0
  74. package/src/ui/FileUpload.tsx +198 -0
  75. package/src/ui/FloatingActionButton.tsx +48 -0
  76. package/src/ui/Form.tsx +108 -0
  77. package/src/ui/Highlight.tsx +53 -0
  78. package/src/ui/HoverCard.tsx +42 -0
  79. package/src/ui/Image.tsx +66 -0
  80. package/src/ui/Input.tsx +38 -0
  81. package/src/ui/Kbd.tsx +25 -0
  82. package/src/ui/List.tsx +71 -0
  83. package/src/ui/Marquee.tsx +48 -0
  84. package/src/ui/Mentions.tsx +170 -0
  85. package/src/ui/Meter.tsx +57 -0
  86. package/src/ui/Modal.tsx +106 -0
  87. package/src/ui/MultiSelect.tsx +236 -0
  88. package/src/ui/NotificationCenter.tsx +103 -0
  89. package/src/ui/NumberInput.tsx +48 -0
  90. package/src/ui/PageHeader.tsx +60 -0
  91. package/src/ui/Pagination.tsx +62 -0
  92. package/src/ui/Popover.tsx +42 -0
  93. package/src/ui/Portal.tsx +34 -0
  94. package/src/ui/Progress.tsx +55 -0
  95. package/src/ui/RadioGroup.tsx +65 -0
  96. package/src/ui/Rating.tsx +98 -0
  97. package/src/ui/Result.tsx +75 -0
  98. package/src/ui/RingProgress.tsx +75 -0
  99. package/src/ui/SegmentedControl.tsx +61 -0
  100. package/src/ui/Select.tsx +55 -0
  101. package/src/ui/Separator.tsx +39 -0
  102. package/src/ui/Skeleton.tsx +23 -0
  103. package/src/ui/Slider.tsx +57 -0
  104. package/src/ui/Sortable.tsx +174 -0
  105. package/src/ui/SpeedDial.tsx +75 -0
  106. package/src/ui/Spinner.tsx +33 -0
  107. package/src/ui/Splitter.tsx +115 -0
  108. package/src/ui/Stat.tsx +74 -0
  109. package/src/ui/Stepper.tsx +134 -0
  110. package/src/ui/Switch.tsx +44 -0
  111. package/src/ui/Table.tsx +128 -0
  112. package/src/ui/Tabs.tsx +72 -0
  113. package/src/ui/TagInput.tsx +85 -0
  114. package/src/ui/Textarea.tsx +38 -0
  115. package/src/ui/TimeField.tsx +298 -0
  116. package/src/ui/Timeline.tsx +89 -0
  117. package/src/ui/Toast.tsx +68 -0
  118. package/src/ui/Toggle.tsx +41 -0
  119. package/src/ui/ToggleGroup.tsx +59 -0
  120. package/src/ui/Tooltip.tsx +42 -0
  121. package/src/ui/Tour.tsx +197 -0
  122. package/src/ui/Transfer.tsx +112 -0
  123. package/src/ui/Tree.tsx +100 -0
  124. package/src/ui/TreeSelect.tsx +151 -0
  125. package/src/ui/VirtualList.tsx +81 -0
  126. package/src/ui/internal/CalendarCore.tsx +276 -0
@@ -0,0 +1,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
+ }
@@ -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
+ }