@a4ui/core 0.11.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/README.md +80 -37
  2. package/dist/elements.css +79 -9
  3. package/dist/full.css +3112 -0
  4. package/dist/index.d.ts +13 -1
  5. package/dist/index.js +3392 -2537
  6. package/dist/lib/flyToCart.d.ts +25 -0
  7. package/dist/ui/Curtain.d.ts +44 -0
  8. package/dist/ui/Expandable.d.ts +34 -0
  9. package/dist/ui/FillText.d.ts +18 -0
  10. package/dist/ui/HoldToConfirm.d.ts +24 -0
  11. package/dist/ui/LoadingDots.d.ts +20 -0
  12. package/dist/ui/MultiStateBadge.d.ts +23 -0
  13. package/dist/ui/NotificationStack.d.ts +32 -0
  14. package/dist/ui/NowPlaying.d.ts +21 -0
  15. package/dist/ui/Parallax.d.ts +21 -0
  16. package/dist/ui/ScrambleText.d.ts +22 -0
  17. package/dist/ui/TextReveal.d.ts +23 -0
  18. package/package.json +8 -1
  19. package/src/charts/BarChart.tsx +55 -0
  20. package/src/charts/DonutChart.tsx +127 -0
  21. package/src/charts/Sparkline.tsx +101 -0
  22. package/src/charts/index.ts +5 -0
  23. package/src/commerce/CartLine.tsx +84 -0
  24. package/src/commerce/CartSummary.tsx +70 -0
  25. package/src/commerce/FilterGroup.tsx +70 -0
  26. package/src/commerce/PriceTag.tsx +62 -0
  27. package/src/commerce/ProductCard.tsx +121 -0
  28. package/src/commerce/ProductGrid.tsx +27 -0
  29. package/src/commerce/QuantityStepper.tsx +59 -0
  30. package/src/commerce/index.ts +10 -0
  31. package/src/elements.tsx +167 -0
  32. package/src/index.ts +186 -0
  33. package/src/layout/AppShell.tsx +102 -0
  34. package/src/layout/ChristmasBackground.tsx +177 -0
  35. package/src/layout/EffectsToggle.tsx +41 -0
  36. package/src/layout/NavGroup.tsx +45 -0
  37. package/src/layout/SnowScenery.tsx +76 -0
  38. package/src/layout/SpaceBackground.tsx +459 -0
  39. package/src/layout/ThemeToggle.tsx +40 -0
  40. package/src/layout/ThemedScenery.tsx +179 -0
  41. package/src/layout/sceneEffects.ts +49 -0
  42. package/src/lib/cn.ts +17 -0
  43. package/src/lib/effects.ts +71 -0
  44. package/src/lib/flyToCart.ts +103 -0
  45. package/src/lib/media.ts +27 -0
  46. package/src/lib/motion.ts +191 -0
  47. package/src/lib/theme.ts +113 -0
  48. package/src/lib/virtual.ts +33 -0
  49. package/src/styles/space.css +510 -0
  50. package/src/styles/tokens.css +213 -0
  51. package/src/themes/index.ts +102 -0
  52. package/src/themes/palettes.ts +368 -0
  53. package/src/ui/Accordion.tsx +65 -0
  54. package/src/ui/Affix.tsx +72 -0
  55. package/src/ui/Alert.tsx +55 -0
  56. package/src/ui/AlertDialog.tsx +58 -0
  57. package/src/ui/Anchor.tsx +100 -0
  58. package/src/ui/Avatar.tsx +34 -0
  59. package/src/ui/AvatarGroup.tsx +55 -0
  60. package/src/ui/BackToTop.tsx +51 -0
  61. package/src/ui/Badge.tsx +46 -0
  62. package/src/ui/BottomNavigation.tsx +65 -0
  63. package/src/ui/Breadcrumb.tsx +64 -0
  64. package/src/ui/Button.tsx +49 -0
  65. package/src/ui/Calendar.tsx +40 -0
  66. package/src/ui/CalendarHeatmap.tsx +180 -0
  67. package/src/ui/Card.tsx +84 -0
  68. package/src/ui/Carousel.tsx +121 -0
  69. package/src/ui/Cascader.tsx +158 -0
  70. package/src/ui/Checkbox.tsx +35 -0
  71. package/src/ui/Clock.tsx +221 -0
  72. package/src/ui/Collapse.tsx +48 -0
  73. package/src/ui/ColorPicker.tsx +89 -0
  74. package/src/ui/Combobox.tsx +67 -0
  75. package/src/ui/Command.tsx +158 -0
  76. package/src/ui/Comment.tsx +91 -0
  77. package/src/ui/ContextMenu.tsx +58 -0
  78. package/src/ui/Countdown.tsx +123 -0
  79. package/src/ui/Curtain.tsx +348 -0
  80. package/src/ui/DataGrid.tsx +177 -0
  81. package/src/ui/DateField.tsx +177 -0
  82. package/src/ui/DateRangePicker.tsx +204 -0
  83. package/src/ui/DateTimeField.tsx +68 -0
  84. package/src/ui/Descriptions.tsx +56 -0
  85. package/src/ui/Drawer.tsx +72 -0
  86. package/src/ui/Dropdown.tsx +71 -0
  87. package/src/ui/Dropzone.tsx +88 -0
  88. package/src/ui/Empty.tsx +53 -0
  89. package/src/ui/Expandable.tsx +229 -0
  90. package/src/ui/FileUpload.tsx +198 -0
  91. package/src/ui/FillText.tsx +63 -0
  92. package/src/ui/FloatingActionButton.tsx +48 -0
  93. package/src/ui/Form.tsx +108 -0
  94. package/src/ui/Highlight.tsx +53 -0
  95. package/src/ui/HoldToConfirm.tsx +141 -0
  96. package/src/ui/HoverCard.tsx +42 -0
  97. package/src/ui/Image.tsx +66 -0
  98. package/src/ui/Input.tsx +38 -0
  99. package/src/ui/Kbd.tsx +25 -0
  100. package/src/ui/List.tsx +71 -0
  101. package/src/ui/LoadingDots.tsx +75 -0
  102. package/src/ui/Marquee.tsx +48 -0
  103. package/src/ui/Mentions.tsx +170 -0
  104. package/src/ui/Meter.tsx +57 -0
  105. package/src/ui/Modal.tsx +106 -0
  106. package/src/ui/MultiSelect.tsx +236 -0
  107. package/src/ui/MultiStateBadge.tsx +93 -0
  108. package/src/ui/NotificationCenter.tsx +103 -0
  109. package/src/ui/NotificationStack.tsx +104 -0
  110. package/src/ui/NowPlaying.tsx +100 -0
  111. package/src/ui/NumberInput.tsx +48 -0
  112. package/src/ui/PageHeader.tsx +60 -0
  113. package/src/ui/Pagination.tsx +62 -0
  114. package/src/ui/Parallax.tsx +51 -0
  115. package/src/ui/Popover.tsx +42 -0
  116. package/src/ui/Portal.tsx +34 -0
  117. package/src/ui/Progress.tsx +55 -0
  118. package/src/ui/RadioGroup.tsx +65 -0
  119. package/src/ui/Rating.tsx +98 -0
  120. package/src/ui/Result.tsx +75 -0
  121. package/src/ui/RingProgress.tsx +75 -0
  122. package/src/ui/ScrambleText.tsx +95 -0
  123. package/src/ui/SegmentedControl.tsx +61 -0
  124. package/src/ui/Select.tsx +55 -0
  125. package/src/ui/Separator.tsx +39 -0
  126. package/src/ui/Skeleton.tsx +23 -0
  127. package/src/ui/Slider.tsx +57 -0
  128. package/src/ui/Sortable.tsx +174 -0
  129. package/src/ui/SpeedDial.tsx +85 -0
  130. package/src/ui/Spinner.tsx +33 -0
  131. package/src/ui/Splitter.tsx +115 -0
  132. package/src/ui/Stat.tsx +74 -0
  133. package/src/ui/Stepper.tsx +134 -0
  134. package/src/ui/Switch.tsx +44 -0
  135. package/src/ui/Table.tsx +128 -0
  136. package/src/ui/Tabs.tsx +72 -0
  137. package/src/ui/TagInput.tsx +85 -0
  138. package/src/ui/TextReveal.tsx +93 -0
  139. package/src/ui/Textarea.tsx +38 -0
  140. package/src/ui/TimeField.tsx +298 -0
  141. package/src/ui/Timeline.tsx +89 -0
  142. package/src/ui/Toast.tsx +68 -0
  143. package/src/ui/Toggle.tsx +41 -0
  144. package/src/ui/ToggleGroup.tsx +59 -0
  145. package/src/ui/Tooltip.tsx +42 -0
  146. package/src/ui/Tour.tsx +197 -0
  147. package/src/ui/Transfer.tsx +112 -0
  148. package/src/ui/Tree.tsx +100 -0
  149. package/src/ui/TreeSelect.tsx +151 -0
  150. package/src/ui/VirtualList.tsx +81 -0
  151. package/src/ui/internal/CalendarCore.tsx +276 -0
@@ -0,0 +1,42 @@
1
+ // Hover/focus tooltip on Kobalte's Tooltip primitive. `children` is the trigger.
2
+ import { Tooltip as KTooltip } from '@kobalte/core/tooltip'
3
+ import type { JSX, ParentProps } from 'solid-js'
4
+
5
+ import { cn } from '../lib/cn'
6
+
7
+ interface TooltipProps extends ParentProps {
8
+ /** Content shown inside the tooltip popover. */
9
+ content: JSX.Element
10
+ /** Applied to the tooltip popover (`KTooltip.Content`), not the trigger. */
11
+ class?: string
12
+ }
13
+
14
+ /**
15
+ * Hover/focus tooltip, built on Kobalte's Tooltip primitive. `children` is
16
+ * the trigger element; `content` is rendered in a portal on hover/focus.
17
+ *
18
+ * @example
19
+ * ```tsx
20
+ * <Tooltip content="Delete this item">
21
+ * <IconButton icon={<TrashIcon />} aria-label="Delete" />
22
+ * </Tooltip>
23
+ * ```
24
+ */
25
+ export function Tooltip(props: TooltipProps): JSX.Element {
26
+ return (
27
+ <KTooltip>
28
+ <KTooltip.Trigger class="inline-flex">{props.children}</KTooltip.Trigger>
29
+ <KTooltip.Portal>
30
+ <KTooltip.Content
31
+ class={cn(
32
+ 'z-50 rounded-md border border-border bg-card px-2.5 py-1.5 text-xs text-card-foreground shadow-md',
33
+ props.class,
34
+ )}
35
+ >
36
+ <KTooltip.Arrow />
37
+ {props.content}
38
+ </KTooltip.Content>
39
+ </KTooltip.Portal>
40
+ </KTooltip>
41
+ )
42
+ }
@@ -0,0 +1,197 @@
1
+ // Guided product tour / coachmarks. Spotlights one target element at a time by
2
+ // drawing a highlight box over its bounding rect and dimming everything else
3
+ // with the classic huge-spreading box-shadow trick (a single element casts a
4
+ // 9999px shadow over the whole viewport, leaving only the target lit). A tooltip
5
+ // Card floats near the target with Back/Next controls. Rendered through a
6
+ // `Portal` into the body so it escapes any overflow/stacking context. All DOM
7
+ // reads are guarded — a missing target just centers the tooltip and skips the
8
+ // highlight.
9
+ import type { JSX } from 'solid-js'
10
+ import { createEffect, createSignal, onCleanup, onMount, Show } from 'solid-js'
11
+ import { Portal } from 'solid-js/web'
12
+
13
+ import { cn } from '../lib/cn'
14
+ import { Button } from './Button'
15
+
16
+ /** A single stop in a {@link Tour}. */
17
+ export interface TourStep {
18
+ /** CSS selector for the element to highlight. */
19
+ target: string
20
+ title: string
21
+ description?: string
22
+ }
23
+
24
+ export interface TourProps {
25
+ steps: TourStep[]
26
+ open: boolean
27
+ onOpenChange: (open: boolean) => void
28
+ /** Called after the last step's "Done" (in addition to `onOpenChange(false)`). */
29
+ onFinish?: () => void
30
+ }
31
+
32
+ interface Rect {
33
+ top: number
34
+ left: number
35
+ width: number
36
+ height: number
37
+ }
38
+
39
+ /**
40
+ * Guided coachmark tour: walks the user through a sequence of on-page targets,
41
+ * spotlighting each one and explaining it in a floating tooltip. Theme-agnostic —
42
+ * the dimmer uses `--background` so it adapts to light/dark automatically.
43
+ *
44
+ * @example
45
+ * ```tsx
46
+ * const [open, setOpen] = createSignal(false)
47
+ * <Tour
48
+ * open={open()}
49
+ * onOpenChange={setOpen}
50
+ * onFinish={() => markTourSeen()}
51
+ * steps={[
52
+ * { target: '#new-btn', title: 'Create', description: 'Start a new record here.' },
53
+ * { target: '#search', title: 'Search', description: 'Find anything fast.' },
54
+ * ]}
55
+ * />
56
+ * ```
57
+ */
58
+ export function Tour(props: TourProps): JSX.Element {
59
+ const [index, setIndex] = createSignal(0)
60
+ const [rect, setRect] = createSignal<Rect | null>(null)
61
+
62
+ const step = () => props.steps[index()]
63
+ const isLast = () => index() >= props.steps.length - 1
64
+ const isFirst = () => index() === 0
65
+
66
+ // Reset to the first step every time the tour is (re)opened.
67
+ createEffect(() => {
68
+ if (props.open) setIndex(0)
69
+ })
70
+
71
+ const measure = () => {
72
+ const current = step()
73
+ if (!props.open || !current) {
74
+ setRect(null)
75
+ return
76
+ }
77
+ const el = document.querySelector(current.target)
78
+ const box = el?.getBoundingClientRect()
79
+ setRect(box ? { top: box.top, left: box.left, width: box.width, height: box.height } : null)
80
+ }
81
+
82
+ // Recompute on step change / open toggle.
83
+ createEffect(() => {
84
+ // Touch reactive deps so this reruns when they change.
85
+ void index()
86
+ void props.open
87
+ measure()
88
+ })
89
+
90
+ onMount(() => {
91
+ const onReflow = () => measure()
92
+ window.addEventListener('resize', onReflow)
93
+ window.addEventListener('scroll', onReflow, true)
94
+ onCleanup(() => {
95
+ window.removeEventListener('resize', onReflow)
96
+ window.removeEventListener('scroll', onReflow, true)
97
+ })
98
+ })
99
+
100
+ const finish = () => {
101
+ props.onOpenChange(false)
102
+ props.onFinish?.()
103
+ }
104
+
105
+ const next = () => {
106
+ if (isLast()) finish()
107
+ else setIndex((i) => i + 1)
108
+ }
109
+
110
+ const back = () => {
111
+ if (!isFirst()) setIndex((i) => i - 1)
112
+ }
113
+
114
+ // Position the tooltip just below the target (falling back to above if the
115
+ // target sits low in the viewport), or dead-center when there's no target.
116
+ const tooltipStyle = (): JSX.CSSProperties => {
117
+ const box = rect()
118
+ if (!box) {
119
+ return {
120
+ position: 'fixed',
121
+ top: '50%',
122
+ left: '50%',
123
+ transform: 'translate(-50%, -50%)',
124
+ }
125
+ }
126
+ const gap = 12
127
+ const below = box.top + box.height + gap
128
+ const placeBelow = below < window.innerHeight - 160
129
+ return {
130
+ position: 'fixed',
131
+ top: placeBelow ? `${below}px` : `${box.top - gap}px`,
132
+ left: `${Math.max(gap, box.left)}px`,
133
+ transform: placeBelow ? undefined : 'translateY(-100%)',
134
+ }
135
+ }
136
+
137
+ return (
138
+ <Show when={props.open && step()}>
139
+ <Portal>
140
+ {/* Spotlight highlight: only rendered when the target was found. The huge
141
+ spreading box-shadow dims the rest of the viewport. */}
142
+ <Show
143
+ when={rect()}
144
+ fallback={
145
+ <div
146
+ class="fixed inset-0 z-[100]"
147
+ style={{ 'background-color': 'hsl(var(--background) / 0.7)' }}
148
+ aria-hidden="true"
149
+ />
150
+ }
151
+ >
152
+ {(box) => (
153
+ <div
154
+ class="pointer-events-none fixed z-[100] rounded-lg ring-2 ring-primary"
155
+ style={{
156
+ top: `${box().top - 4}px`,
157
+ left: `${box().left - 4}px`,
158
+ width: `${box().width + 8}px`,
159
+ height: `${box().height + 8}px`,
160
+ 'box-shadow': '0 0 0 9999px hsl(var(--background) / 0.7)',
161
+ }}
162
+ aria-hidden="true"
163
+ />
164
+ )}
165
+ </Show>
166
+
167
+ {/* Tooltip card */}
168
+ <div
169
+ role="dialog"
170
+ aria-label={step()?.title}
171
+ class="bg-glass z-[101] max-w-xs rounded-lg border border-border p-4 text-card-foreground shadow-2xl"
172
+ style={tooltipStyle()}
173
+ >
174
+ <p class="font-semibold leading-none tracking-tight">{step()?.title}</p>
175
+ <Show when={step()?.description}>
176
+ <p class="mt-2 text-sm text-muted-foreground">{step()?.description}</p>
177
+ </Show>
178
+ <div class="mt-4 flex items-center justify-between gap-3">
179
+ <span class="text-xs text-muted-foreground tabular-nums">
180
+ {index() + 1} / {props.steps.length}
181
+ </span>
182
+ <div class="flex items-center gap-2">
183
+ <Show when={!isFirst()}>
184
+ <Button variant="ghost" class={cn('px-2.5 py-1.5')} onClick={back}>
185
+ Back
186
+ </Button>
187
+ </Show>
188
+ <Button class="px-2.5 py-1.5" onClick={next}>
189
+ {isLast() ? 'Done' : 'Next'}
190
+ </Button>
191
+ </div>
192
+ </div>
193
+ </div>
194
+ </Portal>
195
+ </Show>
196
+ )
197
+ }
@@ -0,0 +1,112 @@
1
+ import type { JSX } from 'solid-js'
2
+ import { createSignal, For } from 'solid-js'
3
+ import { ChevronLeft, ChevronRight } from 'lucide-solid'
4
+
5
+ import { cn } from '../lib/cn'
6
+ import { Button } from './Button'
7
+
8
+ /** A single option shown in either pane of a {@link Transfer}. */
9
+ export interface TransferItem {
10
+ value: string
11
+ label: string
12
+ }
13
+
14
+ export interface TransferProps {
15
+ /** Full pool of options; membership in `selected` decides which pane each lands in. */
16
+ items: TransferItem[]
17
+ /** Values currently in the right ("selected") pane. */
18
+ selected: string[]
19
+ /** Called with the next selected-value array whenever items are moved across. */
20
+ onChange: (selected: string[]) => void
21
+ /** Pane headers, `[availableTitle, selectedTitle]`. Defaults to `['Available', 'Selected']`. */
22
+ titles?: [string, string]
23
+ class?: string
24
+ }
25
+
26
+ /**
27
+ * Dual-list picker: two side-by-side panes let the user shuttle items between
28
+ * an "available" pool and a "selected" set. Tick rows in either pane, then use
29
+ * the middle chevrons to move the ticked items across; `onChange` receives the
30
+ * updated array of selected values.
31
+ *
32
+ * @example
33
+ * ```tsx
34
+ * const [picked, setPicked] = createSignal<string[]>(['a'])
35
+ * <Transfer
36
+ * items={[{ value: 'a', label: 'Apple' }, { value: 'b', label: 'Banana' }]}
37
+ * selected={picked()}
38
+ * onChange={setPicked}
39
+ * titles={['Fruits', 'Basket']}
40
+ * />
41
+ * ```
42
+ */
43
+ export function Transfer(props: TransferProps): JSX.Element {
44
+ const [checked, setChecked] = createSignal<Set<string>>(new Set())
45
+
46
+ const titles = () => props.titles ?? ['Available', 'Selected']
47
+ const available = () => props.items.filter((item) => !props.selected.includes(item.value))
48
+ const chosen = () => props.items.filter((item) => props.selected.includes(item.value))
49
+
50
+ const toggle = (value: string) => {
51
+ const next = new Set(checked())
52
+ if (next.has(value)) next.delete(value)
53
+ else next.add(value)
54
+ setChecked(next)
55
+ }
56
+
57
+ const moveRight = () => {
58
+ const moving = available().filter((item) => checked().has(item.value))
59
+ if (moving.length === 0) return
60
+ props.onChange([...props.selected, ...moving.map((item) => item.value)])
61
+ setChecked(new Set<string>())
62
+ }
63
+
64
+ const moveLeft = () => {
65
+ const remove = new Set(
66
+ chosen()
67
+ .filter((item) => checked().has(item.value))
68
+ .map((item) => item.value),
69
+ )
70
+ if (remove.size === 0) return
71
+ props.onChange(props.selected.filter((value) => !remove.has(value)))
72
+ setChecked(new Set<string>())
73
+ }
74
+
75
+ const pane = (title: string, rows: () => TransferItem[]) => (
76
+ <div class="flex min-w-0 flex-1 flex-col rounded-lg border border-border">
77
+ <div class="border-b border-border px-3 py-2 text-sm font-medium text-foreground">{title}</div>
78
+ <ul class="max-h-64 flex-1 overflow-y-auto p-1">
79
+ <For each={rows()}>
80
+ {(item) => (
81
+ <li>
82
+ <label class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm text-foreground hover:bg-muted">
83
+ <input
84
+ type="checkbox"
85
+ class="h-4 w-4 rounded border-input accent-primary"
86
+ checked={checked().has(item.value)}
87
+ onChange={() => toggle(item.value)}
88
+ />
89
+ <span class="min-w-0 truncate">{item.label}</span>
90
+ </label>
91
+ </li>
92
+ )}
93
+ </For>
94
+ </ul>
95
+ </div>
96
+ )
97
+
98
+ return (
99
+ <div class={cn('flex items-stretch gap-3', props.class)}>
100
+ {pane(titles()[0], available)}
101
+ <div class="flex flex-col items-center justify-center gap-2">
102
+ <Button variant="outline" class="px-2" onClick={moveRight} aria-label="Move to selected">
103
+ <ChevronRight class="h-4 w-4" />
104
+ </Button>
105
+ <Button variant="outline" class="px-2" onClick={moveLeft} aria-label="Move to available">
106
+ <ChevronLeft class="h-4 w-4" />
107
+ </Button>
108
+ </div>
109
+ {pane(titles()[1], chosen)}
110
+ </div>
111
+ )
112
+ }
@@ -0,0 +1,100 @@
1
+ // Hierarchical, expandable tree view with per-node chevron toggles.
2
+ import { ChevronRight } from 'lucide-solid'
3
+ import type { JSX } from 'solid-js'
4
+ import { createSignal, For, Show } from 'solid-js'
5
+ import { cn } from '../lib/cn'
6
+
7
+ /** A single node in a {@link Tree}; nodes may nest via `children`. */
8
+ export interface TreeNode {
9
+ /** Unique identifier; used to track expanded state. */
10
+ id: string
11
+ /** Text shown in the node's row. */
12
+ label: string
13
+ /** Optional icon rendered before the label. */
14
+ icon?: JSX.Element
15
+ /** Child nodes revealed when this node is expanded. */
16
+ children?: TreeNode[]
17
+ }
18
+
19
+ export interface TreeProps {
20
+ nodes: TreeNode[]
21
+ /** Ids of nodes expanded on first render. */
22
+ defaultExpanded?: string[]
23
+ class?: string
24
+ }
25
+
26
+ /**
27
+ * Accessible, indented tree view. Nodes with children render a chevron that
28
+ * rotates when expanded; clicking a row toggles its subtree. Expanded state is
29
+ * tracked internally, seeded from `defaultExpanded`.
30
+ *
31
+ * @example
32
+ * ```tsx
33
+ * <Tree
34
+ * defaultExpanded={['src']}
35
+ * nodes={[
36
+ * {
37
+ * id: 'src',
38
+ * label: 'src',
39
+ * children: [
40
+ * { id: 'index', label: 'index.ts' },
41
+ * { id: 'ui', label: 'ui', children: [{ id: 'tree', label: 'Tree.tsx' }] },
42
+ * ],
43
+ * },
44
+ * ]}
45
+ * />
46
+ * ```
47
+ */
48
+ export function Tree(props: TreeProps): JSX.Element {
49
+ const [expanded, setExpanded] = createSignal<Set<string>>(new Set(props.defaultExpanded ?? []))
50
+
51
+ const toggle = (id: string) =>
52
+ setExpanded((prev) => {
53
+ const next = new Set(prev)
54
+ if (next.has(id)) next.delete(id)
55
+ else next.add(id)
56
+ return next
57
+ })
58
+
59
+ const Node = (nodeProps: { node: TreeNode; depth: number }): JSX.Element => {
60
+ const hasChildren = () => (nodeProps.node.children?.length ?? 0) > 0
61
+ const isExpanded = () => expanded().has(nodeProps.node.id)
62
+
63
+ return (
64
+ <li role="treeitem" aria-expanded={hasChildren() ? isExpanded() : undefined}>
65
+ <div
66
+ class="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm text-foreground hover:bg-muted cursor-pointer"
67
+ style={{ 'padding-left': `${nodeProps.depth * 1 + 0.5}rem` }}
68
+ onClick={() => hasChildren() && toggle(nodeProps.node.id)}
69
+ >
70
+ <Show when={hasChildren()} fallback={<span class="h-3.5 w-3.5 shrink-0" aria-hidden="true" />}>
71
+ <ChevronRight
72
+ class={cn(
73
+ 'h-3.5 w-3.5 shrink-0 transition-transform duration-200',
74
+ isExpanded() && 'rotate-90',
75
+ )}
76
+ aria-hidden="true"
77
+ />
78
+ </Show>
79
+ <Show when={nodeProps.node.icon}>
80
+ <span class="shrink-0">{nodeProps.node.icon}</span>
81
+ </Show>
82
+ <span class="truncate">{nodeProps.node.label}</span>
83
+ </div>
84
+ <Show when={hasChildren() && isExpanded()}>
85
+ <ul role="group">
86
+ <For each={nodeProps.node.children}>
87
+ {(child) => <Node node={child} depth={nodeProps.depth + 1} />}
88
+ </For>
89
+ </ul>
90
+ </Show>
91
+ </li>
92
+ )
93
+ }
94
+
95
+ return (
96
+ <ul role="tree" class={cn('text-foreground', props.class)}>
97
+ <For each={props.nodes}>{(node) => <Node node={node} depth={0} />}</For>
98
+ </ul>
99
+ )
100
+ }
@@ -0,0 +1,151 @@
1
+ // Select-styled trigger that opens a tree popover; leaf clicks pick a value.
2
+ import { ChevronDown, ChevronRight } from 'lucide-solid'
3
+ import type { JSX } from 'solid-js'
4
+ import { createSignal, For, onCleanup, onMount, Show } from 'solid-js'
5
+ import { cn } from '../lib/cn'
6
+ import { type TreeNode } from './Tree'
7
+
8
+ export interface TreeSelectProps {
9
+ /** Hierarchical options; the same shape consumed by {@link Tree}. */
10
+ nodes: TreeNode[]
11
+ /** Id of the currently selected leaf node. */
12
+ value?: string
13
+ /** Fired when a leaf is picked, with its id and the node itself. */
14
+ onChange: (value: string, node: TreeNode) => void
15
+ /** Text shown when no value is selected. */
16
+ placeholder?: string
17
+ class?: string
18
+ }
19
+
20
+ /** Depth-first search for the node whose `id` matches `value`. */
21
+ function findNode(nodes: TreeNode[], value: string): TreeNode | undefined {
22
+ for (const node of nodes) {
23
+ if (node.id === value) return node
24
+ if (node.children) {
25
+ const found = findNode(node.children, value)
26
+ if (found) return found
27
+ }
28
+ }
29
+ return undefined
30
+ }
31
+
32
+ /**
33
+ * A dropdown that pairs a {@link Select}-styled trigger with a {@link Tree} in a
34
+ * popover panel. Clicking a branch toggles its subtree; clicking a leaf calls
35
+ * `onChange` and closes the panel. The trigger shows the selected leaf's label
36
+ * (found by walking `nodes`) or a muted `placeholder`. Closes on outside click.
37
+ *
38
+ * @example
39
+ * ```tsx
40
+ * const [dept, setDept] = createSignal<string>()
41
+ * <TreeSelect
42
+ * placeholder="Pick a team"
43
+ * value={dept()}
44
+ * onChange={(id) => setDept(id)}
45
+ * nodes={[
46
+ * {
47
+ * id: 'eng',
48
+ * label: 'Engineering',
49
+ * children: [
50
+ * { id: 'fe', label: 'Frontend' },
51
+ * { id: 'be', label: 'Backend' },
52
+ * ],
53
+ * },
54
+ * ]}
55
+ * />
56
+ * ```
57
+ */
58
+ export function TreeSelect(props: TreeSelectProps): JSX.Element {
59
+ const [open, setOpen] = createSignal(false)
60
+ const [expanded, setExpanded] = createSignal<Set<string>>(new Set())
61
+ let root!: HTMLDivElement
62
+
63
+ const selected = () => (props.value ? findNode(props.nodes, props.value) : undefined)
64
+
65
+ const toggle = (id: string) =>
66
+ setExpanded((prev) => {
67
+ const next = new Set(prev)
68
+ if (next.has(id)) next.delete(id)
69
+ else next.add(id)
70
+ return next
71
+ })
72
+
73
+ const select = (node: TreeNode) => {
74
+ props.onChange(node.id, node)
75
+ setOpen(false)
76
+ }
77
+
78
+ const onDocClick = (ev: MouseEvent) => {
79
+ if (root && !root.contains(ev.target as Node)) setOpen(false)
80
+ }
81
+ onMount(() => document.addEventListener('click', onDocClick))
82
+ onCleanup(() => document.removeEventListener('click', onDocClick))
83
+
84
+ const Node = (nodeProps: { node: TreeNode; depth: number }): JSX.Element => {
85
+ const hasChildren = () => (nodeProps.node.children?.length ?? 0) > 0
86
+ const isExpanded = () => expanded().has(nodeProps.node.id)
87
+ const isSelected = () => props.value === nodeProps.node.id
88
+
89
+ return (
90
+ <li
91
+ role="treeitem"
92
+ aria-expanded={hasChildren() ? isExpanded() : undefined}
93
+ aria-selected={!hasChildren() ? isSelected() : undefined}
94
+ >
95
+ <div
96
+ class={cn(
97
+ 'flex items-center gap-1.5 rounded-md px-2 py-1 text-sm text-foreground hover:bg-muted cursor-pointer',
98
+ isSelected() && 'bg-accent text-accent-foreground',
99
+ )}
100
+ style={{ 'padding-left': `${nodeProps.depth * 1 + 0.5}rem` }}
101
+ onClick={() => (hasChildren() ? toggle(nodeProps.node.id) : select(nodeProps.node))}
102
+ >
103
+ <Show when={hasChildren()} fallback={<span class="h-3.5 w-3.5 shrink-0" aria-hidden="true" />}>
104
+ <ChevronRight
105
+ class={cn(
106
+ 'h-3.5 w-3.5 shrink-0 transition-transform duration-200',
107
+ isExpanded() && 'rotate-90',
108
+ )}
109
+ aria-hidden="true"
110
+ />
111
+ </Show>
112
+ <Show when={nodeProps.node.icon}>
113
+ <span class="shrink-0">{nodeProps.node.icon}</span>
114
+ </Show>
115
+ <span class="truncate">{nodeProps.node.label}</span>
116
+ </div>
117
+ <Show when={hasChildren() && isExpanded()}>
118
+ <ul role="group">
119
+ <For each={nodeProps.node.children}>
120
+ {(child) => <Node node={child} depth={nodeProps.depth + 1} />}
121
+ </For>
122
+ </ul>
123
+ </Show>
124
+ </li>
125
+ )
126
+ }
127
+
128
+ return (
129
+ <div ref={root} class={cn('relative', props.class)}>
130
+ <button
131
+ type="button"
132
+ aria-haspopup="tree"
133
+ aria-expanded={open()}
134
+ onClick={() => setOpen((v) => !v)}
135
+ class="flex w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none transition-colors a4-field"
136
+ >
137
+ <Show when={selected()} fallback={<span class="text-muted-foreground">{props.placeholder}</span>}>
138
+ <span class="truncate">{selected()!.label}</span>
139
+ </Show>
140
+ <ChevronDown class="h-4 w-4 shrink-0 opacity-50" aria-hidden="true" />
141
+ </button>
142
+ <Show when={open()}>
143
+ <div class="z-50 mt-1 w-full rounded-md border border-border bg-card p-2 shadow-md absolute">
144
+ <ul role="tree" class="text-foreground">
145
+ <For each={props.nodes}>{(node) => <Node node={node} depth={0} />}</For>
146
+ </ul>
147
+ </div>
148
+ </Show>
149
+ </div>
150
+ )
151
+ }
@@ -0,0 +1,81 @@
1
+ // Virtualized list: only the visible rows (+ overscan) live in the DOM, so a
2
+ // list of any length renders and scrolls instantly. Wraps @tanstack/solid-virtual
3
+ // with the remeasureAfterLayout fix (the virtualizer otherwise caches a 0×0
4
+ // viewport when the scroll container isn't laid out at mount and never renders
5
+ // a row). The consumer owns the scroll container's HEIGHT — pass it via `class`
6
+ // (e.g. "h-[65vh]"); a fixed/constrained height is required for virtualization.
7
+ import { createVirtualizer } from '@tanstack/solid-virtual'
8
+ import { For, type Accessor, type JSX } from 'solid-js'
9
+ import { createSignal } from 'solid-js'
10
+
11
+ import { cn } from '../lib/cn'
12
+ import { remeasureAfterLayout } from '../lib/virtual'
13
+
14
+ interface VirtualListProps<T> {
15
+ /** The full data set. Only the visible slice is rendered. */
16
+ each: T[]
17
+ /** Estimated row size in px — a number, or a per-index function. */
18
+ estimateSize: number | ((index: number) => number)
19
+ /** Rows to render beyond the viewport on each side. Default 10. */
20
+ overscan?: number
21
+ /** Scroll-container classes. MUST constrain height (e.g. "h-[65vh]"). */
22
+ class?: string
23
+ /** Row renderer. `index` is an accessor so it stays reactive as rows recycle. */
24
+ children: (item: T, index: Accessor<number>) => JSX.Element
25
+ }
26
+
27
+ /**
28
+ * Virtualized list built on `@tanstack/solid-virtual` — renders only the
29
+ * visible rows (plus `overscan`) so lists of any length mount and scroll
30
+ * instantly. The scroll container's height MUST be constrained via `class`
31
+ * (e.g. `"h-[65vh]"`); virtualization requires a fixed viewport to measure
32
+ * against.
33
+ *
34
+ * @example
35
+ * ```tsx
36
+ * <VirtualList each={rows()} estimateSize={40} class="h-[65vh]">
37
+ * {(row, index) => <div>{index()}: {row.name}</div>}
38
+ * </VirtualList>
39
+ * ```
40
+ */
41
+ export function VirtualList<T>(props: VirtualListProps<T>): JSX.Element {
42
+ const [scrollEl, setScrollEl] = createSignal<HTMLElement>()
43
+ const virt = createVirtualizer({
44
+ get count() {
45
+ return props.each.length
46
+ },
47
+ getScrollElement: () => scrollEl() ?? null,
48
+ estimateSize: (i) =>
49
+ typeof props.estimateSize === 'function' ? props.estimateSize(i) : props.estimateSize,
50
+ get overscan() {
51
+ return props.overscan ?? 10
52
+ },
53
+ })
54
+ // eslint-disable-next-line solid/reactivity -- scrollEl is read inside the helper's onMount (a tracked scope)
55
+ remeasureAfterLayout(scrollEl, setScrollEl)
56
+
57
+ return (
58
+ <div ref={setScrollEl} class={cn('overflow-auto', props.class)}>
59
+ {/* Full-height spacer so the scrollbar reflects the whole list; each row is
60
+ absolutely positioned at its computed offset. */}
61
+ <div style={{ position: 'relative', width: '100%', height: `${virt.getTotalSize()}px` }}>
62
+ <For each={virt.getVirtualItems()}>
63
+ {(vi) => (
64
+ <div
65
+ style={{
66
+ position: 'absolute',
67
+ top: '0',
68
+ left: '0',
69
+ width: '100%',
70
+ height: `${vi.size}px`,
71
+ transform: `translateY(${vi.start}px)`,
72
+ }}
73
+ >
74
+ {props.children(props.each[vi.index], () => vi.index)}
75
+ </div>
76
+ )}
77
+ </For>
78
+ </div>
79
+ </div>
80
+ )
81
+ }