@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,198 @@
1
+ // Advanced file uploader: a drop zone plus a list of files with per-file
2
+ // progress, error/retry, preview thumbnail, and remove. Purely presentational
3
+ // and controlled — the consumer owns `files` and the actual upload; this
4
+ // component only emits `onAdd`/`onRemove`/`onRetry` events.
5
+ import { Check, File as FileIcon, X } from 'lucide-solid'
6
+ import { For, type JSX, Show, createSignal } from 'solid-js'
7
+
8
+ import { cn } from '../lib/cn'
9
+ import { Button } from './Button'
10
+ import { Progress } from './Progress'
11
+
12
+ /** A single file entry rendered by {@link FileUpload}. The consumer owns this state. */
13
+ export interface UploadFile {
14
+ id: string
15
+ name: string
16
+ /** File size in bytes. */
17
+ size: number
18
+ /** Upload progress, 0-100. */
19
+ progress: number
20
+ status: 'pending' | 'uploading' | 'done' | 'error'
21
+ /** Optional preview/thumbnail URL (e.g. an object URL for an image). */
22
+ url?: string
23
+ /** Error message when `status === 'error'`. */
24
+ error?: string
25
+ }
26
+
27
+ export interface FileUploadProps {
28
+ files: UploadFile[]
29
+ /** Called with the newly chosen File[] (from drop or the file dialog). */
30
+ onAdd: (files: File[]) => void
31
+ onRemove?: (id: string) => void
32
+ onRetry?: (id: string) => void
33
+ /** `accept` attribute for the hidden input, e.g. ".png,.jpg,application/pdf". */
34
+ accept?: string
35
+ /** Allow choosing/dropping more than one file at a time. Default: `true`. */
36
+ multiple?: boolean
37
+ /** Helper text shown inside the drop zone. */
38
+ hint?: string
39
+ disabled?: boolean
40
+ class?: string
41
+ }
42
+
43
+ /** Format a byte count as a short human-readable string, e.g. `1.4 MB`. */
44
+ function formatBytes(n: number): string {
45
+ if (!Number.isFinite(n) || n < 0) return '0 B'
46
+ if (n < 1024) return `${n} B`
47
+ const units = ['KB', 'MB', 'GB', 'TB']
48
+ let value = n / 1024
49
+ let unit = 0
50
+ while (value >= 1024 && unit < units.length - 1) {
51
+ value /= 1024
52
+ unit += 1
53
+ }
54
+ return `${value.toFixed(1)} ${units[unit]}`
55
+ }
56
+
57
+ /**
58
+ * Advanced, controlled file uploader: a click-or-drag drop zone plus a list of
59
+ * files showing per-file progress, error/retry, a preview thumbnail, and a
60
+ * remove action. This component owns no file state — the consumer supplies
61
+ * `files` and performs the actual upload, updating `files` as progress comes in.
62
+ *
63
+ * @example
64
+ * ```tsx
65
+ * const [files, setFiles] = createSignal<UploadFile[]>([])
66
+ *
67
+ * <FileUpload
68
+ * files={files()}
69
+ * accept="image/*"
70
+ * onAdd={(added) => {
71
+ * const entries = added.map((f) => ({ id: crypto.randomUUID(), name: f.name, size: f.size, progress: 0, status: 'pending' as const }))
72
+ * setFiles((prev) => [...prev, ...entries])
73
+ * entries.forEach((entry, i) => upload(added[i], entry.id, setFiles))
74
+ * }}
75
+ * onRemove={(id) => setFiles((prev) => prev.filter((f) => f.id !== id))}
76
+ * onRetry={(id) => retryUpload(id, setFiles)}
77
+ * />
78
+ * ```
79
+ */
80
+ export function FileUpload(props: FileUploadProps): JSX.Element {
81
+ const [dragOver, setDragOver] = createSignal(false)
82
+ let input: HTMLInputElement | undefined
83
+
84
+ const emit = (list: FileList | null | undefined) => {
85
+ if (props.disabled || !list) return
86
+ const files = Array.from(list)
87
+ if (files.length) props.onAdd(files)
88
+ }
89
+
90
+ return (
91
+ <div class={cn('flex flex-col gap-4', props.class)}>
92
+ <button
93
+ type="button"
94
+ disabled={props.disabled}
95
+ onClick={() => !props.disabled && input?.click()}
96
+ onDragOver={(e) => {
97
+ if (props.disabled) return
98
+ e.preventDefault()
99
+ setDragOver(true)
100
+ }}
101
+ onDragLeave={(e) => {
102
+ e.preventDefault()
103
+ setDragOver(false)
104
+ }}
105
+ onDrop={(e) => {
106
+ e.preventDefault()
107
+ setDragOver(false)
108
+ emit(e.dataTransfer?.files)
109
+ }}
110
+ class="flex w-full flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-input p-6 text-center transition-colors"
111
+ classList={{
112
+ 'border-primary bg-primary/5': dragOver(),
113
+ 'hover:border-primary hover:bg-primary/5': !dragOver() && !props.disabled,
114
+ 'cursor-not-allowed opacity-60': props.disabled,
115
+ }}
116
+ >
117
+ <FileIcon class="h-8 w-8 text-muted-foreground" />
118
+ <p class="text-sm font-medium text-foreground">
119
+ {props.hint ?? 'Drag files here or click to browse'}
120
+ </p>
121
+ <input
122
+ ref={input}
123
+ type="file"
124
+ class="hidden"
125
+ accept={props.accept}
126
+ multiple={props.multiple ?? true}
127
+ disabled={props.disabled}
128
+ onChange={(e) => {
129
+ emit(e.currentTarget.files)
130
+ e.currentTarget.value = '' // allow re-selecting the same file
131
+ }}
132
+ />
133
+ </button>
134
+
135
+ <Show when={props.files.length > 0}>
136
+ <ul class="flex flex-col gap-2">
137
+ <For each={props.files}>
138
+ {(file) => (
139
+ <li class="flex items-center gap-3 rounded-lg border border-border bg-glass p-3">
140
+ <Show
141
+ when={file.url}
142
+ fallback={
143
+ <div class="flex h-10 w-10 shrink-0 items-center justify-center rounded bg-muted">
144
+ <FileIcon class="h-5 w-5 text-muted-foreground" />
145
+ </div>
146
+ }
147
+ >
148
+ <img src={file.url} alt="" class="h-10 w-10 shrink-0 rounded object-cover" />
149
+ </Show>
150
+
151
+ <div class="flex min-w-0 flex-1 flex-col gap-1">
152
+ <div class="flex items-baseline gap-2">
153
+ <span class="truncate text-sm font-medium text-foreground">{file.name}</span>
154
+ <span class="shrink-0 text-xs text-muted-foreground">{formatBytes(file.size)}</span>
155
+ </div>
156
+
157
+ <Show when={file.status === 'uploading'}>
158
+ <Progress value={file.progress} />
159
+ </Show>
160
+
161
+ <Show when={file.status === 'error'}>
162
+ <div class="flex items-center gap-2">
163
+ <span class="text-xs text-destructive">{file.error ?? 'Upload failed'}</span>
164
+ <Show when={props.onRetry}>
165
+ <Button
166
+ variant="outline"
167
+ class="h-6 px-2 py-0 text-xs"
168
+ onClick={() => props.onRetry?.(file.id)}
169
+ >
170
+ Retry
171
+ </Button>
172
+ </Show>
173
+ </div>
174
+ </Show>
175
+ </div>
176
+
177
+ <Show when={file.status === 'done'}>
178
+ <Check class="h-5 w-5 shrink-0 text-primary" aria-label="Upload complete" />
179
+ </Show>
180
+
181
+ <Show when={props.onRemove}>
182
+ <button
183
+ type="button"
184
+ aria-label="Remove file"
185
+ onClick={() => props.onRemove?.(file.id)}
186
+ class="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
187
+ >
188
+ <X class="h-4 w-4" />
189
+ </button>
190
+ </Show>
191
+ </li>
192
+ )}
193
+ </For>
194
+ </ul>
195
+ </Show>
196
+ </div>
197
+ )
198
+ }
@@ -0,0 +1,63 @@
1
+ // FillText — text with a color "fill" band sweeping left-to-right on loop,
2
+ // for loading states. The sweep is a background-position animation clipped
3
+ // to the glyphs via `background-clip: text`, driven by Motion's `animate`.
4
+ // Falls back to plain static text under reduced motion.
5
+ import { onCleanup, onMount, Show, type JSX } from 'solid-js'
6
+
7
+ import { cn } from '../lib/cn'
8
+ import { animate, motionReduced } from '../lib/motion'
9
+
10
+ export interface FillTextProps {
11
+ text: string
12
+ /** Seconds per sweep. @default 1.6 */
13
+ duration?: number
14
+ class?: string
15
+ }
16
+
17
+ /**
18
+ * Renders `text` with a bright gradient band that sweeps across the glyphs
19
+ * repeatedly, useful as a lightweight loading indicator. Respects
20
+ * `prefers-reduced-motion` by rendering plain muted text instead of animating.
21
+ *
22
+ * @example
23
+ * ```tsx
24
+ * <FillText text="Loading results…" />
25
+ * ```
26
+ */
27
+ export function FillText(props: FillTextProps): JSX.Element {
28
+ let el!: HTMLSpanElement
29
+ let controls: { stop: () => void } | undefined
30
+
31
+ onMount(() => {
32
+ if (motionReduced()) return
33
+
34
+ controls = animate(
35
+ el,
36
+ { backgroundPosition: ['200% 0%', '-200% 0%'] },
37
+ { duration: props.duration ?? 1.6, ease: 'linear', repeat: Infinity },
38
+ )
39
+
40
+ onCleanup(() => controls?.stop())
41
+ })
42
+
43
+ return (
44
+ <span role="status" class={cn('font-medium', props.class)}>
45
+ <Show when={!motionReduced()} fallback={<span class="text-muted-foreground">{props.text}</span>}>
46
+ <span
47
+ ref={el}
48
+ style={{
49
+ background:
50
+ 'linear-gradient(90deg, hsl(var(--muted-foreground)) 0%, hsl(var(--muted-foreground)) 40%, hsl(var(--foreground)) 50%, hsl(var(--muted-foreground)) 60%, hsl(var(--muted-foreground)) 100%)',
51
+ 'background-size': '200% 100%',
52
+ '-webkit-background-clip': 'text',
53
+ 'background-clip': 'text',
54
+ '-webkit-text-fill-color': 'transparent',
55
+ color: 'transparent',
56
+ }}
57
+ >
58
+ {props.text}
59
+ </span>
60
+ </Show>
61
+ </span>
62
+ )
63
+ }
@@ -0,0 +1,48 @@
1
+ import type { JSX } from 'solid-js'
2
+
3
+ import { cn } from '../lib/cn'
4
+
5
+ /** Screen corner a {@link FloatingActionButton} anchors to. Defaults to `'bottom-right'`. */
6
+ export type FloatingActionButtonPosition = 'bottom-right' | 'bottom-left'
7
+
8
+ const POSITION_CLASSES: Record<FloatingActionButtonPosition, string> = {
9
+ 'bottom-right': 'bottom-6 right-6',
10
+ 'bottom-left': 'bottom-6 left-6',
11
+ }
12
+
13
+ // brightness-110 on hover keeps the tint theme-agnostic — it lifts whatever
14
+ // primary color the active theme resolves to, no per-variant color needed.
15
+ const FAB_BASE =
16
+ 'fixed z-40 grid h-14 w-14 place-items-center rounded-full bg-primary text-primary-foreground shadow-lg transition hover:brightness-110'
17
+
18
+ export interface FloatingActionButtonProps {
19
+ /** Icon rendered centered inside the button. */
20
+ icon: JSX.Element
21
+ /** Accessible label; surfaced via `aria-label`. */
22
+ label: string
23
+ onClick?: () => void
24
+ /** Screen corner to anchor to. Defaults to `'bottom-right'`. */
25
+ position?: FloatingActionButtonPosition
26
+ class?: string
27
+ }
28
+
29
+ /**
30
+ * A fixed circular primary action button pinned to a screen corner.
31
+ *
32
+ * @example
33
+ * ```tsx
34
+ * <FloatingActionButton icon={<PlusIcon />} label="New item" onClick={() => create()} />
35
+ * ```
36
+ */
37
+ export function FloatingActionButton(props: FloatingActionButtonProps): JSX.Element {
38
+ return (
39
+ <button
40
+ type="button"
41
+ aria-label={props.label}
42
+ onClick={() => props.onClick?.()}
43
+ class={cn(FAB_BASE, POSITION_CLASSES[props.position ?? 'bottom-right'], props.class)}
44
+ >
45
+ {props.icon}
46
+ </button>
47
+ )
48
+ }
@@ -0,0 +1,108 @@
1
+ // Accessible form-field primitives. `FormField` provisions a shared,
2
+ // auto-generated id via context so `FormLabel`, `FormControl`,
3
+ // `FormDescription`, and `FormError` can associate themselves (`for`,
4
+ // `aria-describedby`, element ids) without the consumer wiring ids by hand.
5
+ import type { JSX } from 'solid-js'
6
+ import { createContext, createUniqueId, Show, useContext } from 'solid-js'
7
+
8
+ import { cn } from '../lib/cn'
9
+
10
+ interface FormFieldContextValue {
11
+ id: string
12
+ describedBy: string
13
+ }
14
+
15
+ const FormFieldContext = createContext<FormFieldContextValue>()
16
+
17
+ function useFormFieldContext(): FormFieldContextValue {
18
+ const ctx = useContext(FormFieldContext)
19
+ return ctx ?? { id: '', describedBy: '' }
20
+ }
21
+
22
+ export interface FormFieldProps {
23
+ /** Optional explicit id; otherwise auto-generated. */
24
+ id?: string
25
+ children: JSX.Element
26
+ class?: string
27
+ }
28
+
29
+ /**
30
+ * Container/provider that gives `FormLabel`, `FormControl`,
31
+ * `FormDescription`, and `FormError` a shared field id so they wire up
32
+ * `for`/`id`/`aria-describedby` automatically.
33
+ *
34
+ * Pairs well with a schema validator (Valibot/Zod): the consumer computes
35
+ * the error string and passes it to `FormError`.
36
+ *
37
+ * @example
38
+ * ```tsx
39
+ * <FormField>
40
+ * <FormLabel>Email</FormLabel>
41
+ * <FormControl>
42
+ * {(fieldProps) => <input type="email" {...fieldProps} />}
43
+ * </FormControl>
44
+ * <FormDescription>We'll never share your email.</FormDescription>
45
+ * <FormError>{errors().email}</FormError>
46
+ * </FormField>
47
+ * ```
48
+ */
49
+ export function FormField(props: FormFieldProps): JSX.Element {
50
+ // eslint-disable-next-line solid/reactivity -- id is stable for a field's lifetime
51
+ const id = props.id ?? createUniqueId()
52
+ const value: FormFieldContextValue = {
53
+ id,
54
+ describedBy: `${id}-desc ${id}-error`,
55
+ }
56
+ return (
57
+ <FormFieldContext.Provider value={value}>
58
+ <div class={cn('space-y-1.5', props.class)}>{props.children}</div>
59
+ </FormFieldContext.Provider>
60
+ )
61
+ }
62
+
63
+ /** Label associated with the enclosing `FormField`'s control via `for`. */
64
+ export function FormLabel(props: { children: JSX.Element; class?: string }): JSX.Element {
65
+ const ctx = useFormFieldContext()
66
+ return (
67
+ <label for={ctx.id} class={cn('text-sm font-medium text-foreground', props.class)}>
68
+ {props.children}
69
+ </label>
70
+ )
71
+ }
72
+
73
+ /**
74
+ * Render-prop bridge that hands the enclosing `FormField`'s `id` and
75
+ * `aria-describedby` to the caller's control, to be spread onto the actual
76
+ * input/select/textarea element.
77
+ */
78
+ export function FormControl(props: {
79
+ children: (fieldProps: { id: string; 'aria-describedby': string }) => JSX.Element
80
+ }): JSX.Element {
81
+ const ctx = useFormFieldContext()
82
+ return <>{props.children({ id: ctx.id, 'aria-describedby': ctx.describedBy })}</>
83
+ }
84
+
85
+ /** Helper text for the enclosing `FormField`, linked via `aria-describedby`. */
86
+ export function FormDescription(props: { children: JSX.Element; class?: string }): JSX.Element {
87
+ const ctx = useFormFieldContext()
88
+ return (
89
+ <p id={`${ctx.id}-desc`} class={cn('text-xs text-muted-foreground', props.class)}>
90
+ {props.children}
91
+ </p>
92
+ )
93
+ }
94
+
95
+ /**
96
+ * Error text for the enclosing `FormField`, linked via `aria-describedby`.
97
+ * Renders nothing when there is no error to show.
98
+ */
99
+ export function FormError(props: { children?: JSX.Element; class?: string }): JSX.Element {
100
+ const ctx = useFormFieldContext()
101
+ return (
102
+ <Show when={props.children}>
103
+ <p id={`${ctx.id}-error`} role="alert" class={cn('text-xs text-destructive', props.class)}>
104
+ {props.children}
105
+ </p>
106
+ </Show>
107
+ )
108
+ }
@@ -0,0 +1,53 @@
1
+ // Highlight — renders text with every case-insensitive occurrence of a query
2
+ // wrapped in a <mark>. Design-system primitive: no business vocabulary, no
3
+ // hardcoded colors — the mark uses semantic tokens so it tracks the theme.
4
+ import { For, type JSX } from 'solid-js'
5
+
6
+ import { cn } from '../lib/cn'
7
+
8
+ export interface HighlightProps {
9
+ /** Full text to render. */
10
+ text: string
11
+ /** Substring to highlight; every case-insensitive occurrence is marked. */
12
+ query: string
13
+ class?: string
14
+ }
15
+
16
+ /** Escape regex metacharacters so an arbitrary query matches literally. */
17
+ function escapeRegExp(value: string): string {
18
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
19
+ }
20
+
21
+ /**
22
+ * Highlights every case-insensitive occurrence of `query` within `text`.
23
+ * When `query` is empty the text renders unchanged.
24
+ *
25
+ * @example
26
+ * ```tsx
27
+ * <Highlight text="Sonora Precision" query="prec" />
28
+ * ```
29
+ */
30
+ export function Highlight(props: HighlightProps): JSX.Element {
31
+ const segments = (): { text: string; match: boolean }[] => {
32
+ if (!props.query) return [{ text: props.text, match: false }]
33
+ const re = new RegExp(`(${escapeRegExp(props.query)})`, 'gi')
34
+ const q = props.query.toLowerCase()
35
+ return props.text
36
+ .split(re)
37
+ .filter((part) => part !== '')
38
+ .map((part) => ({ text: part, match: part.toLowerCase() === q }))
39
+ }
40
+ return (
41
+ <span class={cn(props.class)}>
42
+ <For each={segments()}>
43
+ {(segment) =>
44
+ segment.match ? (
45
+ <mark class="rounded bg-primary/30 px-0.5 text-foreground">{segment.text}</mark>
46
+ ) : (
47
+ segment.text
48
+ )
49
+ }
50
+ </For>
51
+ </span>
52
+ )
53
+ }
@@ -0,0 +1,141 @@
1
+ // A destructive/important-action button that requires a press-and-HOLD (not a
2
+ // single click) to confirm — the fill sweeping across the button doubles as a
3
+ // "how much longer" progress affordance, guarding against accidental taps.
4
+ import type { JSX } from 'solid-js'
5
+ import { createSignal, onCleanup } from 'solid-js'
6
+
7
+ import { cn } from '../lib/cn'
8
+ import { animate, motionReduced } from '../lib/motion'
9
+
10
+ export interface HoldToConfirmProps {
11
+ onConfirm: () => void
12
+ /** Button label. @default 'Hold to confirm' */
13
+ label?: string
14
+ /** How long to hold, in ms. @default 1200 */
15
+ holdMs?: number
16
+ disabled?: boolean
17
+ class?: string
18
+ }
19
+
20
+ /**
21
+ * Press-and-hold button for destructive/important actions: `onConfirm` only
22
+ * fires once the pointer (or Space/Enter) has been held down for `holdMs`.
23
+ * A fill layer sweeps left-to-right as visual progress; releasing early stops
24
+ * and resets it. Under reduced motion the hold is still required (gated by a
25
+ * plain timer instead of the tracked animation), but the fill jumps instead
26
+ * of sweeping and the success flourish is skipped.
27
+ *
28
+ * @example
29
+ * ```tsx
30
+ * <HoldToConfirm label="Hold to delete" holdMs={1500} onConfirm={() => deleteItem(id)} />
31
+ * ```
32
+ */
33
+ export function HoldToConfirm(props: HoldToConfirmProps): JSX.Element {
34
+ const [holding, setHolding] = createSignal(false)
35
+
36
+ let buttonEl: HTMLButtonElement | undefined
37
+ let fillEl: HTMLSpanElement | undefined
38
+ let controls: ReturnType<typeof animate> | undefined
39
+ let timer: ReturnType<typeof setTimeout> | undefined
40
+ let active = false
41
+
42
+ const holdMs = () => props.holdMs ?? 1200
43
+
44
+ const resetFill = (durationSec: number) => {
45
+ if (!fillEl) return
46
+ if (motionReduced()) {
47
+ fillEl.style.transform = 'scaleX(0)'
48
+ return
49
+ }
50
+ animate(fillEl, { scaleX: 0 }, { duration: durationSec, ease: 'easeOut' })
51
+ }
52
+
53
+ const succeed = () => {
54
+ active = false
55
+ setHolding(false)
56
+ props.onConfirm()
57
+ if (!motionReduced() && buttonEl) {
58
+ animate(buttonEl, { scale: [1, 1.04, 1] }, { duration: 0.3, ease: 'easeOut' })
59
+ }
60
+ resetFill(0.2)
61
+ }
62
+
63
+ const cancel = () => {
64
+ if (!active) return
65
+ active = false
66
+ setHolding(false)
67
+ controls?.stop()
68
+ if (timer !== undefined) {
69
+ clearTimeout(timer)
70
+ timer = undefined
71
+ }
72
+ resetFill(0.15)
73
+ }
74
+
75
+ const start = (event: PointerEvent | KeyboardEvent) => {
76
+ if (props.disabled || active) return
77
+ event.preventDefault()
78
+ active = true
79
+ setHolding(true)
80
+
81
+ if (motionReduced()) {
82
+ if (fillEl) fillEl.style.transform = 'scaleX(1)'
83
+ timer = setTimeout(() => {
84
+ if (active) succeed()
85
+ }, holdMs())
86
+ return
87
+ }
88
+
89
+ if (!fillEl) return
90
+ controls = animate(fillEl, { scaleX: [0, 1] }, { duration: holdMs() / 1000, ease: 'linear' })
91
+ controls.finished
92
+ // eslint-disable-next-line solid/reactivity -- promise callback, not a tracked scope; setHolding runs fine here
93
+ .then(() => {
94
+ if (active) succeed()
95
+ })
96
+ .catch(() => {})
97
+ }
98
+
99
+ const onKeyDown = (event: KeyboardEvent) => {
100
+ if (event.key !== ' ' && event.key !== 'Enter') return
101
+ if (event.repeat) return
102
+ start(event)
103
+ }
104
+
105
+ const onKeyUp = (event: KeyboardEvent) => {
106
+ if (event.key !== ' ' && event.key !== 'Enter') return
107
+ cancel()
108
+ }
109
+
110
+ onCleanup(() => {
111
+ controls?.stop()
112
+ if (timer !== undefined) clearTimeout(timer)
113
+ })
114
+
115
+ return (
116
+ <button
117
+ ref={buttonEl}
118
+ type="button"
119
+ disabled={props.disabled}
120
+ aria-label={props.label ?? 'Hold to confirm'}
121
+ aria-busy={holding()}
122
+ class={cn(
123
+ 'relative overflow-hidden rounded-lg border border-border bg-card px-4 py-2 text-sm font-medium disabled:pointer-events-none disabled:opacity-50',
124
+ props.class,
125
+ )}
126
+ onPointerDown={start}
127
+ onPointerUp={cancel}
128
+ onPointerLeave={cancel}
129
+ onKeyDown={onKeyDown}
130
+ onKeyUp={onKeyUp}
131
+ >
132
+ <span
133
+ aria-hidden="true"
134
+ ref={fillEl}
135
+ class="absolute inset-0 origin-left bg-primary/25"
136
+ style={{ transform: 'scaleX(0)' }}
137
+ />
138
+ <span class="relative z-10">{props.label ?? 'Hold to confirm'}</span>
139
+ </button>
140
+ )
141
+ }
@@ -0,0 +1,42 @@
1
+ // Hover-triggered floating panel on Kobalte's HoverCard primitive.
2
+ import { HoverCard as KHoverCard } from '@kobalte/core/hover-card'
3
+ import type { JSX, ParentProps } from 'solid-js'
4
+
5
+ import { cn } from '../lib/cn'
6
+
7
+ interface HoverCardProps extends ParentProps {
8
+ /** Content that opens the card on hover/focus. */
9
+ trigger: JSX.Element
10
+ class?: string
11
+ }
12
+
13
+ /**
14
+ * Floating panel that appears on hover (or focus) of a trigger element, built on
15
+ * Kobalte's `HoverCard` primitive. Good for previews/tooltips with richer content
16
+ * than a plain `title` attribute — e.g. a user avatar preview card.
17
+ *
18
+ * @example
19
+ * ```tsx
20
+ * <HoverCard trigger={<Avatar src={user.avatar} />}>
21
+ * <p>{user.name}</p>
22
+ * </HoverCard>
23
+ * ```
24
+ */
25
+ export function HoverCard(props: HoverCardProps): JSX.Element {
26
+ return (
27
+ <KHoverCard>
28
+ <KHoverCard.Trigger class="inline-flex">{props.trigger}</KHoverCard.Trigger>
29
+ <KHoverCard.Portal>
30
+ <KHoverCard.Content
31
+ class={cn(
32
+ 'bg-glass z-50 rounded-lg border border-border p-4 text-card-foreground shadow-lg',
33
+ props.class,
34
+ )}
35
+ >
36
+ <KHoverCard.Arrow />
37
+ {props.children}
38
+ </KHoverCard.Content>
39
+ </KHoverCard.Portal>
40
+ </KHoverCard>
41
+ )
42
+ }