@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,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
|
+
}
|
package/src/ui/Form.tsx
ADDED
|
@@ -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
|
+
}
|