@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.
Files changed (126) hide show
  1. package/README.md +36 -26
  2. package/dist/full.css +3042 -0
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +1 -1
  5. package/package.json +8 -1
  6. package/src/charts/BarChart.tsx +55 -0
  7. package/src/charts/DonutChart.tsx +127 -0
  8. package/src/charts/Sparkline.tsx +101 -0
  9. package/src/charts/index.ts +5 -0
  10. package/src/commerce/CartLine.tsx +84 -0
  11. package/src/commerce/CartSummary.tsx +70 -0
  12. package/src/commerce/FilterGroup.tsx +70 -0
  13. package/src/commerce/PriceTag.tsx +62 -0
  14. package/src/commerce/ProductCard.tsx +121 -0
  15. package/src/commerce/ProductGrid.tsx +27 -0
  16. package/src/commerce/QuantityStepper.tsx +59 -0
  17. package/src/commerce/index.ts +10 -0
  18. package/src/elements.tsx +167 -0
  19. package/src/index.ts +166 -0
  20. package/src/layout/AppShell.tsx +102 -0
  21. package/src/layout/ChristmasBackground.tsx +177 -0
  22. package/src/layout/EffectsToggle.tsx +41 -0
  23. package/src/layout/NavGroup.tsx +45 -0
  24. package/src/layout/SnowScenery.tsx +76 -0
  25. package/src/layout/SpaceBackground.tsx +459 -0
  26. package/src/layout/ThemeToggle.tsx +40 -0
  27. package/src/layout/ThemedScenery.tsx +179 -0
  28. package/src/layout/sceneEffects.ts +49 -0
  29. package/src/lib/cn.ts +17 -0
  30. package/src/lib/effects.ts +71 -0
  31. package/src/lib/media.ts +27 -0
  32. package/src/lib/motion.ts +191 -0
  33. package/src/lib/theme.ts +113 -0
  34. package/src/lib/virtual.ts +33 -0
  35. package/src/styles/space.css +510 -0
  36. package/src/styles/tokens.css +213 -0
  37. package/src/themes/index.ts +102 -0
  38. package/src/themes/palettes.ts +368 -0
  39. package/src/ui/Accordion.tsx +65 -0
  40. package/src/ui/Affix.tsx +72 -0
  41. package/src/ui/Alert.tsx +55 -0
  42. package/src/ui/AlertDialog.tsx +58 -0
  43. package/src/ui/Anchor.tsx +100 -0
  44. package/src/ui/Avatar.tsx +34 -0
  45. package/src/ui/AvatarGroup.tsx +55 -0
  46. package/src/ui/BackToTop.tsx +51 -0
  47. package/src/ui/Badge.tsx +46 -0
  48. package/src/ui/BottomNavigation.tsx +65 -0
  49. package/src/ui/Breadcrumb.tsx +64 -0
  50. package/src/ui/Button.tsx +49 -0
  51. package/src/ui/Calendar.tsx +40 -0
  52. package/src/ui/CalendarHeatmap.tsx +180 -0
  53. package/src/ui/Card.tsx +84 -0
  54. package/src/ui/Carousel.tsx +121 -0
  55. package/src/ui/Cascader.tsx +158 -0
  56. package/src/ui/Checkbox.tsx +35 -0
  57. package/src/ui/Clock.tsx +221 -0
  58. package/src/ui/Collapse.tsx +48 -0
  59. package/src/ui/ColorPicker.tsx +89 -0
  60. package/src/ui/Combobox.tsx +67 -0
  61. package/src/ui/Command.tsx +158 -0
  62. package/src/ui/Comment.tsx +91 -0
  63. package/src/ui/ContextMenu.tsx +58 -0
  64. package/src/ui/Countdown.tsx +123 -0
  65. package/src/ui/DataGrid.tsx +177 -0
  66. package/src/ui/DateField.tsx +177 -0
  67. package/src/ui/DateRangePicker.tsx +204 -0
  68. package/src/ui/DateTimeField.tsx +68 -0
  69. package/src/ui/Descriptions.tsx +56 -0
  70. package/src/ui/Drawer.tsx +72 -0
  71. package/src/ui/Dropdown.tsx +71 -0
  72. package/src/ui/Dropzone.tsx +88 -0
  73. package/src/ui/Empty.tsx +53 -0
  74. package/src/ui/FileUpload.tsx +198 -0
  75. package/src/ui/FloatingActionButton.tsx +48 -0
  76. package/src/ui/Form.tsx +108 -0
  77. package/src/ui/Highlight.tsx +53 -0
  78. package/src/ui/HoverCard.tsx +42 -0
  79. package/src/ui/Image.tsx +66 -0
  80. package/src/ui/Input.tsx +38 -0
  81. package/src/ui/Kbd.tsx +25 -0
  82. package/src/ui/List.tsx +71 -0
  83. package/src/ui/Marquee.tsx +48 -0
  84. package/src/ui/Mentions.tsx +170 -0
  85. package/src/ui/Meter.tsx +57 -0
  86. package/src/ui/Modal.tsx +106 -0
  87. package/src/ui/MultiSelect.tsx +236 -0
  88. package/src/ui/NotificationCenter.tsx +103 -0
  89. package/src/ui/NumberInput.tsx +48 -0
  90. package/src/ui/PageHeader.tsx +60 -0
  91. package/src/ui/Pagination.tsx +62 -0
  92. package/src/ui/Popover.tsx +42 -0
  93. package/src/ui/Portal.tsx +34 -0
  94. package/src/ui/Progress.tsx +55 -0
  95. package/src/ui/RadioGroup.tsx +65 -0
  96. package/src/ui/Rating.tsx +98 -0
  97. package/src/ui/Result.tsx +75 -0
  98. package/src/ui/RingProgress.tsx +75 -0
  99. package/src/ui/SegmentedControl.tsx +61 -0
  100. package/src/ui/Select.tsx +55 -0
  101. package/src/ui/Separator.tsx +39 -0
  102. package/src/ui/Skeleton.tsx +23 -0
  103. package/src/ui/Slider.tsx +57 -0
  104. package/src/ui/Sortable.tsx +174 -0
  105. package/src/ui/SpeedDial.tsx +75 -0
  106. package/src/ui/Spinner.tsx +33 -0
  107. package/src/ui/Splitter.tsx +115 -0
  108. package/src/ui/Stat.tsx +74 -0
  109. package/src/ui/Stepper.tsx +134 -0
  110. package/src/ui/Switch.tsx +44 -0
  111. package/src/ui/Table.tsx +128 -0
  112. package/src/ui/Tabs.tsx +72 -0
  113. package/src/ui/TagInput.tsx +85 -0
  114. package/src/ui/Textarea.tsx +38 -0
  115. package/src/ui/TimeField.tsx +298 -0
  116. package/src/ui/Timeline.tsx +89 -0
  117. package/src/ui/Toast.tsx +68 -0
  118. package/src/ui/Toggle.tsx +41 -0
  119. package/src/ui/ToggleGroup.tsx +59 -0
  120. package/src/ui/Tooltip.tsx +42 -0
  121. package/src/ui/Tour.tsx +197 -0
  122. package/src/ui/Transfer.tsx +112 -0
  123. package/src/ui/Tree.tsx +100 -0
  124. package/src/ui/TreeSelect.tsx +151 -0
  125. package/src/ui/VirtualList.tsx +81 -0
  126. 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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }