@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,221 @@
1
+ // Live clock that renders the current time as either a digital readout or an
2
+ // analog dial. A single setInterval (started in onMount, cleared in
3
+ // onCleanup) ticks a `Date` signal once per second; both variants derive their
4
+ // hour/minute/second parts from that same signal, using `Intl.DateTimeFormat`
5
+ // when a specific `timeZone` is requested.
6
+ import { createSignal, onCleanup, onMount, Show, For, type JSX } from 'solid-js'
7
+
8
+ import { cn } from '../lib/cn'
9
+
10
+ export interface ClockProps {
11
+ /** 'digital' (default) or 'analog'. */
12
+ variant?: 'digital' | 'analog'
13
+ /** Show the seconds hand / seconds digits. @default true */
14
+ seconds?: boolean
15
+ /** 12-hour clock with AM/PM instead of 24-hour. @default false */
16
+ hour12?: boolean
17
+ /** IANA time zone (e.g. 'America/Hermosillo'); defaults to the local zone. */
18
+ timeZone?: string
19
+ /** Diameter in px for the analog face. @default 160 */
20
+ size?: number
21
+ class?: string
22
+ }
23
+
24
+ const pad = (n: number): string => String(n).padStart(2, '0')
25
+
26
+ interface TimeParts {
27
+ hours: number
28
+ minutes: number
29
+ seconds: number
30
+ }
31
+
32
+ /**
33
+ * Read h/m/s out of a Date, in `timeZone` if given, else the local zone.
34
+ * Always returns 24h parts — AM/PM and the 12h clamp are derived by the
35
+ * caller, so display formatting stays consistent whether or not a timeZone
36
+ * is set.
37
+ */
38
+ function partsOf(date: Date, timeZone: string | undefined): TimeParts {
39
+ if (!timeZone) {
40
+ return { hours: date.getHours(), minutes: date.getMinutes(), seconds: date.getSeconds() }
41
+ }
42
+ const formatted = new Intl.DateTimeFormat('en-US', {
43
+ timeZone,
44
+ hour12: false,
45
+ hour: '2-digit',
46
+ minute: '2-digit',
47
+ second: '2-digit',
48
+ }).formatToParts(date)
49
+ const get = (type: string) => Number(formatted.find((p) => p.type === type)?.value ?? 0)
50
+ return { hours: get('hour') % 24, minutes: get('minute'), seconds: get('second') }
51
+ }
52
+
53
+ /** Digital HH:MM(:SS) readout, zero-padded, with an optional AM/PM suffix. */
54
+ function DigitalClock(props: {
55
+ parts: TimeParts
56
+ seconds: boolean
57
+ hour12: boolean
58
+ class?: string
59
+ }): JSX.Element {
60
+ const display = () => {
61
+ const { hours, minutes, seconds } = props.parts
62
+ let h = hours
63
+ let suffix = ''
64
+ if (props.hour12) {
65
+ suffix = h >= 12 ? 'PM' : 'AM'
66
+ h = h % 12
67
+ if (h === 0) h = 12
68
+ }
69
+ const time = props.seconds ? `${pad(h)}:${pad(minutes)}:${pad(seconds)}` : `${pad(h)}:${pad(minutes)}`
70
+ return { time, suffix }
71
+ }
72
+
73
+ return (
74
+ <div class={cn('flex flex-col items-center justify-center', props.class)}>
75
+ <span class="flex items-baseline gap-2 tabular-nums text-4xl font-bold tracking-tight text-foreground">
76
+ <span>{display().time}</span>
77
+ <Show when={props.hour12}>
78
+ <span class="text-base font-normal text-muted-foreground">{display().suffix}</span>
79
+ </Show>
80
+ </span>
81
+ </div>
82
+ )
83
+ }
84
+
85
+ /** Analog dial: face circle, 12 hour ticks, hour/minute/(second) hands, hub. */
86
+ function AnalogClock(props: {
87
+ parts: TimeParts
88
+ seconds: boolean
89
+ size: number
90
+ class?: string
91
+ }): JSX.Element {
92
+ const size = () => props.size
93
+ const cx = () => size() / 2
94
+ const cy = () => size() / 2
95
+ const radius = () => size() / 2
96
+
97
+ const hourDeg = () => (props.parts.hours % 12) * 30 + props.parts.minutes * 0.5
98
+ const minuteDeg = () => props.parts.minutes * 6 + props.parts.seconds * 0.1
99
+ const secondDeg = () => props.parts.seconds * 6
100
+
101
+ const ticks = Array.from({ length: 12 }, (_, i) => i)
102
+
103
+ const handPoints = (lengthRatio: number) => {
104
+ const r = radius() * lengthRatio
105
+ return `${cx()},${cy()} ${cx()},${cy() - r}`
106
+ }
107
+
108
+ return (
109
+ <svg
110
+ width={size()}
111
+ height={size()}
112
+ viewBox={`0 0 ${size()} ${size()}`}
113
+ class={cn('inline-block', props.class)}
114
+ role="img"
115
+ aria-label="Analog clock"
116
+ >
117
+ <circle cx={cx()} cy={cy()} r={radius() - 2} fill="none" stroke="hsl(var(--border))" stroke-width={2} />
118
+ <For each={ticks}>
119
+ {(i) => {
120
+ const angle = i * 30
121
+ const outer = radius() - 4
122
+ const inner = radius() - (i % 3 === 0 ? 14 : 9)
123
+ const x1 = cx() + outer * Math.sin((angle * Math.PI) / 180)
124
+ const y1 = cy() - outer * Math.cos((angle * Math.PI) / 180)
125
+ const x2 = cx() + inner * Math.sin((angle * Math.PI) / 180)
126
+ const y2 = cy() - inner * Math.cos((angle * Math.PI) / 180)
127
+ return (
128
+ <line
129
+ x1={x1}
130
+ y1={y1}
131
+ x2={x2}
132
+ y2={y2}
133
+ stroke="hsl(var(--muted-foreground))"
134
+ stroke-width={i % 3 === 0 ? 2 : 1}
135
+ stroke-linecap="round"
136
+ />
137
+ )
138
+ }}
139
+ </For>
140
+ {/* Hands rotate via CSS transform. In CSS, rotate() takes only an angle,
141
+ so the pivot is set with transform-origin: center + transform-box:
142
+ view-box (which resolves 'center' against the SVG viewBox = the dial
143
+ center). The SVG-attribute form rotate(angle cx cy) would not work here. */}
144
+ <polyline
145
+ points={handPoints(0.45)}
146
+ stroke="hsl(var(--foreground))"
147
+ stroke-width={4}
148
+ stroke-linecap="round"
149
+ style={{
150
+ transform: `rotate(${hourDeg()}deg)`,
151
+ 'transform-origin': 'center',
152
+ 'transform-box': 'view-box',
153
+ }}
154
+ />
155
+ <polyline
156
+ points={handPoints(0.65)}
157
+ stroke="hsl(var(--foreground))"
158
+ stroke-width={3}
159
+ stroke-linecap="round"
160
+ style={{
161
+ transform: `rotate(${minuteDeg()}deg)`,
162
+ 'transform-origin': 'center',
163
+ 'transform-box': 'view-box',
164
+ }}
165
+ />
166
+ <Show when={props.seconds}>
167
+ <polyline
168
+ points={handPoints(0.75)}
169
+ stroke="hsl(var(--primary))"
170
+ stroke-width={1.5}
171
+ stroke-linecap="round"
172
+ style={{
173
+ transform: `rotate(${secondDeg()}deg)`,
174
+ 'transform-origin': 'center',
175
+ 'transform-box': 'view-box',
176
+ }}
177
+ />
178
+ </Show>
179
+ <circle cx={cx()} cy={cy()} r={3.5} fill="hsl(var(--primary))" />
180
+ </svg>
181
+ )
182
+ }
183
+
184
+ /**
185
+ * Live, self-updating clock. Ticks once per second from a single interval
186
+ * (started in `onMount`, cleared in `onCleanup`) and renders either a digital
187
+ * `HH:MM:SS` readout or an analog SVG dial. Pass `timeZone` to show a specific
188
+ * IANA zone instead of the viewer's local time.
189
+ *
190
+ * @example
191
+ * ```tsx
192
+ * <Clock />
193
+ * <Clock variant="analog" size={200} timeZone="America/Hermosillo" />
194
+ * <Clock hour12 seconds={false} />
195
+ * ```
196
+ */
197
+ export function Clock(props: ClockProps): JSX.Element {
198
+ const [now, setNow] = createSignal(new Date())
199
+
200
+ onMount(() => {
201
+ const id = setInterval(() => setNow(new Date()), 1000)
202
+ onCleanup(() => clearInterval(id))
203
+ })
204
+
205
+ const variant = () => props.variant ?? 'digital'
206
+ const showSeconds = () => props.seconds ?? true
207
+ const hour12 = () => props.hour12 ?? false
208
+ const size = () => props.size ?? 160
209
+ const parts = () => partsOf(now(), props.timeZone)
210
+
211
+ return (
212
+ <Show
213
+ when={variant() === 'analog'}
214
+ fallback={
215
+ <DigitalClock parts={parts()} seconds={showSeconds()} hour12={hour12()} class={props.class} />
216
+ }
217
+ >
218
+ <AnalogClock parts={parts()} seconds={showSeconds()} size={size()} class={props.class} />
219
+ </Show>
220
+ )
221
+ }
@@ -0,0 +1,48 @@
1
+ // Single controlled collapsible region with a toggle header.
2
+ import { ChevronDown } from 'lucide-solid'
3
+ import type { JSX, ParentProps } from 'solid-js'
4
+ import { Show } from 'solid-js'
5
+ import { cn } from '../lib/cn'
6
+
7
+ interface CollapseProps extends ParentProps {
8
+ /** Whether the panel is currently expanded. */
9
+ open: boolean
10
+ /** Called with the next open state when the header is toggled. */
11
+ onOpenChange: (open: boolean) => void
12
+ /** Text shown in the header button. */
13
+ title: string
14
+ class?: string
15
+ }
16
+
17
+ /**
18
+ * A single controlled collapsible region: a header button that toggles a panel
19
+ * of arbitrary children. State is owned by the caller via `open`/`onOpenChange`.
20
+ *
21
+ * @example
22
+ * ```tsx
23
+ * const [open, setOpen] = createSignal(false)
24
+ * <Collapse open={open()} onOpenChange={setOpen} title="Details">
25
+ * <p>Hidden content revealed on toggle.</p>
26
+ * </Collapse>
27
+ * ```
28
+ */
29
+ export function Collapse(props: CollapseProps): JSX.Element {
30
+ return (
31
+ <div class={cn(props.class)}>
32
+ <button
33
+ type="button"
34
+ aria-expanded={props.open}
35
+ onClick={() => props.onOpenChange(!props.open)}
36
+ class="flex w-full items-center justify-between rounded-md px-3 py-2 text-sm font-medium text-foreground hover:bg-muted"
37
+ >
38
+ {props.title}
39
+ <ChevronDown
40
+ class={cn('h-4 w-4 shrink-0 transition-transform duration-200', props.open && 'rotate-180')}
41
+ />
42
+ </button>
43
+ <Show when={props.open}>
44
+ <div class="px-3 py-2 text-sm text-muted-foreground">{props.children}</div>
45
+ </Show>
46
+ </div>
47
+ )
48
+ }
@@ -0,0 +1,89 @@
1
+ // The hex text field keeps a local buffer so half-typed values (e.g. "#3b8")
2
+ // don't get pushed upstream; only a full valid 6-digit hex calls onChange.
3
+ // Swatches use inline `background` on purpose — they render arbitrary user
4
+ // colors, which is the one legitimate place to bypass the theme tokens.
5
+ import type { JSX } from 'solid-js'
6
+ import { createEffect, createSignal, For } from 'solid-js'
7
+
8
+ import { cn } from '../lib/cn'
9
+
10
+ const HEX_RE = /^#[0-9a-fA-F]{6}$/
11
+
12
+ const DEFAULT_SWATCHES = [
13
+ '#ef4444',
14
+ '#f97316',
15
+ '#eab308',
16
+ '#22c55e',
17
+ '#3b82f6',
18
+ '#8b5cf6',
19
+ '#ec4899',
20
+ '#64748b',
21
+ ]
22
+
23
+ export interface ColorPickerProps {
24
+ /** Current color as a `#rrggbb` hex string. */
25
+ value: string
26
+ /** Called with a valid `#rrggbb` hex whenever the user picks a new color. */
27
+ onChange: (hex: string) => void
28
+ /** Preset colors shown as clickable swatches. Defaults to a small palette. */
29
+ swatches?: string[]
30
+ class?: string
31
+ }
32
+
33
+ /**
34
+ * Controlled color picker: a native color well, a hex text field, and a row of
35
+ * preset swatches. The text field validates against `#rrggbb` and only reports
36
+ * complete, valid hex values while letting you type freely in between.
37
+ *
38
+ * @example
39
+ * ```tsx
40
+ * const [color, setColor] = createSignal('#3b82f6')
41
+ * <ColorPicker value={color()} onChange={setColor} />
42
+ * ```
43
+ */
44
+ export function ColorPicker(props: ColorPickerProps): JSX.Element {
45
+ const [text, setText] = createSignal(props.value)
46
+
47
+ // Keep the text buffer in sync when the value changes from outside.
48
+ createEffect(() => setText(props.value))
49
+
50
+ const swatches = () => props.swatches ?? DEFAULT_SWATCHES
51
+
52
+ const onHexInput = (value: string): void => {
53
+ setText(value)
54
+ if (HEX_RE.test(value)) props.onChange(value)
55
+ }
56
+
57
+ return (
58
+ <div class={cn('flex flex-wrap items-center gap-3', props.class)}>
59
+ <input
60
+ type="color"
61
+ aria-label="Pick a color"
62
+ class="h-10 w-14 cursor-pointer rounded-md border border-border bg-transparent"
63
+ value={props.value}
64
+ onInput={(ev) => props.onChange(ev.currentTarget.value)}
65
+ />
66
+ <input
67
+ class="rounded-md border border-input bg-background px-3 py-2 text-sm font-mono"
68
+ value={text()}
69
+ onInput={(ev) => onHexInput(ev.currentTarget.value)}
70
+ />
71
+ <div class="flex w-full flex-wrap items-center gap-2">
72
+ <For each={swatches()}>
73
+ {(hex) => (
74
+ <button
75
+ type="button"
76
+ aria-label={hex}
77
+ style={{ background: hex }}
78
+ class={cn(
79
+ 'h-6 w-6 rounded-md border border-border transition-transform active:scale-90 focus:outline-none focus:ring-2 focus:ring-ring',
80
+ hex === props.value && 'ring-2 ring-ring ring-offset-1 ring-offset-background',
81
+ )}
82
+ onClick={() => props.onChange(hex)}
83
+ />
84
+ )}
85
+ </For>
86
+ </div>
87
+ </div>
88
+ )
89
+ }
@@ -0,0 +1,67 @@
1
+ // Searchable single-select on Kobalte's Combobox primitive (string options).
2
+ import { Combobox as KCombobox } from '@kobalte/core/combobox'
3
+ import { Check, ChevronsUpDown } from 'lucide-solid'
4
+ import type { JSX } from 'solid-js'
5
+
6
+ import { cn } from '../lib/cn'
7
+
8
+ interface ComboboxProps {
9
+ /** Full list of selectable string options (filtered as the user types). */
10
+ options: string[]
11
+ value: string
12
+ /** Called with the newly selected option, or `''` if the selection is cleared. */
13
+ onChange: (value: string) => void
14
+ placeholder?: string
15
+ class?: string
16
+ }
17
+
18
+ /**
19
+ * Searchable single-select dropdown for plain string options, built on
20
+ * Kobalte's `Combobox` primitive with a filterable text input and trigger.
21
+ *
22
+ * @example
23
+ * ```tsx
24
+ * <Combobox
25
+ * options={['Draft', 'Active', 'Archived']}
26
+ * value={status()}
27
+ * onChange={setStatus}
28
+ * placeholder="Select a status"
29
+ * />
30
+ * ```
31
+ */
32
+ export function Combobox(props: ComboboxProps): JSX.Element {
33
+ return (
34
+ <KCombobox
35
+ options={props.options}
36
+ value={props.value}
37
+ onChange={(v) => props.onChange(v ?? '')}
38
+ placeholder={props.placeholder}
39
+ class={cn('w-full', props.class)}
40
+ itemComponent={(itemProps) => (
41
+ <KCombobox.Item
42
+ item={itemProps.item}
43
+ class="flex cursor-pointer select-none items-center justify-between rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-muted"
44
+ >
45
+ <KCombobox.ItemLabel>{itemProps.item.rawValue}</KCombobox.ItemLabel>
46
+ <KCombobox.ItemIndicator>
47
+ <Check class="h-4 w-4" />
48
+ </KCombobox.ItemIndicator>
49
+ </KCombobox.Item>
50
+ )}
51
+ >
52
+ <KCombobox.Control class="flex w-full items-center rounded-md border border-input bg-background text-foreground transition-colors focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/30">
53
+ <KCombobox.Input class="w-full bg-transparent px-3 py-2 text-sm outline-none placeholder:text-muted-foreground" />
54
+ <KCombobox.Trigger class="grid h-9 w-9 shrink-0 place-items-center text-muted-foreground">
55
+ <KCombobox.Icon>
56
+ <ChevronsUpDown class="h-4 w-4" />
57
+ </KCombobox.Icon>
58
+ </KCombobox.Trigger>
59
+ </KCombobox.Control>
60
+ <KCombobox.Portal>
61
+ <KCombobox.Content class="z-50 overflow-hidden rounded-md border border-border bg-card p-1 text-card-foreground shadow-sm">
62
+ <KCombobox.Listbox class="max-h-60 overflow-y-auto" />
63
+ </KCombobox.Content>
64
+ </KCombobox.Portal>
65
+ </KCombobox>
66
+ )
67
+ }
@@ -0,0 +1,158 @@
1
+ // ⌘K command palette on Kobalte's Dialog primitive — a generic, app-agnostic
2
+ // version of preview/CommandPalette. Feed it a flat list of CommandItems and it
3
+ // handles fuzzy filtering (label + hint + group), keyboard navigation
4
+ // (↑/↓/Enter), optional grouping, and query reset on close. Theme-agnostic:
5
+ // every color comes from a semantic token, never a literal.
6
+ import { Dialog } from '@kobalte/core/dialog'
7
+ import { createEffect, createMemo, createSignal, For, Show, type JSX } from 'solid-js'
8
+
9
+ import { cn } from '../lib/cn'
10
+
11
+ /** A single selectable row in the palette. */
12
+ export interface CommandItem {
13
+ /** Primary text shown on the left of the row. */
14
+ label: string
15
+ /** Optional secondary text shown muted on the right. */
16
+ hint?: string
17
+ /** Optional group name; rows sharing a group render under one header. */
18
+ group?: string
19
+ /** Invoked when the row is picked (click or Enter). */
20
+ onSelect: () => void
21
+ }
22
+
23
+ export interface CommandProps {
24
+ open: boolean
25
+ onOpenChange: (open: boolean) => void
26
+ /** Flat list of commands; filtered live against the search query. */
27
+ items: CommandItem[]
28
+ /** Search input placeholder. @default 'Type a command…' */
29
+ placeholder?: string
30
+ }
31
+
32
+ /**
33
+ * A ⌘K command palette: a centered, top-anchored dialog with a search input and
34
+ * a filtered, keyboard-navigable list of commands. Filtering is a
35
+ * case-insensitive substring match over each item's `label + hint + group`.
36
+ * Picking a row runs its `onSelect`, closes the palette, and clears the query.
37
+ *
38
+ * @example
39
+ * ```tsx
40
+ * const [open, setOpen] = createSignal(false)
41
+ * <Command
42
+ * open={open()}
43
+ * onOpenChange={setOpen}
44
+ * items={[
45
+ * { label: 'New file', hint: '⌘N', group: 'File', onSelect: () => createFile() },
46
+ * { label: 'Toggle theme', group: 'View', onSelect: () => toggleTheme() },
47
+ * ]}
48
+ * />
49
+ * ```
50
+ */
51
+ export function Command(props: CommandProps): JSX.Element {
52
+ const [query, setQuery] = createSignal('')
53
+ const [active, setActive] = createSignal(0)
54
+
55
+ const results = createMemo<CommandItem[]>(() => {
56
+ const q = query().trim().toLowerCase()
57
+ if (!q) return props.items
58
+ return props.items.filter((item) =>
59
+ `${item.label} ${item.hint ?? ''} ${item.group ?? ''}`.toLowerCase().includes(q),
60
+ )
61
+ })
62
+
63
+ // Keep the highlighted row in range whenever the result set changes.
64
+ createEffect(() => {
65
+ results()
66
+ setActive(0)
67
+ })
68
+
69
+ // Reset the query whenever the palette closes so it opens fresh next time.
70
+ createEffect(() => {
71
+ if (!props.open) setQuery('')
72
+ })
73
+
74
+ const pick = (item: CommandItem) => {
75
+ item.onSelect()
76
+ props.onOpenChange(false)
77
+ setQuery('')
78
+ }
79
+
80
+ const onKeyDown = (e: KeyboardEvent) => {
81
+ const list = results()
82
+ if (e.key === 'ArrowDown') {
83
+ e.preventDefault()
84
+ setActive((i) => Math.min(i + 1, list.length - 1))
85
+ } else if (e.key === 'ArrowUp') {
86
+ e.preventDefault()
87
+ setActive((i) => Math.max(i - 1, 0))
88
+ } else if (e.key === 'Enter') {
89
+ e.preventDefault()
90
+ const item = list[active()]
91
+ if (item) pick(item)
92
+ }
93
+ }
94
+
95
+ return (
96
+ <Dialog open={props.open} onOpenChange={props.onOpenChange}>
97
+ <Dialog.Portal>
98
+ <Dialog.Overlay class="modal-overlay fixed inset-0 z-40 bg-background/80 backdrop-blur-sm" />
99
+ <div class="fixed inset-x-0 top-[14vh] z-50 mx-auto w-full max-w-lg px-4">
100
+ <Dialog.Content
101
+ role="dialog"
102
+ class="modal-content bg-glass overflow-hidden rounded-xl border border-border shadow-2xl"
103
+ >
104
+ <input
105
+ autofocus
106
+ type="text"
107
+ value={query()}
108
+ onInput={(e) => setQuery(e.currentTarget.value)}
109
+ onKeyDown={onKeyDown}
110
+ placeholder={props.placeholder ?? 'Type a command…'}
111
+ aria-label={props.placeholder ?? 'Type a command…'}
112
+ class="w-full border-b border-border bg-transparent px-4 py-3 text-sm text-foreground outline-none placeholder:text-muted-foreground"
113
+ />
114
+ <ul class="max-h-80 overflow-y-auto p-2">
115
+ <Show
116
+ when={results().length}
117
+ fallback={<li class="px-3 py-8 text-center text-sm text-muted-foreground">No results</li>}
118
+ >
119
+ <For each={results()}>
120
+ {(item, i) => (
121
+ <li>
122
+ <Show when={item.group && (i() === 0 || results()[i() - 1]?.group !== item.group)}>
123
+ <div class="px-3 pb-1 pt-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
124
+ {item.group}
125
+ </div>
126
+ </Show>
127
+ <button
128
+ type="button"
129
+ onMouseEnter={() => setActive(i())}
130
+ onClick={() => pick(item)}
131
+ class={cn(
132
+ 'flex w-full items-center justify-between gap-3 rounded-md px-3 py-2 text-left transition-colors',
133
+ active() === i() ? 'bg-primary/15 text-foreground' : 'text-muted-foreground',
134
+ )}
135
+ >
136
+ <span class="text-sm text-foreground">{item.label}</span>
137
+ <Show when={item.hint}>
138
+ <span class="text-[11px] uppercase tracking-wide text-muted-foreground">
139
+ {item.hint}
140
+ </span>
141
+ </Show>
142
+ </button>
143
+ </li>
144
+ )}
145
+ </For>
146
+ </Show>
147
+ </ul>
148
+ <div class="flex items-center gap-3 border-t border-border px-4 py-2 text-[11px] text-muted-foreground">
149
+ <span>↑↓ to navigate</span>
150
+ <span>↵ to select</span>
151
+ <span>esc to close</span>
152
+ </div>
153
+ </Dialog.Content>
154
+ </div>
155
+ </Dialog.Portal>
156
+ </Dialog>
157
+ )
158
+ }
@@ -0,0 +1,91 @@
1
+ // Threaded comment layout — an avatar, header/content/actions, and recursive replies.
2
+ import type { JSX } from 'solid-js'
3
+ import { For, Show } from 'solid-js'
4
+
5
+ import { Avatar } from './Avatar'
6
+
7
+ /** A single comment node, optionally carrying its own nested `replies`. */
8
+ export interface CommentData {
9
+ /** Unique identifier for the comment. */
10
+ id: string
11
+ /** Display name of the comment's author. */
12
+ author: string
13
+ /** Author avatar image URL; falls back to the author's initials. */
14
+ avatar?: string
15
+ /** Human-readable timestamp shown next to the author. */
16
+ time?: string
17
+ /** The comment body. */
18
+ content: JSX.Element
19
+ /** Nested replies, rendered recursively below the comment. */
20
+ replies?: CommentData[]
21
+ }
22
+
23
+ export interface CommentProps {
24
+ comment: CommentData
25
+ /** Renders an actions row (e.g. reply/like) for a given comment. */
26
+ actions?: (comment: CommentData) => JSX.Element
27
+ class?: string
28
+ }
29
+
30
+ /** Derives up-to-two-letter initials from an author name. */
31
+ function initials(author: string): string {
32
+ return author
33
+ .trim()
34
+ .split(/\s+/)
35
+ .map((word) => word[0] ?? '')
36
+ .slice(0, 2)
37
+ .join('')
38
+ .toUpperCase()
39
+ }
40
+
41
+ /**
42
+ * Threaded comment layout: a left-aligned {@link Avatar} beside the author,
43
+ * timestamp, content, and an optional actions row. Any `replies` are rendered
44
+ * recursively in a left-indented, bordered thread, inheriting the same `actions`.
45
+ *
46
+ * @example
47
+ * ```tsx
48
+ * <Comment
49
+ * comment={{
50
+ * id: '1',
51
+ * author: 'Jane Doe',
52
+ * time: '2h ago',
53
+ * content: <p>Great work on this!</p>,
54
+ * replies: [{ id: '2', author: 'John Smith', content: <p>Agreed.</p> }],
55
+ * }}
56
+ * actions={(c) => <button>Reply to {c.author}</button>}
57
+ * />
58
+ * ```
59
+ */
60
+ export function Comment(props: CommentProps): JSX.Element {
61
+ return (
62
+ <div class={props.class}>
63
+ <div class="flex gap-3">
64
+ <Avatar
65
+ src={props.comment.avatar}
66
+ alt={props.comment.author}
67
+ fallback={initials(props.comment.author)}
68
+ />
69
+ <div class="flex-1">
70
+ <div class="flex items-baseline gap-2">
71
+ <span class="text-sm font-medium text-foreground">{props.comment.author}</span>
72
+ <Show when={props.comment.time}>
73
+ <span class="text-xs text-muted-foreground">{props.comment.time}</span>
74
+ </Show>
75
+ </div>
76
+ <div class="mt-1 text-sm text-foreground">{props.comment.content}</div>
77
+ <Show when={props.actions}>
78
+ <div class="mt-2 flex gap-3 text-xs text-muted-foreground">{props.actions?.(props.comment)}</div>
79
+ </Show>
80
+ </div>
81
+ </div>
82
+ <Show when={props.comment.replies?.length}>
83
+ <div class="mt-4 space-y-4 border-l border-border pl-4">
84
+ <For each={props.comment.replies}>
85
+ {(reply) => <Comment comment={reply} actions={props.actions} />}
86
+ </For>
87
+ </div>
88
+ </Show>
89
+ </div>
90
+ )
91
+ }