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