@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,93 @@
1
+ // Reveal text word-by-word or char-by-char with a Motion (motion.dev) stagger:
2
+ // each unit starts translated down and transparent, then fades/slides in with
3
+ // `animate`'s `delay: stagger(...)`, either on mount or the first time the
4
+ // text scrolls into view (`inView`). Whitespace units are rendered as plain
5
+ // text (not wrapped/animated) so word spacing stays exactly what it would be
6
+ // without the effect.
7
+ import { createMemo, onMount, For, type JSX } from 'solid-js'
8
+
9
+ import { cn } from '../lib/cn'
10
+ import { animate, inView, motionReduced, stagger } from '../lib/motion'
11
+
12
+ export interface TextRevealProps {
13
+ text: string
14
+ /** Split unit. @default 'word' */
15
+ by?: 'word' | 'char'
16
+ /** Reveal when scrolled into view instead of on mount. @default false */
17
+ onView?: boolean
18
+ class?: string
19
+ }
20
+
21
+ /** Split `text` into render units: words with their surrounding spaces kept as separate whitespace units, or individual characters. */
22
+ function splitUnits(text: string, by: 'word' | 'char'): string[] {
23
+ if (by === 'char') return text.split('')
24
+ return text.split(/(\s+)/).filter((unit) => unit.length > 0)
25
+ }
26
+
27
+ const isWhitespace = (unit: string): boolean => /^\s+$/.test(unit)
28
+
29
+ /**
30
+ * Reveals `text`, split into words or characters, with a staggered
31
+ * fade + slide-up (via Motion's `animate` + `stagger`). Runs once on mount by
32
+ * default; pass `onView` to instead reveal the first time the component
33
+ * scrolls into view. Whitespace between words renders as plain text, not an
34
+ * animated unit. Under reduced motion, everything stays visible and no
35
+ * animation runs.
36
+ *
37
+ * @example
38
+ * ```tsx
39
+ * <TextReveal text="Ship it." by="char" onView />
40
+ * ```
41
+ */
42
+ export function TextReveal(props: TextRevealProps): JSX.Element {
43
+ const units = createMemo(() => splitUnits(props.text, props.by ?? 'word'))
44
+ const spans: (HTMLSpanElement | undefined)[] = []
45
+ let root: HTMLSpanElement | undefined
46
+
47
+ const reveal = (): void => {
48
+ const targets = spans.filter((el): el is HTMLSpanElement => el !== undefined)
49
+ if (targets.length === 0) return
50
+ animate(
51
+ targets,
52
+ { opacity: [0, 1], y: [12, 0] },
53
+ { delay: stagger(0.03), duration: 0.4, ease: 'easeOut' },
54
+ )
55
+ }
56
+
57
+ onMount(() => {
58
+ if (motionReduced()) return
59
+
60
+ for (const el of spans) {
61
+ if (el) el.style.opacity = '0'
62
+ }
63
+
64
+ if (props.onView) {
65
+ if (!root) return
66
+ inView(root, () => reveal(), { amount: 0.3 })
67
+ return
68
+ }
69
+
70
+ reveal()
71
+ })
72
+
73
+ return (
74
+ <span class={cn(props.class)} ref={root}>
75
+ <For each={units()}>
76
+ {(unit, index) =>
77
+ isWhitespace(unit) ? (
78
+ unit
79
+ ) : (
80
+ <span
81
+ class="inline-block will-change-transform"
82
+ ref={(el) => {
83
+ spans[index()] = el
84
+ }}
85
+ >
86
+ {unit}
87
+ </span>
88
+ )
89
+ }
90
+ </For>
91
+ </span>
92
+ )
93
+ }
@@ -0,0 +1,38 @@
1
+ // Controlled multi-line text field — plain <textarea>, mirrors Input.tsx.
2
+ import type { JSX } from 'solid-js'
3
+ import { splitProps } from 'solid-js'
4
+
5
+ import { cn } from '../lib/cn'
6
+
7
+ const TEXTAREA_BASE =
8
+ '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'
9
+
10
+ interface TextareaProps extends Omit<JSX.TextareaHTMLAttributes<HTMLTextAreaElement>, 'value' | 'onInput'> {
11
+ value: string
12
+ /** Called with the new string value on every input event (not the raw DOM event). */
13
+ onInput: (value: string) => void
14
+ class?: string
15
+ }
16
+
17
+ /**
18
+ * Controlled multi-line text field — a plain `<textarea>` styled to match
19
+ * {@link Input}. Any other native textarea attribute (e.g. `rows`, `placeholder`,
20
+ * `disabled`) is passed through.
21
+ *
22
+ * @example
23
+ * ```tsx
24
+ * const [bio, setBio] = createSignal('')
25
+ * <Textarea value={bio()} onInput={setBio} placeholder="Tell us about yourself" />
26
+ * ```
27
+ */
28
+ export function Textarea(props: TextareaProps): JSX.Element {
29
+ const [local, rest] = splitProps(props, ['value', 'onInput', 'class'])
30
+ return (
31
+ <textarea
32
+ class={cn(TEXTAREA_BASE, local.class)}
33
+ value={local.value}
34
+ onInput={(ev) => local.onInput(ev.currentTarget.value)}
35
+ {...rest}
36
+ />
37
+ )
38
+ }
@@ -0,0 +1,298 @@
1
+ // Compact custom time picker — no Kobalte primitive covers this yet, so this is
2
+ // a small hand-rolled hour/minute (/ AM-PM) column picker in plain Solid + theme
3
+ // tokens (works in light/dark). Reuses DateField's popover machinery verbatim:
4
+ // a trigger button opens a portaled popover positioned `fixed` at the trigger's
5
+ // viewport rect, closing on outside mousedown, Escape, scroll, or resize.
6
+ import { Clock } 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
+ type AmPm = 'AM' | 'PM'
14
+
15
+ const HOURS24 = Array.from({ length: 24 }, (_, i) => i)
16
+ const HOURS12 = [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
17
+
18
+ /** Zero-pad a non-negative integer to 2 digits. */
19
+ function pad(n: number): string {
20
+ return `${n}`.padStart(2, '0')
21
+ }
22
+
23
+ /** Parse canonical "HH:MM" (24h) into `{h, m}`; null if malformed or out of range. */
24
+ function parse(value: string): { h: number; m: number } | null {
25
+ const mm = /^(\d{2}):(\d{2})$/.exec(value)
26
+ if (!mm) return null
27
+ const h = Number(mm[1])
28
+ const m = Number(mm[2])
29
+ if (h < 0 || h > 23 || m < 0 || m > 59) return null
30
+ return { h, m }
31
+ }
32
+
33
+ /** Format an `{h, m}` pair as canonical "HH:MM" (24h, zero-padded). */
34
+ function format(h: number, m: number): string {
35
+ return `${pad(h)}:${pad(m)}`
36
+ }
37
+
38
+ /** Convert a 24h hour to `{h12, ampm}` (12 stands for both 12AM and 12PM). */
39
+ function to12(h24: number): { h12: number; ampm: AmPm } {
40
+ const ampm: AmPm = h24 < 12 ? 'AM' : 'PM'
41
+ let h12 = h24 % 12
42
+ if (h12 === 0) h12 = 12
43
+ return { h12, ampm }
44
+ }
45
+
46
+ /** Convert a 12h hour + AM/PM back to a 24h hour. */
47
+ function to24(h12: number, ampm: AmPm): number {
48
+ if (ampm === 'AM') return h12 === 12 ? 0 : h12
49
+ return h12 === 12 ? 12 : h12 + 12
50
+ }
51
+
52
+ /** Human-readable trigger label, e.g. `15:05` or `3:05 PM`. */
53
+ function displayLabel(value: string, hour12: boolean): string | null {
54
+ const p = parse(value)
55
+ if (!p) return null
56
+ if (hour12) {
57
+ const { h12, ampm } = to12(p.h)
58
+ return `${h12}:${pad(p.m)} ${ampm}`
59
+ }
60
+ return format(p.h, p.m)
61
+ }
62
+
63
+ export interface TimeFieldProps {
64
+ /** Selected time as canonical 24h "HH:MM" (e.g. "15:05"), or "" for none. */
65
+ value: string
66
+ /** Called with the newly picked time as "HH:MM" (24h). */
67
+ onChange: (value: string) => void
68
+ /** 12-hour display with AM/PM instead of 24-hour. @default false */
69
+ hour12?: boolean
70
+ /** Minute increment for the options list. @default 5 */
71
+ minuteStep?: number
72
+ /** Placeholder shown on the trigger when value is empty. */
73
+ label?: string
74
+ disabled?: boolean
75
+ class?: string
76
+ }
77
+
78
+ /**
79
+ * Compact hand-rolled hour/minute (/ AM-PM) column picker (no Kobalte primitive
80
+ * covers this yet). Trigger button opens a portaled popover; closes on outside
81
+ * click, Escape, scroll, or resize. Speaks plain 24h `HH:MM` strings.
82
+ *
83
+ * @example
84
+ * ```tsx
85
+ * const [time, setTime] = createSignal('15:05')
86
+ * <TimeField value={time()} onChange={setTime} label="Start time" hour12 />
87
+ * ```
88
+ */
89
+ export function TimeField(props: TimeFieldProps): JSX.Element {
90
+ const [open, setOpen] = createSignal(false)
91
+ // Working selection shown/highlighted in the popover columns — 24h internally
92
+ // regardless of display mode. Seeded on open (see openPopover), not here.
93
+ const [workH, setWorkH] = createSignal(0)
94
+ const [workM, setWorkM] = createSignal(0)
95
+
96
+ let rootRef: HTMLDivElement | undefined
97
+ let btnRef: HTMLButtonElement | undefined
98
+ let popRef: HTMLDivElement | undefined
99
+ // The picker is rendered in a <Portal> (so it escapes the glass card's
100
+ // backdrop-filter stacking context, which otherwise traps it behind later
101
+ // content). Being at <body>, it can't use `absolute` positioning relative to
102
+ // the trigger — position it with `fixed` at the trigger's viewport rect.
103
+ const [pos, setPos] = createSignal({ top: 0, left: 0 })
104
+ const popWidth = () => (props.hour12 ? 224 : 176) // w-56 / w-44
105
+
106
+ const place = () => {
107
+ if (!btnRef) return
108
+ const r = btnRef.getBoundingClientRect()
109
+ const w = popWidth()
110
+ const left = Math.max(8, Math.min(r.left, window.innerWidth - w - 8))
111
+ setPos({ top: r.bottom + 4, left })
112
+ }
113
+
114
+ const openPopover = () => {
115
+ if (props.disabled) return
116
+ const seed = parse(props.value)
117
+ if (seed) {
118
+ setWorkH(seed.h)
119
+ setWorkM(seed.m)
120
+ } else {
121
+ const step = props.minuteStep ?? 5
122
+ const now = new Date()
123
+ let h = now.getHours()
124
+ let m = Math.round(now.getMinutes() / step) * step
125
+ if (m >= 60) {
126
+ m -= 60
127
+ h = (h + 1) % 24
128
+ }
129
+ setWorkH(h)
130
+ setWorkM(m)
131
+ }
132
+ place()
133
+ setOpen(true)
134
+ }
135
+
136
+ const close = () => setOpen(false)
137
+
138
+ const onDocPointer = (ev: MouseEvent) => {
139
+ const t = ev.target as Node
140
+ // Ignore clicks on the trigger (rootRef) AND the portaled popover (popRef).
141
+ if (rootRef && !rootRef.contains(t) && popRef && !popRef.contains(t)) close()
142
+ }
143
+ const onKey = (ev: KeyboardEvent) => {
144
+ if (ev.key === 'Escape') close()
145
+ }
146
+
147
+ // Wire/tear-down the global listeners strictly around the open state. Scroll/
148
+ // resize close the popover (its fixed position would otherwise drift).
149
+ const toggleListeners = (isOpen: boolean) => {
150
+ if (isOpen) {
151
+ document.addEventListener('mousedown', onDocPointer)
152
+ document.addEventListener('keydown', onKey)
153
+ window.addEventListener('scroll', close, true)
154
+ window.addEventListener('resize', close)
155
+ } else {
156
+ document.removeEventListener('mousedown', onDocPointer)
157
+ document.removeEventListener('keydown', onKey)
158
+ window.removeEventListener('scroll', close, true)
159
+ window.removeEventListener('resize', close)
160
+ }
161
+ }
162
+ // Reactively attach when open flips; always clean up on unmount.
163
+ createEffect(() => toggleListeners(open()))
164
+ onCleanup(() => toggleListeners(false))
165
+
166
+ const minuteOptions = createMemo(() => {
167
+ const step = props.minuteStep ?? 5
168
+ const out: number[] = []
169
+ for (let m = 0; m < 60; m += step) out.push(m)
170
+ return out
171
+ })
172
+
173
+ // Pick an hour: `value` is a 24h hour when hour12 is off, else the 12h label
174
+ // (1..12) which is combined with the current AM/PM to resolve the 24h hour.
175
+ const pickHour = (value: number) => {
176
+ const h24 = props.hour12 ? to24(value, to12(workH()).ampm) : value
177
+ setWorkH(h24)
178
+ props.onChange(format(h24, workM()))
179
+ }
180
+
181
+ const pickMinute = (m: number) => {
182
+ setWorkM(m)
183
+ props.onChange(format(workH(), m))
184
+ }
185
+
186
+ const pickAmPm = (ampm: AmPm) => {
187
+ const h24 = to24(to12(workH()).h12, ampm)
188
+ setWorkH(h24)
189
+ props.onChange(format(h24, workM()))
190
+ }
191
+
192
+ const optionClass = (selected: boolean) =>
193
+ cn(
194
+ 'rounded-md px-2 py-1 text-left text-sm transition-colors',
195
+ selected ? 'bg-primary text-primary-foreground' : 'text-foreground hover:bg-muted',
196
+ )
197
+
198
+ return (
199
+ <div ref={rootRef} class={cn('relative', props.class)}>
200
+ <button
201
+ ref={btnRef}
202
+ type="button"
203
+ disabled={props.disabled}
204
+ onClick={() => (open() ? close() : openPopover())}
205
+ class="flex w-full 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"
206
+ >
207
+ <span class={displayLabel(props.value, props.hour12 ?? false) ? '' : 'text-muted-foreground'}>
208
+ {displayLabel(props.value, props.hour12 ?? false) ?? props.label ?? 'Select time'}
209
+ </span>
210
+ <Clock class="h-4 w-4 shrink-0 text-muted-foreground" />
211
+ </button>
212
+
213
+ <Show when={open()}>
214
+ <Portal>
215
+ <div
216
+ ref={popRef}
217
+ style={{ position: 'fixed', top: `${pos().top}px`, left: `${pos().left}px` }}
218
+ class={cn(
219
+ 'z-50 flex gap-1 divide-x divide-border rounded-lg border border-border bg-card p-2 text-card-foreground shadow-lg',
220
+ props.hour12 ? 'w-56' : 'w-44',
221
+ )}
222
+ >
223
+ <div
224
+ class="flex max-h-56 flex-1 flex-col gap-0.5 overflow-y-auto pr-1"
225
+ role="listbox"
226
+ aria-label="Hour"
227
+ >
228
+ <For each={props.hour12 ? HOURS12 : HOURS24}>
229
+ {(h) => {
230
+ const isSel = () => (props.hour12 ? to12(workH()).h12 === h : workH() === h)
231
+ return (
232
+ <button
233
+ type="button"
234
+ role="option"
235
+ aria-selected={isSel()}
236
+ onClick={() => pickHour(h)}
237
+ class={optionClass(isSel())}
238
+ >
239
+ {props.hour12 ? h : pad(h)}
240
+ </button>
241
+ )
242
+ }}
243
+ </For>
244
+ </div>
245
+
246
+ <div
247
+ class="flex max-h-56 flex-1 flex-col gap-0.5 overflow-y-auto px-1"
248
+ role="listbox"
249
+ aria-label="Minute"
250
+ >
251
+ <For each={minuteOptions()}>
252
+ {(m) => {
253
+ const isSel = () => workM() === m
254
+ return (
255
+ <button
256
+ type="button"
257
+ role="option"
258
+ aria-selected={isSel()}
259
+ onClick={() => pickMinute(m)}
260
+ class={optionClass(isSel())}
261
+ >
262
+ {pad(m)}
263
+ </button>
264
+ )
265
+ }}
266
+ </For>
267
+ </div>
268
+
269
+ <Show when={props.hour12}>
270
+ <div
271
+ class="flex max-h-56 flex-1 flex-col gap-0.5 overflow-y-auto pl-1"
272
+ role="listbox"
273
+ aria-label="AM or PM"
274
+ >
275
+ <For each={['AM', 'PM'] as const}>
276
+ {(ampm) => {
277
+ const isSel = () => to12(workH()).ampm === ampm
278
+ return (
279
+ <button
280
+ type="button"
281
+ role="option"
282
+ aria-selected={isSel()}
283
+ onClick={() => pickAmPm(ampm)}
284
+ class={optionClass(isSel())}
285
+ >
286
+ {ampm}
287
+ </button>
288
+ )
289
+ }}
290
+ </For>
291
+ </div>
292
+ </Show>
293
+ </div>
294
+ </Portal>
295
+ </Show>
296
+ </div>
297
+ )
298
+ }
@@ -0,0 +1,89 @@
1
+ // Vertical timeline: a continuous line with a tone-colored dot per event, plus
2
+ // title / optional time / optional description. Semantic <ol>/<li>, theme-agnostic
3
+ // tokens only (with fixed semantic accents like Badge/Stat use for success).
4
+ import type { JSX } from 'solid-js'
5
+ import { For, Show } from 'solid-js'
6
+
7
+ import { cn } from '../lib/cn'
8
+
9
+ /** Accent applied to a {@link TimelineItem}'s dot on the line. */
10
+ export type TimelineTone = 'default' | 'primary' | 'success' | 'danger'
11
+
12
+ const DOT_CLASSES: Record<TimelineTone, string> = {
13
+ default: 'bg-muted border border-border',
14
+ primary: 'bg-primary',
15
+ success: 'bg-emerald-500',
16
+ danger: 'bg-destructive',
17
+ }
18
+
19
+ /** A single event rendered by {@link Timeline}. */
20
+ export interface TimelineItem {
21
+ /** Headline for the event (rendered emphasized). */
22
+ title: string
23
+ /** Optional supporting copy shown under the title. */
24
+ description?: string
25
+ /** Optional timestamp / relative time label. */
26
+ time?: string
27
+ /** Accent for the dot on the line. Defaults to `'default'`. */
28
+ tone?: TimelineTone
29
+ }
30
+
31
+ export interface TimelineProps {
32
+ items: TimelineItem[]
33
+ class?: string
34
+ }
35
+
36
+ /**
37
+ * Vertical timeline of events. A continuous line runs down the left with a
38
+ * tone-colored dot marking each entry; to the right sit the title, an optional
39
+ * right-aligned time, and an optional description. The line segment stops at the
40
+ * last item's dot. Renders as a semantic `<ol>`/`<li>` list.
41
+ *
42
+ * @example
43
+ * ```tsx
44
+ * <Timeline
45
+ * items={[
46
+ * { title: 'Order placed', time: '09:24', tone: 'primary' },
47
+ * { title: 'Shipped', description: 'Left the warehouse.', time: '11:02', tone: 'success' },
48
+ * { title: 'Delayed', description: 'Weather hold.', time: '14:30', tone: 'danger' },
49
+ * ]}
50
+ * />
51
+ * ```
52
+ */
53
+ export function Timeline(props: TimelineProps): JSX.Element {
54
+ return (
55
+ <ol class={cn('relative flex flex-col gap-6', props.class)}>
56
+ <For each={props.items}>
57
+ {(item, index) => (
58
+ <li class="relative flex gap-4 pl-2">
59
+ {/* Line segment: hidden on the last item so it doesn't extend past the dot. */}
60
+ <Show when={index() < props.items.length - 1}>
61
+ <span
62
+ aria-hidden="true"
63
+ class="absolute left-[calc(0.5rem+0.375rem)] top-3 -bottom-6 w-px -translate-x-1/2 bg-border"
64
+ />
65
+ </Show>
66
+ <span
67
+ aria-hidden="true"
68
+ class={cn(
69
+ 'relative z-[1] mt-1 h-3 w-3 shrink-0 rounded-full',
70
+ DOT_CLASSES[item.tone ?? 'default'],
71
+ )}
72
+ />
73
+ <div class="min-w-0 flex-1">
74
+ <div class="flex items-baseline justify-between gap-3">
75
+ <p class="font-medium text-foreground">{item.title}</p>
76
+ <Show when={item.time}>
77
+ <span class="shrink-0 text-xs text-muted-foreground">{item.time}</span>
78
+ </Show>
79
+ </div>
80
+ <Show when={item.description}>
81
+ <p class="mt-1 text-sm text-muted-foreground">{item.description}</p>
82
+ </Show>
83
+ </div>
84
+ </li>
85
+ )}
86
+ </For>
87
+ </ol>
88
+ )
89
+ }
@@ -0,0 +1,68 @@
1
+ // Toast notifications on Kobalte's imperative `toaster` + `Toast.Region`.
2
+ // Mount <Toaster /> once near the app root; call the `toast` helpers from
3
+ // anywhere (pages, mutations' onError/onSuccess).
4
+ import { Toast as KToast, toaster } from '@kobalte/core/toast'
5
+ import type { JSX } from 'solid-js'
6
+ import { Show } from 'solid-js'
7
+
8
+ import { cn } from '../lib/cn'
9
+
10
+ /** Visual/semantic tone of a toast — controls its border/background accent color. */
11
+ export type ToastTone = 'success' | 'error' | 'info'
12
+
13
+ const TONE_CLASSES: Record<ToastTone, string> = {
14
+ success: 'border-emerald-500/30 bg-emerald-500/10',
15
+ error: 'border-destructive/30 bg-destructive/10',
16
+ info: 'border-sky-500/30 bg-sky-500/10',
17
+ }
18
+
19
+ function show(tone: ToastTone, title: string, description?: string): void {
20
+ toaster.show((props) => (
21
+ <KToast
22
+ toastId={props.toastId}
23
+ class={cn(
24
+ // toast-item drives the slide+fade keyframes in styles.css, keyed off
25
+ // Kobalte's data-opened/data-closed presence (same animationend-only
26
+ // mechanism as Modal).
27
+ 'toast-item flex w-full items-start gap-2 rounded-md border bg-card p-3 text-sm text-card-foreground shadow-sm ring-1 ring-border',
28
+ TONE_CLASSES[tone],
29
+ )}
30
+ >
31
+ <div class="flex-1">
32
+ <KToast.Title class="font-semibold">{title}</KToast.Title>
33
+ <Show when={description}>
34
+ <KToast.Description class="text-muted-foreground">{description}</KToast.Description>
35
+ </Show>
36
+ </div>
37
+ <KToast.CloseButton class="text-muted-foreground hover:text-foreground" aria-label="Close">
38
+
39
+ </KToast.CloseButton>
40
+ </KToast>
41
+ ))
42
+ }
43
+
44
+ /**
45
+ * Imperative API to show a toast notification from anywhere in the app (event
46
+ * handlers, mutation `onError`/`onSuccess`, etc.) — no component context
47
+ * needed. Requires a mounted {@link Toaster} to render into.
48
+ *
49
+ * @example
50
+ * ```tsx
51
+ * toast.success('Saved', 'Your changes were saved.')
52
+ * toast.error('Something went wrong')
53
+ * ```
54
+ */
55
+ export const toast = {
56
+ success: (title: string, description?: string) => show('success', title, description),
57
+ error: (title: string, description?: string) => show('error', title, description),
58
+ info: (title: string, description?: string) => show('info', title, description),
59
+ }
60
+
61
+ /** Mount once near the app root — hosts the toast viewport. */
62
+ export function Toaster(): JSX.Element {
63
+ return (
64
+ <KToast.Region duration={5000} swipeDirection="right">
65
+ <KToast.List class="fixed bottom-4 right-4 z-50 flex w-80 flex-col gap-2 outline-none" />
66
+ </KToast.Region>
67
+ )
68
+ }
@@ -0,0 +1,41 @@
1
+ // Two-state toggle button on Kobalte's ToggleButton primitive.
2
+ import { ToggleButton as KToggleButton } from '@kobalte/core/toggle-button'
3
+ import type { JSX, ParentProps } from 'solid-js'
4
+
5
+ import { cn } from '../lib/cn'
6
+
7
+ interface ToggleProps extends ParentProps {
8
+ /** Whether the toggle is pressed/"on". Controlled — pair with `onChange`. */
9
+ pressed: boolean
10
+ onChange: (pressed: boolean) => void
11
+ class?: string
12
+ }
13
+
14
+ /**
15
+ * Two-state toggle button (e.g. bold/italic, mute, favorite), built on
16
+ * Kobalte's ToggleButton primitive. `children` is the button's content
17
+ * (icon and/or text).
18
+ *
19
+ * @example
20
+ * ```tsx
21
+ * const [pressed, setPressed] = createSignal(false)
22
+ * <Toggle pressed={pressed()} onChange={setPressed}><StarIcon /></Toggle>
23
+ * ```
24
+ */
25
+ export function Toggle(props: ToggleProps): JSX.Element {
26
+ return (
27
+ <KToggleButton
28
+ pressed={props.pressed}
29
+ onChange={props.onChange}
30
+ class={cn(
31
+ // Outlined at rest so it reads as a button; filled primary when pressed
32
+ // so the "on" state is unmistakable (the resting muted look alone made it
33
+ // look like stray text).
34
+ 'inline-flex h-9 w-9 items-center justify-center rounded-md border border-border text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[pressed]:border-primary data-[pressed]:bg-primary data-[pressed]:text-primary-foreground',
35
+ props.class,
36
+ )}
37
+ >
38
+ {props.children}
39
+ </KToggleButton>
40
+ )
41
+ }
@@ -0,0 +1,59 @@
1
+ // Segmented button row on Kobalte's ToggleGroup primitive.
2
+ import { ToggleGroup as KToggleGroup } from '@kobalte/core/toggle-group'
3
+ import type { JSX } from 'solid-js'
4
+ import { For } from 'solid-js'
5
+
6
+ import { cn } from '../lib/cn'
7
+
8
+ /** One option in a {@link ToggleGroup}: its `value` and visible `label`. */
9
+ export interface ToggleGroupOption {
10
+ value: string
11
+ label: string
12
+ }
13
+
14
+ interface ToggleGroupProps {
15
+ /** Currently selected option's `value`, or `null` if none is selected. Controlled — pair with `onChange`. */
16
+ value: string | null
17
+ onChange: (value: string | null) => void
18
+ /** Options to render, in order, as segmented buttons. */
19
+ options: ToggleGroupOption[]
20
+ class?: string
21
+ }
22
+
23
+ /**
24
+ * Single-select segmented button row, built on Kobalte's ToggleGroup
25
+ * primitive — for exclusive-choice controls like view mode or alignment.
26
+ *
27
+ * @example
28
+ * ```tsx
29
+ * const [align, setAlign] = createSignal<string | null>('left')
30
+ * <ToggleGroup
31
+ * value={align()}
32
+ * onChange={setAlign}
33
+ * options={[
34
+ * { value: 'left', label: 'Left' },
35
+ * { value: 'center', label: 'Center' },
36
+ * ]}
37
+ * />
38
+ * ```
39
+ */
40
+ export function ToggleGroup(props: ToggleGroupProps): JSX.Element {
41
+ return (
42
+ <KToggleGroup
43
+ value={props.value}
44
+ onChange={props.onChange}
45
+ class={cn('inline-flex gap-1 rounded-md border border-border bg-card p-1', props.class)}
46
+ >
47
+ <For each={props.options}>
48
+ {(option) => (
49
+ <KToggleGroup.Item
50
+ value={option.value}
51
+ class="rounded-sm px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground data-[pressed]:bg-primary data-[pressed]:text-primary-foreground"
52
+ >
53
+ {option.label}
54
+ </KToggleGroup.Item>
55
+ )}
56
+ </For>
57
+ </KToggleGroup>
58
+ )
59
+ }