@a4ui/core 0.11.1 → 0.13.0

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