@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,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
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// A status pill that smoothly morphs between idle/loading/success/error: the
|
|
2
|
+
// background/text color tweens via CSS `transition-colors` while the icon and
|
|
3
|
+
// label cross-fade, and each state change gets a spring "pop" via Motion
|
|
4
|
+
// (motion.dev's "multi-state badge" pattern) — skipped under reduced motion.
|
|
5
|
+
import { createEffect, on, Switch, Match, type JSX } from 'solid-js'
|
|
6
|
+
import { Loader2, Check, X } from 'lucide-solid'
|
|
7
|
+
|
|
8
|
+
import { cn } from '../lib/cn'
|
|
9
|
+
import { motionReduced, animate } from '../lib/motion'
|
|
10
|
+
|
|
11
|
+
/** The lifecycle state a {@link MultiStateBadge} can be in. */
|
|
12
|
+
export type BadgeState = 'idle' | 'loading' | 'success' | 'error'
|
|
13
|
+
|
|
14
|
+
export interface MultiStateBadgeProps {
|
|
15
|
+
/** Current lifecycle state; drives color, icon, and label. */
|
|
16
|
+
state: BadgeState
|
|
17
|
+
/** Override the per-state text. Defaults: idle→'Ready', loading→'Working…', success→'Done', error→'Failed'. */
|
|
18
|
+
labels?: Partial<Record<BadgeState, string>>
|
|
19
|
+
class?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DEFAULT_LABELS: Record<BadgeState, string> = {
|
|
23
|
+
idle: 'Ready',
|
|
24
|
+
loading: 'Working…',
|
|
25
|
+
success: 'Done',
|
|
26
|
+
error: 'Failed',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const STATE_CLASSES: Record<BadgeState, string> = {
|
|
30
|
+
idle: 'bg-muted text-muted-foreground',
|
|
31
|
+
loading: 'bg-primary/15 text-primary',
|
|
32
|
+
success: 'bg-green-500/15 text-green-600',
|
|
33
|
+
error: 'bg-destructive/15 text-destructive',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Status badge that animates between `idle` → `loading` → `success` → `error`,
|
|
38
|
+
* morphing its color and swapping its icon/label on each transition. Colors
|
|
39
|
+
* tween via a CSS `transition-colors` class; the pill also gets a spring
|
|
40
|
+
* "pop" and the icon/label cross-fade via Motion's `animate`, both skipped
|
|
41
|
+
* when {@link motionReduced} is true.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```tsx
|
|
45
|
+
* <MultiStateBadge state={saveState()} labels={{ error: 'Save failed' }} />
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export function MultiStateBadge(props: MultiStateBadgeProps): JSX.Element {
|
|
49
|
+
let rootEl: HTMLSpanElement | undefined
|
|
50
|
+
let contentEl: HTMLSpanElement | undefined
|
|
51
|
+
|
|
52
|
+
const label = () => props.labels?.[props.state] ?? DEFAULT_LABELS[props.state]
|
|
53
|
+
|
|
54
|
+
createEffect(
|
|
55
|
+
on(
|
|
56
|
+
() => props.state,
|
|
57
|
+
() => {
|
|
58
|
+
if (motionReduced()) return
|
|
59
|
+
if (rootEl) animate(rootEl, { scale: [0.9, 1] }, { type: 'spring', stiffness: 500, damping: 20 })
|
|
60
|
+
if (contentEl) animate(contentEl, { opacity: [0, 1] }, { duration: 0.2, ease: 'easeOut' })
|
|
61
|
+
},
|
|
62
|
+
{ defer: true },
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<span
|
|
68
|
+
ref={rootEl}
|
|
69
|
+
role="status"
|
|
70
|
+
aria-live="polite"
|
|
71
|
+
class={cn(
|
|
72
|
+
'inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-medium transition-colors',
|
|
73
|
+
STATE_CLASSES[props.state],
|
|
74
|
+
props.class,
|
|
75
|
+
)}
|
|
76
|
+
>
|
|
77
|
+
<span ref={contentEl} class="inline-flex items-center gap-1.5">
|
|
78
|
+
<Switch>
|
|
79
|
+
<Match when={props.state === 'loading'}>
|
|
80
|
+
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
|
81
|
+
</Match>
|
|
82
|
+
<Match when={props.state === 'success'}>
|
|
83
|
+
<Check class="h-3.5 w-3.5" />
|
|
84
|
+
</Match>
|
|
85
|
+
<Match when={props.state === 'error'}>
|
|
86
|
+
<X class="h-3.5 w-3.5" />
|
|
87
|
+
</Match>
|
|
88
|
+
</Switch>
|
|
89
|
+
<span>{label()}</span>
|
|
90
|
+
</span>
|
|
91
|
+
</span>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Bell-triggered notification panel built on A4ui's Popover primitive.
|
|
2
|
+
import { Bell, X } from 'lucide-solid'
|
|
3
|
+
import { For, type JSX, Show } from 'solid-js'
|
|
4
|
+
|
|
5
|
+
import { cn } from '../lib/cn'
|
|
6
|
+
import { Popover } from './Popover'
|
|
7
|
+
|
|
8
|
+
/** A single entry rendered by {@link NotificationCenter}. */
|
|
9
|
+
export interface NotificationItem {
|
|
10
|
+
id: string
|
|
11
|
+
title: string
|
|
12
|
+
description?: string
|
|
13
|
+
time?: string
|
|
14
|
+
read?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface NotificationCenterProps {
|
|
18
|
+
/** Notifications to display, newest first. */
|
|
19
|
+
items: NotificationItem[]
|
|
20
|
+
/** Called with an item's id when its dismiss button is clicked. */
|
|
21
|
+
onDismiss?: (id: string) => void
|
|
22
|
+
/** Called when the "Mark all read" action is clicked. */
|
|
23
|
+
onMarkAllRead?: () => void
|
|
24
|
+
class?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Bell button that opens a popover listing notifications, with an unread count
|
|
29
|
+
* badge, per-item dismiss, and a "mark all read" action. Built on {@link Popover}.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```tsx
|
|
33
|
+
* <NotificationCenter
|
|
34
|
+
* items={items}
|
|
35
|
+
* onDismiss={(id) => remove(id)}
|
|
36
|
+
* onMarkAllRead={() => markAll()}
|
|
37
|
+
* />
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export function NotificationCenter(props: NotificationCenterProps): JSX.Element {
|
|
41
|
+
const unread = () => props.items.filter((item) => !item.read).length
|
|
42
|
+
|
|
43
|
+
const trigger = (
|
|
44
|
+
<span class={cn('relative inline-grid place-items-center rounded-md p-2 text-foreground', props.class)}>
|
|
45
|
+
<Bell class="h-5 w-5" />
|
|
46
|
+
<Show when={unread() > 0}>
|
|
47
|
+
<span class="absolute -right-1 -top-1 grid h-4 min-w-4 place-items-center rounded-full bg-primary px-1 text-[10px] text-primary-foreground">
|
|
48
|
+
{unread()}
|
|
49
|
+
</span>
|
|
50
|
+
</Show>
|
|
51
|
+
</span>
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<Popover trigger={trigger} class="w-80 p-0">
|
|
56
|
+
<div class="flex items-center justify-between border-b border-border px-4 py-3">
|
|
57
|
+
<span class="text-sm font-semibold text-foreground">Notifications</span>
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
class="text-xs font-medium text-primary hover:underline"
|
|
61
|
+
onClick={() => props.onMarkAllRead?.()}
|
|
62
|
+
>
|
|
63
|
+
Mark all read
|
|
64
|
+
</button>
|
|
65
|
+
</div>
|
|
66
|
+
<Show
|
|
67
|
+
when={props.items.length > 0}
|
|
68
|
+
fallback={<p class="px-4 py-8 text-center text-sm text-muted-foreground">You're all caught up</p>}
|
|
69
|
+
>
|
|
70
|
+
<ul class="max-h-96 divide-y divide-border overflow-y-auto">
|
|
71
|
+
<For each={props.items}>
|
|
72
|
+
{(item) => (
|
|
73
|
+
<li class="flex items-start gap-2 px-4 py-3">
|
|
74
|
+
<div class="min-w-0 flex-1">
|
|
75
|
+
<p class="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
|
76
|
+
<Show when={!item.read}>
|
|
77
|
+
<span class="h-1.5 w-1.5 shrink-0 rounded-full bg-primary" />
|
|
78
|
+
</Show>
|
|
79
|
+
<span class="truncate">{item.title}</span>
|
|
80
|
+
</p>
|
|
81
|
+
<Show when={item.description}>
|
|
82
|
+
<p class="mt-0.5 text-xs text-muted-foreground">{item.description}</p>
|
|
83
|
+
</Show>
|
|
84
|
+
<Show when={item.time}>
|
|
85
|
+
<p class="mt-1 text-[11px] text-muted-foreground">{item.time}</p>
|
|
86
|
+
</Show>
|
|
87
|
+
</div>
|
|
88
|
+
<button
|
|
89
|
+
type="button"
|
|
90
|
+
aria-label="Dismiss"
|
|
91
|
+
class="shrink-0 rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
92
|
+
onClick={() => props.onDismiss?.(item.id)}
|
|
93
|
+
>
|
|
94
|
+
<X class="h-4 w-4" />
|
|
95
|
+
</button>
|
|
96
|
+
</li>
|
|
97
|
+
)}
|
|
98
|
+
</For>
|
|
99
|
+
</ul>
|
|
100
|
+
</Show>
|
|
101
|
+
</Popover>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// A stacked-notifications widget (phone lock-screen style): the newest card is
|
|
2
|
+
// full-size on top and older cards peek out behind it, scaled down and offset.
|
|
3
|
+
// Presentational + controlled — the parent owns `items` (newest first) and
|
|
4
|
+
// removes a card when `onDismiss` fires; the stack re-flows with a CSS transition.
|
|
5
|
+
import { For, type JSX } from 'solid-js'
|
|
6
|
+
|
|
7
|
+
import { cn } from '../lib/cn'
|
|
8
|
+
import { animateIn, motionReduced } from '../lib/motion'
|
|
9
|
+
|
|
10
|
+
export interface StackNotification {
|
|
11
|
+
id: string | number
|
|
12
|
+
title: string
|
|
13
|
+
description?: string
|
|
14
|
+
icon?: JSX.Element
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface NotificationStackProps {
|
|
18
|
+
/** Newest first. The parent owns this array. */
|
|
19
|
+
items: StackNotification[]
|
|
20
|
+
/** How many cards are visible before the rest collapse behind. @default 3 */
|
|
21
|
+
max?: number
|
|
22
|
+
/** Fired when a card is dismissed; the parent should drop it from `items`. */
|
|
23
|
+
onDismiss?: (id: string | number) => void
|
|
24
|
+
class?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Stacked notifications. Cards behind the front one are offset down and scaled,
|
|
29
|
+
* so they peek from the bottom edge; removing the front card promotes the rest
|
|
30
|
+
* with a smooth transition (skipped under reduced motion). New cards fade/slide
|
|
31
|
+
* in via `animateIn`.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```tsx
|
|
35
|
+
* const [items, setItems] = createStore<StackNotification[]>([])
|
|
36
|
+
* <NotificationStack
|
|
37
|
+
* items={items}
|
|
38
|
+
* onDismiss={(id) => setItems((xs) => xs.filter((x) => x.id !== id))}
|
|
39
|
+
* />
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export function NotificationStack(props: NotificationStackProps): JSX.Element {
|
|
43
|
+
const max = () => props.max ?? 3
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div
|
|
47
|
+
role="log"
|
|
48
|
+
aria-live="polite"
|
|
49
|
+
class={cn('relative w-full max-w-sm', props.class)}
|
|
50
|
+
// reserve room for the front card plus the peek of those behind it
|
|
51
|
+
style={{ 'min-height': `${88 + (Math.min(props.items.length, max()) - 1) * 10}px` }}
|
|
52
|
+
>
|
|
53
|
+
<For each={props.items}>
|
|
54
|
+
{(item, i) => {
|
|
55
|
+
const hidden = () => i() > max() - 1
|
|
56
|
+
return (
|
|
57
|
+
<div
|
|
58
|
+
ref={(el) => animateIn(el, { y: -8 })}
|
|
59
|
+
class={cn(
|
|
60
|
+
'card absolute inset-x-0 top-0 rounded-xl border border-border bg-card p-3 shadow-lg',
|
|
61
|
+
!motionReduced() && 'transition-[transform,opacity] duration-300 ease-out',
|
|
62
|
+
)}
|
|
63
|
+
style={{
|
|
64
|
+
transform: `translateY(${i() * 10}px) scale(${1 - i() * 0.05})`,
|
|
65
|
+
opacity: hidden() ? 0 : 1 - i() * 0.15,
|
|
66
|
+
'z-index': props.items.length - i(),
|
|
67
|
+
'pointer-events': hidden() ? 'none' : 'auto',
|
|
68
|
+
'transform-origin': 'top center',
|
|
69
|
+
}}
|
|
70
|
+
>
|
|
71
|
+
<div class="flex items-start gap-2.5">
|
|
72
|
+
{item.icon && <span class="mt-0.5 shrink-0 text-primary">{item.icon}</span>}
|
|
73
|
+
<div class="min-w-0 flex-1">
|
|
74
|
+
<p class="truncate text-sm font-medium text-foreground">{item.title}</p>
|
|
75
|
+
{item.description && (
|
|
76
|
+
<p class="mt-0.5 line-clamp-2 text-sm text-muted-foreground">{item.description}</p>
|
|
77
|
+
)}
|
|
78
|
+
</div>
|
|
79
|
+
<button
|
|
80
|
+
type="button"
|
|
81
|
+
aria-label={`Dismiss ${item.title}`}
|
|
82
|
+
onClick={() => props.onDismiss?.(item.id)}
|
|
83
|
+
class="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
84
|
+
>
|
|
85
|
+
<svg
|
|
86
|
+
viewBox="0 0 24 24"
|
|
87
|
+
class="h-3.5 w-3.5"
|
|
88
|
+
fill="none"
|
|
89
|
+
stroke="currentColor"
|
|
90
|
+
stroke-width="2.5"
|
|
91
|
+
stroke-linecap="round"
|
|
92
|
+
aria-hidden="true"
|
|
93
|
+
>
|
|
94
|
+
<path d="M6 6l12 12M18 6L6 18" />
|
|
95
|
+
</svg>
|
|
96
|
+
</button>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
)
|
|
100
|
+
}}
|
|
101
|
+
</For>
|
|
102
|
+
</div>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Compact "now playing" music widget: cover art, title/artist, and an
|
|
2
|
+
// animated equalizer. The bounce is pure CSS (keyframes injected once into
|
|
3
|
+
// the document head), so the bars animate at zero per-frame JS cost — same
|
|
4
|
+
// pattern as LoadingDots. Bars freeze to a static height when playback is
|
|
5
|
+
// paused or under reduced motion.
|
|
6
|
+
import type { JSX } from 'solid-js'
|
|
7
|
+
import { For, Show } from 'solid-js'
|
|
8
|
+
|
|
9
|
+
import { cn } from '../lib/cn'
|
|
10
|
+
import { motionReduced } from '../lib/motion'
|
|
11
|
+
|
|
12
|
+
export interface NowPlayingProps {
|
|
13
|
+
title: string
|
|
14
|
+
artist?: string
|
|
15
|
+
/** Cover art URL. */
|
|
16
|
+
cover?: string
|
|
17
|
+
/** Whether the equalizer bars are animating. @default true */
|
|
18
|
+
playing?: boolean
|
|
19
|
+
class?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const STYLE_ID = 'a4ui-now-playing-style'
|
|
23
|
+
|
|
24
|
+
function ensureStyleInjected(): void {
|
|
25
|
+
if (typeof document === 'undefined' || document.getElementById(STYLE_ID)) return
|
|
26
|
+
const style = document.createElement('style')
|
|
27
|
+
style.id = STYLE_ID
|
|
28
|
+
style.textContent = `
|
|
29
|
+
@keyframes a4-eq {
|
|
30
|
+
0%, 100% { transform: scaleY(0.3); }
|
|
31
|
+
50% { transform: scaleY(1); }
|
|
32
|
+
}
|
|
33
|
+
`
|
|
34
|
+
document.head.appendChild(style)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Per-bar duration/delay (seconds) so the bars don't bounce in lockstep.
|
|
38
|
+
const BARS = [
|
|
39
|
+
{ duration: 0.6, delay: 0 },
|
|
40
|
+
{ duration: 0.5, delay: 0.1 },
|
|
41
|
+
{ duration: 0.7, delay: 0.05 },
|
|
42
|
+
{ duration: 0.45, delay: 0.15 },
|
|
43
|
+
{ duration: 0.65, delay: 0.2 },
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Compact "now playing" music widget with an animated equalizer. Bars bounce
|
|
48
|
+
* via CSS keyframes while `playing` is true; they freeze to a low static
|
|
49
|
+
* height when paused or under reduced motion.
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```tsx
|
|
53
|
+
* <NowPlaying title="Random Access Memories" artist="Daft Punk" cover="/covers/ram.jpg" playing />
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
export function NowPlaying(props: NowPlayingProps): JSX.Element {
|
|
57
|
+
ensureStyleInjected()
|
|
58
|
+
|
|
59
|
+
const playing = () => (props.playing ?? true) && !motionReduced()
|
|
60
|
+
|
|
61
|
+
const barStyle = (bar: (typeof BARS)[number], index: number): JSX.CSSProperties => {
|
|
62
|
+
if (!playing()) {
|
|
63
|
+
return {
|
|
64
|
+
height: '30%',
|
|
65
|
+
'transform-origin': 'bottom',
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
height: '100%',
|
|
70
|
+
'transform-origin': 'bottom',
|
|
71
|
+
'animation-name': 'a4-eq',
|
|
72
|
+
'animation-duration': `${bar.duration}s`,
|
|
73
|
+
'animation-delay': `${bar.delay + index * 0.02}s`,
|
|
74
|
+
'animation-iteration-count': 'infinite',
|
|
75
|
+
'animation-timing-function': 'ease-in-out',
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div
|
|
81
|
+
class={cn('flex items-center gap-3 rounded-xl border border-border bg-card p-3', props.class)}
|
|
82
|
+
aria-label={`Now playing: ${props.title}${props.artist ? ` by ${props.artist}` : ''}`}
|
|
83
|
+
>
|
|
84
|
+
<Show when={props.cover} fallback={<div class="h-12 w-12 shrink-0 rounded-lg bg-muted" />}>
|
|
85
|
+
<img src={props.cover} alt="" class="h-12 w-12 shrink-0 rounded-lg object-cover" />
|
|
86
|
+
</Show>
|
|
87
|
+
<div class="min-w-0 flex-1">
|
|
88
|
+
<p class="truncate font-medium text-foreground">{props.title}</p>
|
|
89
|
+
<Show when={props.artist}>
|
|
90
|
+
<p class="truncate text-sm text-muted-foreground">{props.artist}</p>
|
|
91
|
+
</Show>
|
|
92
|
+
</div>
|
|
93
|
+
<div class="flex h-6 items-end gap-0.5">
|
|
94
|
+
<For each={BARS}>
|
|
95
|
+
{(bar, index) => <span class="w-1 rounded-full bg-primary" style={barStyle(bar, index())} />}
|
|
96
|
+
</For>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Numeric field with −/+ steppers on Kobalte's NumberField primitive.
|
|
2
|
+
import { NumberField as KNumberField } from '@kobalte/core/number-field'
|
|
3
|
+
import type { JSX } from 'solid-js'
|
|
4
|
+
|
|
5
|
+
import { cn } from '../lib/cn'
|
|
6
|
+
|
|
7
|
+
interface NumberInputProps {
|
|
8
|
+
value: number
|
|
9
|
+
onChange: (value: number) => void
|
|
10
|
+
/** Lower bound; the decrement trigger disables itself at this value. */
|
|
11
|
+
min?: number
|
|
12
|
+
/** Upper bound; the increment trigger disables itself at this value. */
|
|
13
|
+
max?: number
|
|
14
|
+
class?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const TRIGGER_CLASS =
|
|
18
|
+
'grid h-9 w-9 shrink-0 place-items-center border border-input bg-background text-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Numeric field with −/+ stepper buttons, built on Kobalte's `NumberField` primitive.
|
|
22
|
+
* Use for bounded numeric input (quantities, counts) where clicking to nudge the
|
|
23
|
+
* value is preferable to typing alone.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```tsx
|
|
27
|
+
* <NumberInput value={qty()} onChange={setQty} min={0} max={99} />
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export function NumberInput(props: NumberInputProps): JSX.Element {
|
|
31
|
+
return (
|
|
32
|
+
<KNumberField
|
|
33
|
+
rawValue={props.value}
|
|
34
|
+
onRawValueChange={(v) => props.onChange(v)}
|
|
35
|
+
minValue={props.min}
|
|
36
|
+
maxValue={props.max}
|
|
37
|
+
class={cn('inline-flex items-stretch', props.class)}
|
|
38
|
+
>
|
|
39
|
+
<KNumberField.DecrementTrigger class={cn(TRIGGER_CLASS, 'rounded-l-md')} aria-label="Decrement">
|
|
40
|
+
−
|
|
41
|
+
</KNumberField.DecrementTrigger>
|
|
42
|
+
<KNumberField.Input class="w-16 border-y border-input bg-background px-3 py-2 text-center text-sm text-foreground outline-none transition-colors a4-field" />
|
|
43
|
+
<KNumberField.IncrementTrigger class={cn(TRIGGER_CLASS, 'rounded-r-md')} aria-label="Increment">
|
|
44
|
+
+
|
|
45
|
+
</KNumberField.IncrementTrigger>
|
|
46
|
+
</KNumberField>
|
|
47
|
+
)
|
|
48
|
+
}
|