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