@a4ui/core 0.11.1 → 0.12.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 +36 -26
- package/dist/full.css +3042 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- 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 +166 -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/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/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/FileUpload.tsx +198 -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/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/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/NotificationCenter.tsx +103 -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/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/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 +75 -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/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,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,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
|
+
}
|
package/src/ui/Image.tsx
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Content image with an optional click-to-zoom lightbox, built on Kobalte's
|
|
2
|
+
// Dialog primitive. The thumbnail lazy-loads; when `preview` is on it becomes a
|
|
3
|
+
// button that opens a dimmed, backdrop-blurred overlay with an enlarged copy of
|
|
4
|
+
// the image and a corner close button (focus trap/portal handled by Kobalte).
|
|
5
|
+
import { Dialog } from '@kobalte/core/dialog'
|
|
6
|
+
import { X } from 'lucide-solid'
|
|
7
|
+
import type { JSX } from 'solid-js'
|
|
8
|
+
import { createSignal, Show } from 'solid-js'
|
|
9
|
+
|
|
10
|
+
import { cn } from '../lib/cn'
|
|
11
|
+
|
|
12
|
+
export interface ImageProps {
|
|
13
|
+
src: string
|
|
14
|
+
alt: string
|
|
15
|
+
class?: string
|
|
16
|
+
/** When true, clicking the image opens a zoomable lightbox. @default true */
|
|
17
|
+
preview?: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Lazy-loaded content image. Unless `preview` is disabled, the thumbnail is a
|
|
22
|
+
* zoom-in button that opens a centered lightbox (dimmed overlay + close button)
|
|
23
|
+
* on Kobalte's `Dialog` primitive.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```tsx
|
|
27
|
+
* <Image src="/photo.jpg" alt="A scenic view" />
|
|
28
|
+
* <Image src="/logo.svg" alt="Logo" preview={false} />
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export function Image(props: ImageProps): JSX.Element {
|
|
32
|
+
const [open, setOpen] = createSignal(false)
|
|
33
|
+
const preview = () => props.preview !== false
|
|
34
|
+
|
|
35
|
+
const img = (
|
|
36
|
+
<img src={props.src} alt={props.alt} loading="lazy" class={cn('rounded-lg object-cover', props.class)} />
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<Show when={preview()} fallback={img}>
|
|
41
|
+
<button type="button" class="cursor-zoom-in" aria-label={props.alt} onClick={() => setOpen(true)}>
|
|
42
|
+
{img}
|
|
43
|
+
</button>
|
|
44
|
+
<Dialog open={open()} onOpenChange={setOpen}>
|
|
45
|
+
<Dialog.Portal>
|
|
46
|
+
<Dialog.Overlay class="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm" />
|
|
47
|
+
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
48
|
+
<Dialog.Content role="dialog" class="relative">
|
|
49
|
+
<img
|
|
50
|
+
src={props.src}
|
|
51
|
+
alt={props.alt}
|
|
52
|
+
class="max-h-[85vh] max-w-[90vw] rounded-lg object-contain"
|
|
53
|
+
/>
|
|
54
|
+
<Dialog.CloseButton
|
|
55
|
+
class="absolute right-2 top-2 grid h-8 w-8 place-items-center rounded-lg bg-background/70 text-foreground transition hover:bg-muted"
|
|
56
|
+
aria-label="Close"
|
|
57
|
+
>
|
|
58
|
+
<X class="h-5 w-5" />
|
|
59
|
+
</Dialog.CloseButton>
|
|
60
|
+
</Dialog.Content>
|
|
61
|
+
</div>
|
|
62
|
+
</Dialog.Portal>
|
|
63
|
+
</Dialog>
|
|
64
|
+
</Show>
|
|
65
|
+
)
|
|
66
|
+
}
|
package/src/ui/Input.tsx
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// `disabled` is wired onto the <input> element (not just styled).
|
|
2
|
+
import type { JSX } from 'solid-js'
|
|
3
|
+
import { splitProps } from 'solid-js'
|
|
4
|
+
|
|
5
|
+
import { cn } from '../lib/cn'
|
|
6
|
+
|
|
7
|
+
const INPUT_BASE =
|
|
8
|
+
'w-full 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 InputProps extends Omit<JSX.InputHTMLAttributes<HTMLInputElement>, 'value' | 'onInput'> {
|
|
11
|
+
value: string
|
|
12
|
+
/** Called with the raw string value on every input event (controlled). */
|
|
13
|
+
onInput: (value: string) => void
|
|
14
|
+
class?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Themed text input with a value/onInput controlled-string API (instead of raw
|
|
19
|
+
* DOM events). All other native `<input>` attributes pass through, so `type`,
|
|
20
|
+
* `disabled`, `placeholder`, etc. work as usual.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```tsx
|
|
24
|
+
* const [name, setName] = createSignal('')
|
|
25
|
+
* <Input value={name()} onInput={setName} placeholder="Full name" />
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export function Input(props: InputProps): JSX.Element {
|
|
29
|
+
const [local, rest] = splitProps(props, ['value', 'onInput', 'class'])
|
|
30
|
+
return (
|
|
31
|
+
<input
|
|
32
|
+
class={cn(INPUT_BASE, local.class)}
|
|
33
|
+
value={local.value}
|
|
34
|
+
onInput={(ev) => local.onInput(ev.currentTarget.value)}
|
|
35
|
+
{...rest}
|
|
36
|
+
/>
|
|
37
|
+
)
|
|
38
|
+
}
|
package/src/ui/Kbd.tsx
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Kbd — keyboard key indicator. Renders a single keycap for documenting
|
|
2
|
+
// shortcuts; compose several side by side for chords (e.g. ⌘ + K). Purely
|
|
3
|
+
// presentational and theme-agnostic — colors come from semantic tokens.
|
|
4
|
+
import type { JSX, ParentProps } from 'solid-js'
|
|
5
|
+
|
|
6
|
+
import { cn } from '../lib/cn'
|
|
7
|
+
|
|
8
|
+
interface KbdProps extends ParentProps {
|
|
9
|
+
class?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const KBD_BASE =
|
|
13
|
+
'inline-flex min-w-[1.5rem] items-center justify-center rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground shadow-sm'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Keycap-styled indicator for a keyboard key or shortcut token.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```tsx
|
|
20
|
+
* <Kbd>⌘</Kbd><Kbd>K</Kbd>
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export function Kbd(props: KbdProps): JSX.Element {
|
|
24
|
+
return <kbd class={cn(KBD_BASE, props.class)}>{props.children}</kbd>
|
|
25
|
+
}
|
package/src/ui/List.tsx
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Vertical list primitive (plain HTML + Tailwind, no headless dep). Renders a
|
|
2
|
+
// semantic <ul> of rows, each with an optional leading avatar, a title +
|
|
3
|
+
// optional description column, and an optional trailing meta/actions slot.
|
|
4
|
+
import { For, Show, type JSX } from 'solid-js'
|
|
5
|
+
|
|
6
|
+
import { cn } from '../lib/cn'
|
|
7
|
+
|
|
8
|
+
/** A single row in a {@link List}. */
|
|
9
|
+
export interface ListItem {
|
|
10
|
+
title: string
|
|
11
|
+
description?: string
|
|
12
|
+
avatar?: JSX.Element
|
|
13
|
+
meta?: JSX.Element
|
|
14
|
+
actions?: JSX.Element
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ListProps {
|
|
18
|
+
items: ListItem[]
|
|
19
|
+
class?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type { ListProps }
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Vertical list of rows inside a rounded, bordered container with divider
|
|
26
|
+
* lines between items. Each item shows an optional leading `avatar`, a
|
|
27
|
+
* `title` with optional `description`, and an optional trailing `meta` /
|
|
28
|
+
* `actions` slot.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```tsx
|
|
32
|
+
* <List
|
|
33
|
+
* items={[
|
|
34
|
+
* {
|
|
35
|
+
* title: 'Alfredo Rivera',
|
|
36
|
+
* description: 'Owner',
|
|
37
|
+
* avatar: <Avatar name="Alfredo" />,
|
|
38
|
+
* meta: <span>2h ago</span>,
|
|
39
|
+
* actions: <Button size="sm">Edit</Button>,
|
|
40
|
+
* },
|
|
41
|
+
* ]}
|
|
42
|
+
* />
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export function List(props: ListProps): JSX.Element {
|
|
46
|
+
return (
|
|
47
|
+
<ul class={cn('divide-y divide-border rounded-lg border border-border', props.class)}>
|
|
48
|
+
<For each={props.items}>
|
|
49
|
+
{(item) => (
|
|
50
|
+
<li class="flex items-center gap-3 px-4 py-3">
|
|
51
|
+
<Show when={item.avatar}>
|
|
52
|
+
<div class="shrink-0">{item.avatar}</div>
|
|
53
|
+
</Show>
|
|
54
|
+
<div class="min-w-0 flex-1">
|
|
55
|
+
<p class="text-sm font-medium text-foreground">{item.title}</p>
|
|
56
|
+
<Show when={item.description}>
|
|
57
|
+
<p class="text-xs text-muted-foreground">{item.description}</p>
|
|
58
|
+
</Show>
|
|
59
|
+
</div>
|
|
60
|
+
<Show when={item.meta}>
|
|
61
|
+
<div class="text-xs text-muted-foreground">{item.meta}</div>
|
|
62
|
+
</Show>
|
|
63
|
+
<Show when={item.actions}>
|
|
64
|
+
<div class="shrink-0">{item.actions}</div>
|
|
65
|
+
</Show>
|
|
66
|
+
</li>
|
|
67
|
+
)}
|
|
68
|
+
</For>
|
|
69
|
+
</ul>
|
|
70
|
+
)
|
|
71
|
+
}
|