@a4ui/core 0.11.1 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -26
- package/dist/full.css +3042 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +8 -1
- package/src/charts/BarChart.tsx +55 -0
- package/src/charts/DonutChart.tsx +127 -0
- package/src/charts/Sparkline.tsx +101 -0
- package/src/charts/index.ts +5 -0
- package/src/commerce/CartLine.tsx +84 -0
- package/src/commerce/CartSummary.tsx +70 -0
- package/src/commerce/FilterGroup.tsx +70 -0
- package/src/commerce/PriceTag.tsx +62 -0
- package/src/commerce/ProductCard.tsx +121 -0
- package/src/commerce/ProductGrid.tsx +27 -0
- package/src/commerce/QuantityStepper.tsx +59 -0
- package/src/commerce/index.ts +10 -0
- package/src/elements.tsx +167 -0
- package/src/index.ts +166 -0
- package/src/layout/AppShell.tsx +102 -0
- package/src/layout/ChristmasBackground.tsx +177 -0
- package/src/layout/EffectsToggle.tsx +41 -0
- package/src/layout/NavGroup.tsx +45 -0
- package/src/layout/SnowScenery.tsx +76 -0
- package/src/layout/SpaceBackground.tsx +459 -0
- package/src/layout/ThemeToggle.tsx +40 -0
- package/src/layout/ThemedScenery.tsx +179 -0
- package/src/layout/sceneEffects.ts +49 -0
- package/src/lib/cn.ts +17 -0
- package/src/lib/effects.ts +71 -0
- package/src/lib/media.ts +27 -0
- package/src/lib/motion.ts +191 -0
- package/src/lib/theme.ts +113 -0
- package/src/lib/virtual.ts +33 -0
- package/src/styles/space.css +510 -0
- package/src/styles/tokens.css +213 -0
- package/src/themes/index.ts +102 -0
- package/src/themes/palettes.ts +368 -0
- package/src/ui/Accordion.tsx +65 -0
- package/src/ui/Affix.tsx +72 -0
- package/src/ui/Alert.tsx +55 -0
- package/src/ui/AlertDialog.tsx +58 -0
- package/src/ui/Anchor.tsx +100 -0
- package/src/ui/Avatar.tsx +34 -0
- package/src/ui/AvatarGroup.tsx +55 -0
- package/src/ui/BackToTop.tsx +51 -0
- package/src/ui/Badge.tsx +46 -0
- package/src/ui/BottomNavigation.tsx +65 -0
- package/src/ui/Breadcrumb.tsx +64 -0
- package/src/ui/Button.tsx +49 -0
- package/src/ui/Calendar.tsx +40 -0
- package/src/ui/CalendarHeatmap.tsx +180 -0
- package/src/ui/Card.tsx +84 -0
- package/src/ui/Carousel.tsx +121 -0
- package/src/ui/Cascader.tsx +158 -0
- package/src/ui/Checkbox.tsx +35 -0
- package/src/ui/Clock.tsx +221 -0
- package/src/ui/Collapse.tsx +48 -0
- package/src/ui/ColorPicker.tsx +89 -0
- package/src/ui/Combobox.tsx +67 -0
- package/src/ui/Command.tsx +158 -0
- package/src/ui/Comment.tsx +91 -0
- package/src/ui/ContextMenu.tsx +58 -0
- package/src/ui/Countdown.tsx +123 -0
- package/src/ui/DataGrid.tsx +177 -0
- package/src/ui/DateField.tsx +177 -0
- package/src/ui/DateRangePicker.tsx +204 -0
- package/src/ui/DateTimeField.tsx +68 -0
- package/src/ui/Descriptions.tsx +56 -0
- package/src/ui/Drawer.tsx +72 -0
- package/src/ui/Dropdown.tsx +71 -0
- package/src/ui/Dropzone.tsx +88 -0
- package/src/ui/Empty.tsx +53 -0
- package/src/ui/FileUpload.tsx +198 -0
- package/src/ui/FloatingActionButton.tsx +48 -0
- package/src/ui/Form.tsx +108 -0
- package/src/ui/Highlight.tsx +53 -0
- package/src/ui/HoverCard.tsx +42 -0
- package/src/ui/Image.tsx +66 -0
- package/src/ui/Input.tsx +38 -0
- package/src/ui/Kbd.tsx +25 -0
- package/src/ui/List.tsx +71 -0
- package/src/ui/Marquee.tsx +48 -0
- package/src/ui/Mentions.tsx +170 -0
- package/src/ui/Meter.tsx +57 -0
- package/src/ui/Modal.tsx +106 -0
- package/src/ui/MultiSelect.tsx +236 -0
- package/src/ui/NotificationCenter.tsx +103 -0
- package/src/ui/NumberInput.tsx +48 -0
- package/src/ui/PageHeader.tsx +60 -0
- package/src/ui/Pagination.tsx +62 -0
- package/src/ui/Popover.tsx +42 -0
- package/src/ui/Portal.tsx +34 -0
- package/src/ui/Progress.tsx +55 -0
- package/src/ui/RadioGroup.tsx +65 -0
- package/src/ui/Rating.tsx +98 -0
- package/src/ui/Result.tsx +75 -0
- package/src/ui/RingProgress.tsx +75 -0
- package/src/ui/SegmentedControl.tsx +61 -0
- package/src/ui/Select.tsx +55 -0
- package/src/ui/Separator.tsx +39 -0
- package/src/ui/Skeleton.tsx +23 -0
- package/src/ui/Slider.tsx +57 -0
- package/src/ui/Sortable.tsx +174 -0
- package/src/ui/SpeedDial.tsx +75 -0
- package/src/ui/Spinner.tsx +33 -0
- package/src/ui/Splitter.tsx +115 -0
- package/src/ui/Stat.tsx +74 -0
- package/src/ui/Stepper.tsx +134 -0
- package/src/ui/Switch.tsx +44 -0
- package/src/ui/Table.tsx +128 -0
- package/src/ui/Tabs.tsx +72 -0
- package/src/ui/TagInput.tsx +85 -0
- package/src/ui/Textarea.tsx +38 -0
- package/src/ui/TimeField.tsx +298 -0
- package/src/ui/Timeline.tsx +89 -0
- package/src/ui/Toast.tsx +68 -0
- package/src/ui/Toggle.tsx +41 -0
- package/src/ui/ToggleGroup.tsx +59 -0
- package/src/ui/Tooltip.tsx +42 -0
- package/src/ui/Tour.tsx +197 -0
- package/src/ui/Transfer.tsx +112 -0
- package/src/ui/Tree.tsx +100 -0
- package/src/ui/TreeSelect.tsx +151 -0
- package/src/ui/VirtualList.tsx +81 -0
- package/src/ui/internal/CalendarCore.tsx +276 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Infinite horizontal scroller that loops its children seamlessly.
|
|
2
|
+
import type { JSX, ParentProps } from 'solid-js'
|
|
3
|
+
|
|
4
|
+
import { cn } from '../lib/cn'
|
|
5
|
+
import { motionReduced } from '../lib/motion'
|
|
6
|
+
|
|
7
|
+
interface MarqueeProps extends ParentProps {
|
|
8
|
+
/** Seconds for one full loop of the track. Lower is faster. Defaults to `20`. */
|
|
9
|
+
speed?: number
|
|
10
|
+
/** Pause the scroll while the pointer hovers the strip. Defaults to `true`. */
|
|
11
|
+
pauseOnHover?: boolean
|
|
12
|
+
class?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A horizontally scrolling strip that loops its children seamlessly. The
|
|
17
|
+
* children are rendered twice inside a flex track; when the first copy has
|
|
18
|
+
* scrolled fully out of view the second copy sits exactly where the first
|
|
19
|
+
* began, so the animation restarts with no visible seam. Honors reduced
|
|
20
|
+
* motion (the track sits still) and, by default, pauses on hover.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```tsx
|
|
24
|
+
* <Marquee speed={30}>
|
|
25
|
+
* <span class="mx-4">A4ui</span>
|
|
26
|
+
* <span class="mx-4">SolidJS</span>
|
|
27
|
+
* <span class="mx-4">Tailwind</span>
|
|
28
|
+
* </Marquee>
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export function Marquee(props: MarqueeProps): JSX.Element {
|
|
32
|
+
const pauseOnHover = () => props.pauseOnHover ?? true
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div class={cn('overflow-hidden', props.class)}>
|
|
36
|
+
<style>{'@keyframes marquee{from{transform:translateX(0)}to{transform:translateX(-50%)}}'}</style>
|
|
37
|
+
<div
|
|
38
|
+
class={cn('flex w-max shrink-0', pauseOnHover() && 'hover:[animation-play-state:paused]')}
|
|
39
|
+
style={motionReduced() ? undefined : { animation: `marquee ${props.speed ?? 20}s linear infinite` }}
|
|
40
|
+
>
|
|
41
|
+
<div class="flex shrink-0">{props.children}</div>
|
|
42
|
+
<div class="flex shrink-0" aria-hidden="true">
|
|
43
|
+
{props.children}
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Multi-line text field with @mention autocomplete over a native <textarea>.
|
|
2
|
+
import type { JSX } from 'solid-js'
|
|
3
|
+
import { For, Show, createSignal } from 'solid-js'
|
|
4
|
+
|
|
5
|
+
import { cn } from '../lib/cn'
|
|
6
|
+
|
|
7
|
+
export interface MentionOption {
|
|
8
|
+
value: string
|
|
9
|
+
label: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface MentionsProps {
|
|
13
|
+
value: string
|
|
14
|
+
/** Called with the new string value on every input (not the raw DOM event). */
|
|
15
|
+
onChange: (value: string) => void
|
|
16
|
+
/** Options offered while typing an `@mention`. */
|
|
17
|
+
options: MentionOption[]
|
|
18
|
+
placeholder?: string
|
|
19
|
+
class?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const TEXTAREA_BASE =
|
|
23
|
+
'w-full min-h-[80px] resize-y rounded-md border border-input bg-background text-foreground placeholder:text-muted-foreground px-3 py-2 text-sm outline-none transition-colors a4-field disabled:cursor-not-allowed disabled:opacity-50'
|
|
24
|
+
|
|
25
|
+
/** Match an active `@query` immediately before the caret. */
|
|
26
|
+
const MENTION_RE = /@(\w*)$/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Controlled multi-line text field that shows an autocomplete dropdown while
|
|
30
|
+
* the user types an `@mention`. On each input, the text before the caret is
|
|
31
|
+
* checked for a trailing `@query`; matching {@link MentionOption}s (by `label`
|
|
32
|
+
* or `value`, case-insensitive) are offered in a panel. Selecting one replaces
|
|
33
|
+
* the `@query` with `@{label} `. Arrow keys move the highlight, Enter accepts,
|
|
34
|
+
* Escape dismisses.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```tsx
|
|
38
|
+
* const [body, setBody] = createSignal('')
|
|
39
|
+
* <Mentions
|
|
40
|
+
* value={body()}
|
|
41
|
+
* onChange={setBody}
|
|
42
|
+
* options={[
|
|
43
|
+
* { value: 'ada', label: 'Ada Lovelace' },
|
|
44
|
+
* { value: 'alan', label: 'Alan Turing' },
|
|
45
|
+
* ]}
|
|
46
|
+
* placeholder="Write a note, use @ to mention someone"
|
|
47
|
+
* />
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export function Mentions(props: MentionsProps): JSX.Element {
|
|
51
|
+
let ref: HTMLTextAreaElement | undefined
|
|
52
|
+
const [open, setOpen] = createSignal(false)
|
|
53
|
+
const [query, setQuery] = createSignal('')
|
|
54
|
+
const [caret, setCaret] = createSignal(0)
|
|
55
|
+
const [highlighted, setHighlighted] = createSignal(0)
|
|
56
|
+
|
|
57
|
+
const filtered = (): MentionOption[] => {
|
|
58
|
+
const q = query().toLowerCase()
|
|
59
|
+
return props.options.filter((o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const close = () => {
|
|
63
|
+
setOpen(false)
|
|
64
|
+
setQuery('')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const sync = (el: HTMLTextAreaElement) => {
|
|
68
|
+
const pos = el.selectionStart ?? el.value.length
|
|
69
|
+
setCaret(pos)
|
|
70
|
+
const before = el.value.slice(0, pos)
|
|
71
|
+
const match = before.match(MENTION_RE)
|
|
72
|
+
if (match) {
|
|
73
|
+
setQuery(match[1])
|
|
74
|
+
setOpen(true)
|
|
75
|
+
setHighlighted(0)
|
|
76
|
+
} else {
|
|
77
|
+
close()
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const select = (option: MentionOption) => {
|
|
82
|
+
const el = ref
|
|
83
|
+
const full = props.value
|
|
84
|
+
const pos = el ? (el.selectionStart ?? caret()) : caret()
|
|
85
|
+
const before = full.slice(0, pos)
|
|
86
|
+
const after = full.slice(pos)
|
|
87
|
+
const start = before.lastIndexOf('@')
|
|
88
|
+
if (start === -1) {
|
|
89
|
+
close()
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
const insert = `@${option.label} `
|
|
93
|
+
const next = full.slice(0, start) + insert + after
|
|
94
|
+
props.onChange(next)
|
|
95
|
+
close()
|
|
96
|
+
if (el) {
|
|
97
|
+
const nextCaret = start + insert.length
|
|
98
|
+
queueMicrotask(() => {
|
|
99
|
+
el.focus()
|
|
100
|
+
el.setSelectionRange(nextCaret, nextCaret)
|
|
101
|
+
setCaret(nextCaret)
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const onKeyDown = (ev: KeyboardEvent) => {
|
|
107
|
+
if (!open()) return
|
|
108
|
+
const items = filtered()
|
|
109
|
+
if (items.length === 0) return
|
|
110
|
+
switch (ev.key) {
|
|
111
|
+
case 'ArrowDown':
|
|
112
|
+
ev.preventDefault()
|
|
113
|
+
setHighlighted((i) => (i + 1) % items.length)
|
|
114
|
+
break
|
|
115
|
+
case 'ArrowUp':
|
|
116
|
+
ev.preventDefault()
|
|
117
|
+
setHighlighted((i) => (i - 1 + items.length) % items.length)
|
|
118
|
+
break
|
|
119
|
+
case 'Enter':
|
|
120
|
+
ev.preventDefault()
|
|
121
|
+
select(items[Math.min(highlighted(), items.length - 1)])
|
|
122
|
+
break
|
|
123
|
+
case 'Escape':
|
|
124
|
+
ev.preventDefault()
|
|
125
|
+
close()
|
|
126
|
+
break
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<div class={cn('relative', props.class)}>
|
|
132
|
+
<textarea
|
|
133
|
+
ref={ref}
|
|
134
|
+
class={TEXTAREA_BASE}
|
|
135
|
+
value={props.value}
|
|
136
|
+
placeholder={props.placeholder}
|
|
137
|
+
onInput={(ev) => {
|
|
138
|
+
props.onChange(ev.currentTarget.value)
|
|
139
|
+
sync(ev.currentTarget)
|
|
140
|
+
}}
|
|
141
|
+
onClick={(ev) => sync(ev.currentTarget)}
|
|
142
|
+
onKeyUp={(ev) => sync(ev.currentTarget)}
|
|
143
|
+
onKeyDown={onKeyDown}
|
|
144
|
+
onBlur={() => close()}
|
|
145
|
+
/>
|
|
146
|
+
<Show when={open() && filtered().length > 0}>
|
|
147
|
+
<div class="absolute z-50 mt-1 max-h-48 w-56 overflow-y-auto rounded-md border border-border bg-card p-1 shadow-md">
|
|
148
|
+
<For each={filtered()}>
|
|
149
|
+
{(option, i) => (
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
class={cn(
|
|
153
|
+
'w-full rounded px-2 py-1.5 text-left text-sm hover:bg-muted',
|
|
154
|
+
i() === highlighted() && 'bg-muted',
|
|
155
|
+
)}
|
|
156
|
+
onMouseDown={(ev) => {
|
|
157
|
+
ev.preventDefault()
|
|
158
|
+
select(option)
|
|
159
|
+
}}
|
|
160
|
+
onMouseEnter={() => setHighlighted(i())}
|
|
161
|
+
>
|
|
162
|
+
{option.label}
|
|
163
|
+
</button>
|
|
164
|
+
)}
|
|
165
|
+
</For>
|
|
166
|
+
</div>
|
|
167
|
+
</Show>
|
|
168
|
+
</div>
|
|
169
|
+
)
|
|
170
|
+
}
|
package/src/ui/Meter.tsx
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Static measurement gauge on Kobalte's Meter primitive (e.g. disk usage).
|
|
2
|
+
import { Meter as KMeter } from '@kobalte/core/meter'
|
|
3
|
+
import type { JSX } from 'solid-js'
|
|
4
|
+
import { Show } from 'solid-js'
|
|
5
|
+
|
|
6
|
+
import { cn } from '../lib/cn'
|
|
7
|
+
|
|
8
|
+
interface MeterProps {
|
|
9
|
+
value: number
|
|
10
|
+
/** @default 100 */
|
|
11
|
+
max?: number
|
|
12
|
+
/** Label shown above the track, paired with an auto-formatted value label. */
|
|
13
|
+
label?: string
|
|
14
|
+
class?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Static measurement gauge (e.g. disk/quota usage) on Kobalte's `Meter` primitive.
|
|
19
|
+
* Unlike a progress bar, a meter represents a fixed measurement against a range,
|
|
20
|
+
* not an in-flight operation.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```tsx
|
|
24
|
+
* <Meter value={72} max={100} label="Storage used" />
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export function Meter(props: MeterProps): JSX.Element {
|
|
28
|
+
// Kobalte's Meter.Fill has no intrinsic width — we set it ourselves from the
|
|
29
|
+
// value as a fraction of the range, clamped to 0–100%. (Without this the bar
|
|
30
|
+
// ignored `max`, so e.g. value 38 of max 50 read "76%" but filled only ~38%.)
|
|
31
|
+
const percent = () => {
|
|
32
|
+
const max = props.max ?? 100
|
|
33
|
+
if (max <= 0) return 0
|
|
34
|
+
return Math.max(0, Math.min(100, (props.value / max) * 100))
|
|
35
|
+
}
|
|
36
|
+
return (
|
|
37
|
+
<KMeter
|
|
38
|
+
value={props.value}
|
|
39
|
+
minValue={0}
|
|
40
|
+
maxValue={props.max ?? 100}
|
|
41
|
+
class={cn('flex flex-col gap-1.5', props.class)}
|
|
42
|
+
>
|
|
43
|
+
<Show when={props.label}>
|
|
44
|
+
<div class="flex items-center justify-between text-sm text-foreground">
|
|
45
|
+
<KMeter.Label>{props.label}</KMeter.Label>
|
|
46
|
+
<KMeter.ValueLabel class="text-muted-foreground" />
|
|
47
|
+
</div>
|
|
48
|
+
</Show>
|
|
49
|
+
<KMeter.Track class="h-2 overflow-hidden rounded-full bg-muted">
|
|
50
|
+
<KMeter.Fill
|
|
51
|
+
class="h-full rounded-full bg-primary transition-all duration-500"
|
|
52
|
+
style={{ width: `${percent()}%` }}
|
|
53
|
+
/>
|
|
54
|
+
</KMeter.Track>
|
|
55
|
+
</KMeter>
|
|
56
|
+
)
|
|
57
|
+
}
|
package/src/ui/Modal.tsx
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Dialog on Kobalte's Dialog primitive. Two shapes via `variant`:
|
|
2
|
+
// - 'drawer' (default): right-anchored slide-over — create/edit forms, detail
|
|
3
|
+
// panels (more vertical room, less jarring than a centered pop).
|
|
4
|
+
// - 'center': the classic centered modal — short confirmations (destructive
|
|
5
|
+
// yes/no) where a full-height drawer would be overkill.
|
|
6
|
+
// The .drawer-content / .modal-content classes drive the slide/scale keyframes
|
|
7
|
+
// (styles.css) off Kobalte's data-expanded/data-closed presence tracking.
|
|
8
|
+
import { Dialog } from '@kobalte/core/dialog'
|
|
9
|
+
import { X } from 'lucide-solid'
|
|
10
|
+
import type { JSX, ParentProps } from 'solid-js'
|
|
11
|
+
import { Show } from 'solid-js'
|
|
12
|
+
|
|
13
|
+
import { cn } from '../lib/cn'
|
|
14
|
+
|
|
15
|
+
interface ModalProps extends ParentProps {
|
|
16
|
+
open: boolean
|
|
17
|
+
onOpenChange: (open: boolean) => void
|
|
18
|
+
/** Header title; omitting it hides the whole header bar. */
|
|
19
|
+
title?: string
|
|
20
|
+
/**
|
|
21
|
+
* `'drawer'` (default): right-anchored slide-over, more room, less jarring —
|
|
22
|
+
* use for forms/detail panels. `'center'`: classic centered modal — use for
|
|
23
|
+
* short confirmations (e.g. destructive yes/no).
|
|
24
|
+
* @default 'drawer'
|
|
25
|
+
*/
|
|
26
|
+
variant?: 'drawer' | 'center'
|
|
27
|
+
class?: string
|
|
28
|
+
/** Accessible label for the close button. @default 'Close' */
|
|
29
|
+
closeLabel?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Dialog on Kobalte's `Dialog` primitive (focus trap, portal, presence handled
|
|
34
|
+
* for you), rendered as either a slide-over drawer or a centered modal depending
|
|
35
|
+
* on `variant`.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```tsx
|
|
39
|
+
* const [open, setOpen] = createSignal(false)
|
|
40
|
+
* <Modal open={open()} onOpenChange={setOpen} variant="center" title="Delete item?">
|
|
41
|
+
* <p>This action cannot be undone.</p>
|
|
42
|
+
* </Modal>
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export function Modal(props: ModalProps): JSX.Element {
|
|
46
|
+
const isCenter = () => props.variant === 'center'
|
|
47
|
+
return (
|
|
48
|
+
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
|
|
49
|
+
<Dialog.Portal>
|
|
50
|
+
<Dialog.Overlay class="modal-overlay fixed inset-0 z-40 bg-background/80 backdrop-blur-sm" />
|
|
51
|
+
<Show
|
|
52
|
+
when={isCenter()}
|
|
53
|
+
fallback={
|
|
54
|
+
<div class="fixed inset-0 z-50 flex justify-end">
|
|
55
|
+
<Dialog.Content
|
|
56
|
+
class={cn(
|
|
57
|
+
'drawer-content bg-glass flex h-full w-full max-w-[520px] flex-col overflow-y-auto border-l border-border text-card-foreground shadow-2xl',
|
|
58
|
+
props.class,
|
|
59
|
+
)}
|
|
60
|
+
>
|
|
61
|
+
<Show when={props.title}>
|
|
62
|
+
<div class="sticky top-0 z-10 flex items-center justify-between border-b border-border bg-background/85 px-6 py-4 backdrop-blur">
|
|
63
|
+
<Dialog.Title class="text-lg font-semibold leading-none tracking-tight">
|
|
64
|
+
{props.title}
|
|
65
|
+
</Dialog.Title>
|
|
66
|
+
<Dialog.CloseButton
|
|
67
|
+
class="grid h-8 w-8 place-items-center rounded-lg text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
|
68
|
+
aria-label={props.closeLabel ?? 'Close'}
|
|
69
|
+
>
|
|
70
|
+
<X class="h-5 w-5" />
|
|
71
|
+
</Dialog.CloseButton>
|
|
72
|
+
</div>
|
|
73
|
+
</Show>
|
|
74
|
+
<div class="p-6 pt-4">{props.children}</div>
|
|
75
|
+
</Dialog.Content>
|
|
76
|
+
</div>
|
|
77
|
+
}
|
|
78
|
+
>
|
|
79
|
+
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
80
|
+
<Dialog.Content
|
|
81
|
+
class={cn(
|
|
82
|
+
'modal-content w-full max-w-lg rounded-xl border border-border bg-card text-card-foreground shadow-sm',
|
|
83
|
+
props.class,
|
|
84
|
+
)}
|
|
85
|
+
>
|
|
86
|
+
<Show when={props.title}>
|
|
87
|
+
<div class="flex items-center justify-between border-b border-border p-6 pb-4">
|
|
88
|
+
<Dialog.Title class="text-lg font-semibold leading-none tracking-tight">
|
|
89
|
+
{props.title}
|
|
90
|
+
</Dialog.Title>
|
|
91
|
+
<Dialog.CloseButton
|
|
92
|
+
class="text-muted-foreground hover:text-foreground"
|
|
93
|
+
aria-label={props.closeLabel ?? 'Close'}
|
|
94
|
+
>
|
|
95
|
+
✕
|
|
96
|
+
</Dialog.CloseButton>
|
|
97
|
+
</div>
|
|
98
|
+
</Show>
|
|
99
|
+
<div class="p-6 pt-4">{props.children}</div>
|
|
100
|
+
</Dialog.Content>
|
|
101
|
+
</div>
|
|
102
|
+
</Show>
|
|
103
|
+
</Dialog.Portal>
|
|
104
|
+
</Dialog>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Multi-select dropdown — searchable options with removable chips on the
|
|
2
|
+
// trigger. Popover mechanics (portaled, fixed-position, outside-click/Escape/
|
|
3
|
+
// scroll/resize close) are copied verbatim from DateField.tsx: no Kobalte
|
|
4
|
+
// primitive covers a chip-trigger multi-select, so this is hand-rolled in
|
|
5
|
+
// plain Solid + theme tokens.
|
|
6
|
+
import { Check, ChevronDown, X } from 'lucide-solid'
|
|
7
|
+
import type { JSX } from 'solid-js'
|
|
8
|
+
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from 'solid-js'
|
|
9
|
+
import { Portal } from 'solid-js/web'
|
|
10
|
+
|
|
11
|
+
import { cn } from '../lib/cn'
|
|
12
|
+
|
|
13
|
+
/** One selectable option: a stable `value` and its display `label`. */
|
|
14
|
+
export interface MultiSelectOption {
|
|
15
|
+
value: string
|
|
16
|
+
label: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface MultiSelectProps {
|
|
20
|
+
options: MultiSelectOption[]
|
|
21
|
+
value: string[]
|
|
22
|
+
onChange: (value: string[]) => void
|
|
23
|
+
placeholder?: string
|
|
24
|
+
/** Show a search box in the dropdown. Default `true`. */
|
|
25
|
+
searchable?: boolean
|
|
26
|
+
disabled?: boolean
|
|
27
|
+
class?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Multi-select dropdown: the trigger shows selected options as removable
|
|
32
|
+
* chips, and the portaled popover lists (optionally search-filtered) options
|
|
33
|
+
* to toggle. Picking an option keeps the popover open so several values can
|
|
34
|
+
* be chosen in a row; the popover closes on outside click, Escape, scroll, or
|
|
35
|
+
* resize.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```tsx
|
|
39
|
+
* const [tags, setTags] = createSignal<string[]>([])
|
|
40
|
+
* <MultiSelect
|
|
41
|
+
* options={[{ value: 'a', label: 'Alpha' }, { value: 'b', label: 'Beta' }]}
|
|
42
|
+
* value={tags()}
|
|
43
|
+
* onChange={setTags}
|
|
44
|
+
* placeholder="Select tags…"
|
|
45
|
+
* />
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export function MultiSelect(props: MultiSelectProps): JSX.Element {
|
|
49
|
+
const [open, setOpen] = createSignal(false)
|
|
50
|
+
const [query, setQuery] = createSignal('')
|
|
51
|
+
|
|
52
|
+
let rootRef: HTMLDivElement | undefined
|
|
53
|
+
let btnRef: HTMLButtonElement | undefined
|
|
54
|
+
let popRef: HTMLDivElement | undefined
|
|
55
|
+
let searchRef: HTMLInputElement | undefined
|
|
56
|
+
|
|
57
|
+
// Portaled popovers escape the trigger's stacking context (e.g. a glass
|
|
58
|
+
// card's backdrop-filter), so they can't be `absolute`-positioned relative
|
|
59
|
+
// to it — place them with `fixed` at the trigger's measured viewport rect,
|
|
60
|
+
// same as DateField.
|
|
61
|
+
const [pos, setPos] = createSignal({ top: 0, left: 0, width: 224 })
|
|
62
|
+
const MIN_W = 224 // min-w-56
|
|
63
|
+
|
|
64
|
+
const place = () => {
|
|
65
|
+
if (!btnRef) return
|
|
66
|
+
const r = btnRef.getBoundingClientRect()
|
|
67
|
+
const width = Math.max(r.width, MIN_W)
|
|
68
|
+
const left = Math.max(8, Math.min(r.left, window.innerWidth - width - 8))
|
|
69
|
+
setPos({ top: r.bottom + 4, left, width })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const openPopover = () => {
|
|
73
|
+
if (props.disabled) return
|
|
74
|
+
place()
|
|
75
|
+
setOpen(true)
|
|
76
|
+
setQuery('')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const close = () => setOpen(false)
|
|
80
|
+
|
|
81
|
+
const onDocPointer = (ev: MouseEvent) => {
|
|
82
|
+
const t = ev.target as Node
|
|
83
|
+
// Ignore clicks on the trigger (rootRef) AND the portaled popover (popRef).
|
|
84
|
+
if (rootRef && !rootRef.contains(t) && popRef && !popRef.contains(t)) close()
|
|
85
|
+
}
|
|
86
|
+
const onKey = (ev: KeyboardEvent) => {
|
|
87
|
+
if (ev.key === 'Escape') close()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Wire/tear-down the global listeners strictly around the open state. Scroll/
|
|
91
|
+
// resize close the popover (its fixed position would otherwise drift).
|
|
92
|
+
const toggleListeners = (isOpen: boolean) => {
|
|
93
|
+
if (isOpen) {
|
|
94
|
+
document.addEventListener('mousedown', onDocPointer)
|
|
95
|
+
document.addEventListener('keydown', onKey)
|
|
96
|
+
window.addEventListener('scroll', close, true)
|
|
97
|
+
window.addEventListener('resize', close)
|
|
98
|
+
} else {
|
|
99
|
+
document.removeEventListener('mousedown', onDocPointer)
|
|
100
|
+
document.removeEventListener('keydown', onKey)
|
|
101
|
+
window.removeEventListener('scroll', close, true)
|
|
102
|
+
window.removeEventListener('resize', close)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// Reactively attach when open flips; always clean up on unmount.
|
|
106
|
+
createEffect(() => toggleListeners(open()))
|
|
107
|
+
onCleanup(() => toggleListeners(false))
|
|
108
|
+
|
|
109
|
+
// Autofocus the search box once the popover mounts.
|
|
110
|
+
createEffect(() => {
|
|
111
|
+
if (open() && (props.searchable ?? true)) {
|
|
112
|
+
searchRef?.focus()
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
const selectedOptions = createMemo(() => props.options.filter((o) => props.value.includes(o.value)))
|
|
117
|
+
|
|
118
|
+
const filteredOptions = createMemo(() => {
|
|
119
|
+
const q = query().trim().toLowerCase()
|
|
120
|
+
if (!q) return props.options
|
|
121
|
+
return props.options.filter((o) => o.label.toLowerCase().includes(q))
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const toggleValue = (value: string) => {
|
|
125
|
+
const next = props.value.includes(value)
|
|
126
|
+
? props.value.filter((v) => v !== value)
|
|
127
|
+
: [...props.value, value]
|
|
128
|
+
props.onChange(next)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const removeValue = (ev: MouseEvent, value: string) => {
|
|
132
|
+
ev.stopPropagation()
|
|
133
|
+
props.onChange(props.value.filter((v) => v !== value))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<div ref={rootRef} class={cn('relative', props.class)}>
|
|
138
|
+
<button
|
|
139
|
+
ref={btnRef}
|
|
140
|
+
type="button"
|
|
141
|
+
disabled={props.disabled}
|
|
142
|
+
aria-haspopup="listbox"
|
|
143
|
+
aria-expanded={open()}
|
|
144
|
+
onClick={() => (open() ? close() : openPopover())}
|
|
145
|
+
class="flex min-h-9 h-auto w-full flex-wrap 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"
|
|
146
|
+
>
|
|
147
|
+
<span class="flex flex-1 flex-wrap items-center gap-1">
|
|
148
|
+
<Show
|
|
149
|
+
when={selectedOptions().length > 0}
|
|
150
|
+
fallback={<span class="text-muted-foreground">{props.placeholder ?? 'Select…'}</span>}
|
|
151
|
+
>
|
|
152
|
+
<For each={selectedOptions()}>
|
|
153
|
+
{(opt) => (
|
|
154
|
+
<span class="inline-flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 text-xs text-foreground">
|
|
155
|
+
{opt.label}
|
|
156
|
+
<button
|
|
157
|
+
type="button"
|
|
158
|
+
aria-label={`Remove ${opt.label}`}
|
|
159
|
+
onClick={(ev) => removeValue(ev, opt.value)}
|
|
160
|
+
class="rounded-sm text-muted-foreground outline-none transition-colors hover:text-foreground focus:ring-2 focus:ring-ring/30"
|
|
161
|
+
>
|
|
162
|
+
<X class="h-3 w-3" />
|
|
163
|
+
</button>
|
|
164
|
+
</span>
|
|
165
|
+
)}
|
|
166
|
+
</For>
|
|
167
|
+
</Show>
|
|
168
|
+
</span>
|
|
169
|
+
<ChevronDown class="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
170
|
+
</button>
|
|
171
|
+
|
|
172
|
+
<Show when={open()}>
|
|
173
|
+
<Portal>
|
|
174
|
+
<div
|
|
175
|
+
ref={popRef}
|
|
176
|
+
style={{
|
|
177
|
+
position: 'fixed',
|
|
178
|
+
top: `${pos().top}px`,
|
|
179
|
+
left: `${pos().left}px`,
|
|
180
|
+
width: `${pos().width}px`,
|
|
181
|
+
}}
|
|
182
|
+
class="z-50 min-w-56 rounded-lg border border-border bg-card p-1 text-card-foreground shadow-lg"
|
|
183
|
+
>
|
|
184
|
+
<Show when={props.searchable ?? true}>
|
|
185
|
+
<input
|
|
186
|
+
ref={searchRef}
|
|
187
|
+
type="text"
|
|
188
|
+
value={query()}
|
|
189
|
+
onInput={(ev) => setQuery(ev.currentTarget.value)}
|
|
190
|
+
placeholder="Search…"
|
|
191
|
+
class="mb-1 w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm text-foreground outline-none a4-field"
|
|
192
|
+
/>
|
|
193
|
+
</Show>
|
|
194
|
+
<ul role="listbox" aria-multiselectable="true" class="max-h-60 overflow-y-auto">
|
|
195
|
+
<For
|
|
196
|
+
each={filteredOptions()}
|
|
197
|
+
fallback={<li class="px-2 py-1.5 text-sm text-muted-foreground">No options</li>}
|
|
198
|
+
>
|
|
199
|
+
{(opt) => {
|
|
200
|
+
const isSelected = () => props.value.includes(opt.value)
|
|
201
|
+
return (
|
|
202
|
+
<li role="none">
|
|
203
|
+
<button
|
|
204
|
+
type="button"
|
|
205
|
+
role="option"
|
|
206
|
+
aria-selected={isSelected()}
|
|
207
|
+
aria-pressed={isSelected()}
|
|
208
|
+
onClick={() => toggleValue(opt.value)}
|
|
209
|
+
class={cn(
|
|
210
|
+
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm outline-none transition-colors hover:bg-muted focus:bg-muted',
|
|
211
|
+
isSelected() && 'bg-primary/10',
|
|
212
|
+
)}
|
|
213
|
+
>
|
|
214
|
+
<span
|
|
215
|
+
class={cn(
|
|
216
|
+
'flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border border-input',
|
|
217
|
+
isSelected() && 'border-primary bg-primary text-primary-foreground',
|
|
218
|
+
)}
|
|
219
|
+
>
|
|
220
|
+
<Show when={isSelected()}>
|
|
221
|
+
<Check class="h-3 w-3" />
|
|
222
|
+
</Show>
|
|
223
|
+
</span>
|
|
224
|
+
<span class="truncate">{opt.label}</span>
|
|
225
|
+
</button>
|
|
226
|
+
</li>
|
|
227
|
+
)
|
|
228
|
+
}}
|
|
229
|
+
</For>
|
|
230
|
+
</ul>
|
|
231
|
+
</div>
|
|
232
|
+
</Portal>
|
|
233
|
+
</Show>
|
|
234
|
+
</div>
|
|
235
|
+
)
|
|
236
|
+
}
|