@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,180 @@
|
|
|
1
|
+
// GitHub-contributions-style activity grid: the last `weeks` weeks rendered as
|
|
2
|
+
// columns of 7 Sun→Sat day-cells, each shaded into one of 5 intensity buckets by
|
|
3
|
+
// its count. Plain Solid + theme tokens (works in light/dark) — level 0 is
|
|
4
|
+
// `bg-muted`, levels 1–4 are `bg-primary` at rising opacity via a static class
|
|
5
|
+
// map (never computed classes). Native `title` gives a hover tooltip per day.
|
|
6
|
+
import type { JSX } from 'solid-js'
|
|
7
|
+
import { For } from 'solid-js'
|
|
8
|
+
|
|
9
|
+
import { cn } from '../lib/cn'
|
|
10
|
+
|
|
11
|
+
/** A single day's activity count, keyed by an ISO `YYYY-MM-DD` date. */
|
|
12
|
+
export interface HeatmapValue {
|
|
13
|
+
date: string
|
|
14
|
+
count: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CalendarHeatmapProps {
|
|
18
|
+
/** Activity counts to plot; dates outside the visible range are ignored. */
|
|
19
|
+
values: HeatmapValue[]
|
|
20
|
+
/** Number of week-columns to show, ending on today. Default 26. */
|
|
21
|
+
weeks?: number
|
|
22
|
+
class?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Level 0 uses the neutral muted token; levels 1–4 tint the primary token at
|
|
26
|
+
// increasing opacity. Kept as a literal map so Tailwind can see every class.
|
|
27
|
+
const LEVEL_CLASSES = ['bg-muted', 'bg-primary/25', 'bg-primary/45', 'bg-primary/70', 'bg-primary'] as const
|
|
28
|
+
|
|
29
|
+
// Layout constants (px): cell size + inter-cell gap → column/row pitch. Month
|
|
30
|
+
// labels are absolutely positioned by column pitch; weekday labels by row pitch.
|
|
31
|
+
const CELL = 11
|
|
32
|
+
const GAP = 4
|
|
33
|
+
const PITCH = CELL + GAP
|
|
34
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] as const
|
|
35
|
+
// Weekday labels: only Mon/Wed/Fri are shown (rows 1/3/5, Sun=0), GitHub-style.
|
|
36
|
+
const WEEKDAYS = ['', 'Mon', '', 'Wed', '', 'Fri', ''] as const
|
|
37
|
+
|
|
38
|
+
/** Bucket a raw count into an intensity level 0..4. */
|
|
39
|
+
function levelFor(count: number): number {
|
|
40
|
+
if (count <= 0) return 0
|
|
41
|
+
if (count <= 2) return 1
|
|
42
|
+
if (count <= 5) return 2
|
|
43
|
+
if (count <= 9) return 3
|
|
44
|
+
return 4
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Local-time `YYYY-MM-DD` key for a Date. */
|
|
48
|
+
function isoKey(d: Date): string {
|
|
49
|
+
const y = d.getFullYear()
|
|
50
|
+
const m = String(d.getMonth() + 1).padStart(2, '0')
|
|
51
|
+
const day = String(d.getDate()).padStart(2, '0')
|
|
52
|
+
return `${y}-${m}-${day}`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* GitHub-contributions-style heatmap: a grid of the last `weeks` weeks ending
|
|
57
|
+
* today, laid out as columns (one per week) of 7 vertically-stacked day-cells
|
|
58
|
+
* (Sun→Sat). Each cell is shaded by its count into 5 intensity levels and shows
|
|
59
|
+
* a native tooltip like "3 on 2026-07-10". A "Less … More" legend sits below.
|
|
60
|
+
* Theme-agnostic (semantic tokens only).
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```tsx
|
|
64
|
+
* <CalendarHeatmap values={[{ date: '2026-07-10', count: 3 }]} weeks={26} />
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
export function CalendarHeatmap(props: CalendarHeatmapProps): JSX.Element {
|
|
68
|
+
const weeks = () => props.weeks ?? 26
|
|
69
|
+
|
|
70
|
+
// date -> count lookup from the provided values.
|
|
71
|
+
const counts = () => {
|
|
72
|
+
const map = new Map<string, number>()
|
|
73
|
+
for (const v of props.values) map.set(v.date, (map.get(v.date) ?? 0) + v.count)
|
|
74
|
+
return map
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Build `weeks` columns ending on the week that contains today. The grid ends
|
|
78
|
+
// on the Saturday of the current week; column 0 is the earliest week.
|
|
79
|
+
const columns = () => {
|
|
80
|
+
const lookup = counts()
|
|
81
|
+
const today = new Date()
|
|
82
|
+
// Start of the current week (Sunday), then step back to the first column.
|
|
83
|
+
const currentSunday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - today.getDay())
|
|
84
|
+
const cols: { date: Date; count: number; level: number; key: string }[][] = []
|
|
85
|
+
for (let w = 0; w < weeks(); w++) {
|
|
86
|
+
const weekOffset = (weeks() - 1 - w) * 7
|
|
87
|
+
const col: { date: Date; count: number; level: number; key: string }[] = []
|
|
88
|
+
for (let d = 0; d < 7; d++) {
|
|
89
|
+
const date = new Date(
|
|
90
|
+
currentSunday.getFullYear(),
|
|
91
|
+
currentSunday.getMonth(),
|
|
92
|
+
currentSunday.getDate() - weekOffset + d,
|
|
93
|
+
)
|
|
94
|
+
const key = isoKey(date)
|
|
95
|
+
const count = lookup.get(key) ?? 0
|
|
96
|
+
col.push({ date, count, level: levelFor(count), key })
|
|
97
|
+
}
|
|
98
|
+
cols.push(col)
|
|
99
|
+
}
|
|
100
|
+
return cols
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// A month label at each column where a new month begins (top/Sunday cell).
|
|
104
|
+
const monthLabels = () => {
|
|
105
|
+
const cols = columns()
|
|
106
|
+
const labels: { index: number; label: string }[] = []
|
|
107
|
+
let prevMonth = -1
|
|
108
|
+
cols.forEach((col, i) => {
|
|
109
|
+
const month = col[0].date.getMonth()
|
|
110
|
+
if (month !== prevMonth) {
|
|
111
|
+
labels.push({ index: i, label: MONTHS[month] })
|
|
112
|
+
prevMonth = month
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
return labels
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Width reserved for the weekday label gutter (left of the grid).
|
|
119
|
+
const gutter = 28
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<div class={cn('inline-flex flex-col gap-2', props.class)}>
|
|
123
|
+
{/* Month labels, positioned by column so they sit over the right week. */}
|
|
124
|
+
<div class="relative h-4 text-[9px] text-muted-foreground" style={{ 'margin-left': `${gutter}px` }}>
|
|
125
|
+
<For each={monthLabels()}>
|
|
126
|
+
{(m) => (
|
|
127
|
+
<span class="absolute top-0 whitespace-nowrap" style={{ left: `${m.index * PITCH}px` }}>
|
|
128
|
+
{m.label}
|
|
129
|
+
</span>
|
|
130
|
+
)}
|
|
131
|
+
</For>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
<div class="flex" style={{ gap: `${GAP}px` }}>
|
|
135
|
+
{/* Weekday gutter (Mon/Wed/Fri), aligned to the day rows. */}
|
|
136
|
+
<div
|
|
137
|
+
class="flex flex-col justify-between pr-1 text-right text-[9px] leading-none text-muted-foreground"
|
|
138
|
+
style={{ gap: `${GAP}px`, width: `${gutter}px` }}
|
|
139
|
+
>
|
|
140
|
+
<For each={WEEKDAYS}>
|
|
141
|
+
{(day) => (
|
|
142
|
+
<span class="flex items-center justify-end" style={{ height: `${CELL}px` }}>
|
|
143
|
+
{day}
|
|
144
|
+
</span>
|
|
145
|
+
)}
|
|
146
|
+
</For>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
<For each={columns()}>
|
|
150
|
+
{(col) => (
|
|
151
|
+
<div class="flex flex-col" style={{ gap: `${GAP}px` }}>
|
|
152
|
+
<For each={col}>
|
|
153
|
+
{(cell) => (
|
|
154
|
+
<div
|
|
155
|
+
title={`${cell.count} on ${cell.key}`}
|
|
156
|
+
class={cn('rounded-sm', LEVEL_CLASSES[cell.level])}
|
|
157
|
+
style={{ height: `${CELL}px`, width: `${CELL}px` }}
|
|
158
|
+
/>
|
|
159
|
+
)}
|
|
160
|
+
</For>
|
|
161
|
+
</div>
|
|
162
|
+
)}
|
|
163
|
+
</For>
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
<div
|
|
167
|
+
class="flex items-center gap-1 text-xs text-muted-foreground"
|
|
168
|
+
style={{ 'margin-left': `${gutter}px` }}
|
|
169
|
+
>
|
|
170
|
+
<span>Less</span>
|
|
171
|
+
<For each={LEVEL_CLASSES}>
|
|
172
|
+
{(levelClass) => (
|
|
173
|
+
<div class={cn('rounded-sm', levelClass)} style={{ height: `${CELL}px`, width: `${CELL}px` }} />
|
|
174
|
+
)}
|
|
175
|
+
</For>
|
|
176
|
+
<span>More</span>
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
)
|
|
180
|
+
}
|
package/src/ui/Card.tsx
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Card + 4 sub-parts (no CardFooter/CardDescription — add if needed).
|
|
2
|
+
import type { JSX, ParentProps } from 'solid-js'
|
|
3
|
+
import { splitProps } from 'solid-js'
|
|
4
|
+
|
|
5
|
+
import { cn } from '../lib/cn'
|
|
6
|
+
|
|
7
|
+
interface DivProps extends ParentProps {
|
|
8
|
+
class?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface CardProps extends DivProps {
|
|
12
|
+
/** Frosted "space glass" surface (`.card`) instead of the opaque default. */
|
|
13
|
+
glass?: boolean
|
|
14
|
+
/**
|
|
15
|
+
* Cursor-following edge glow (only meaningful with `glass`). Defaults to ON
|
|
16
|
+
* for glass cards so the look is uniform; pass `glow={false}` to opt out.
|
|
17
|
+
*/
|
|
18
|
+
glow?: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Container surface for grouping related content, with an optional frosted
|
|
23
|
+
* "space glass" look. Compose with {@link CardHeader}, {@link CardTitle}, and
|
|
24
|
+
* {@link CardContent}.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```tsx
|
|
28
|
+
* <Card glass>
|
|
29
|
+
* <CardHeader>
|
|
30
|
+
* <CardTitle>Usage</CardTitle>
|
|
31
|
+
* </CardHeader>
|
|
32
|
+
* <CardContent>1,204 requests today</CardContent>
|
|
33
|
+
* </Card>
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export function Card(props: CardProps): JSX.Element {
|
|
37
|
+
const [local, rest] = splitProps(props, ['class', 'children', 'glass', 'glow'])
|
|
38
|
+
// Glow defaults to the card's glass state when not explicitly set.
|
|
39
|
+
const showGlow = () => (local.glow ?? local.glass) === true
|
|
40
|
+
return (
|
|
41
|
+
<div
|
|
42
|
+
class={cn(
|
|
43
|
+
local.glass
|
|
44
|
+
? 'card rounded-xl text-card-foreground'
|
|
45
|
+
: 'rounded-xl border border-border bg-card text-card-foreground shadow-sm',
|
|
46
|
+
showGlow() && 'glow-edge',
|
|
47
|
+
local.class,
|
|
48
|
+
)}
|
|
49
|
+
{...rest}
|
|
50
|
+
>
|
|
51
|
+
{local.children}
|
|
52
|
+
</div>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Header region of a {@link Card}; typically wraps a {@link CardTitle}. */
|
|
57
|
+
export function CardHeader(props: DivProps): JSX.Element {
|
|
58
|
+
const [local, rest] = splitProps(props, ['class', 'children'])
|
|
59
|
+
return (
|
|
60
|
+
<div class={cn('flex flex-col space-y-1.5 p-6', local.class)} {...rest}>
|
|
61
|
+
{local.children}
|
|
62
|
+
</div>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Heading text (rendered as an `<h2>`) for a {@link Card}. */
|
|
67
|
+
export function CardTitle(props: DivProps): JSX.Element {
|
|
68
|
+
const [local, rest] = splitProps(props, ['class', 'children'])
|
|
69
|
+
return (
|
|
70
|
+
<h2 class={cn('text-lg font-semibold leading-none tracking-tight', local.class)} {...rest}>
|
|
71
|
+
{local.children}
|
|
72
|
+
</h2>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Main body region of a {@link Card}, below the header. */
|
|
77
|
+
export function CardContent(props: DivProps): JSX.Element {
|
|
78
|
+
const [local, rest] = splitProps(props, ['class', 'children'])
|
|
79
|
+
return (
|
|
80
|
+
<div class={cn('p-6 pt-0', local.class)} {...rest}>
|
|
81
|
+
{local.children}
|
|
82
|
+
</div>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Single-slide carousel: a translateX track that slides horizontally between
|
|
2
|
+
// slides with a CSS transition, arrow + dot controls, keyboard navigation, and
|
|
3
|
+
// optional hover-pausing autoplay (gated on prefers-reduced-motion via the
|
|
4
|
+
// shared `motionReduced` helper).
|
|
5
|
+
import { ChevronLeft, ChevronRight } from 'lucide-solid'
|
|
6
|
+
import { For, createSignal, onCleanup, onMount, type JSX } from 'solid-js'
|
|
7
|
+
|
|
8
|
+
import { cn } from '../lib/cn'
|
|
9
|
+
import { motionReduced } from '../lib/motion'
|
|
10
|
+
|
|
11
|
+
export interface CarouselProps {
|
|
12
|
+
/** Slides to page through, one visible at a time. */
|
|
13
|
+
slides: JSX.Element[]
|
|
14
|
+
/** Auto-advance interval in ms. Omit to disable autoplay (also skipped under reduced motion / hover). */
|
|
15
|
+
autoplayMs?: number
|
|
16
|
+
class?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Horizontally-sliding carousel that shows one slide at a time inside a rounded,
|
|
21
|
+
* overflow-hidden viewport. Includes wrapping prev/next arrow buttons, a row of
|
|
22
|
+
* dot indicators, Left/Right arrow-key navigation when focused, and optional
|
|
23
|
+
* autoplay that pauses on hover and is disabled under `prefers-reduced-motion`.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```tsx
|
|
27
|
+
* <Carousel
|
|
28
|
+
* autoplayMs={4000}
|
|
29
|
+
* slides={[<img src="/a.jpg" />, <img src="/b.jpg" />, <img src="/c.jpg" />]}
|
|
30
|
+
* />
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export function Carousel(props: CarouselProps): JSX.Element {
|
|
34
|
+
const [index, setIndex] = createSignal(0)
|
|
35
|
+
const count = (): number => props.slides.length
|
|
36
|
+
|
|
37
|
+
const go = (to: number): void => {
|
|
38
|
+
const n = count()
|
|
39
|
+
if (n === 0) return
|
|
40
|
+
setIndex(((to % n) + n) % n)
|
|
41
|
+
}
|
|
42
|
+
const prev = (): void => go(index() - 1)
|
|
43
|
+
const next = (): void => go(index() + 1)
|
|
44
|
+
|
|
45
|
+
const onKeyDown = (e: KeyboardEvent): void => {
|
|
46
|
+
if (e.key === 'ArrowLeft') {
|
|
47
|
+
e.preventDefault()
|
|
48
|
+
prev()
|
|
49
|
+
} else if (e.key === 'ArrowRight') {
|
|
50
|
+
e.preventDefault()
|
|
51
|
+
next()
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// --- autoplay (paused on hover, skipped under reduced motion) --------------
|
|
56
|
+
const [paused, setPaused] = createSignal(false)
|
|
57
|
+
onMount(() => {
|
|
58
|
+
if (props.autoplayMs === undefined || motionReduced()) return
|
|
59
|
+
const timer = setInterval(() => {
|
|
60
|
+
if (!paused()) next()
|
|
61
|
+
}, props.autoplayMs)
|
|
62
|
+
onCleanup(() => clearInterval(timer))
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div
|
|
67
|
+
role="region"
|
|
68
|
+
aria-roledescription="carousel"
|
|
69
|
+
tabindex="0"
|
|
70
|
+
class={cn(
|
|
71
|
+
'flex flex-col gap-3 outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
|
72
|
+
props.class,
|
|
73
|
+
)}
|
|
74
|
+
onKeyDown={onKeyDown}
|
|
75
|
+
onPointerEnter={() => setPaused(true)}
|
|
76
|
+
onPointerLeave={() => setPaused(false)}
|
|
77
|
+
>
|
|
78
|
+
<div class="relative overflow-hidden rounded-xl border border-border bg-card text-foreground">
|
|
79
|
+
<div
|
|
80
|
+
class="flex transition-transform duration-500 ease-out"
|
|
81
|
+
style={{ transform: `translateX(-${index() * 100}%)` }}
|
|
82
|
+
>
|
|
83
|
+
<For each={props.slides}>{(slide) => <div class="w-full shrink-0">{slide}</div>}</For>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
aria-label="Previous slide"
|
|
89
|
+
onClick={prev}
|
|
90
|
+
class="absolute left-2 top-1/2 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full bg-primary text-primary-foreground shadow transition-opacity hover:opacity-90"
|
|
91
|
+
>
|
|
92
|
+
<ChevronLeft class="h-5 w-5" />
|
|
93
|
+
</button>
|
|
94
|
+
<button
|
|
95
|
+
type="button"
|
|
96
|
+
aria-label="Next slide"
|
|
97
|
+
onClick={next}
|
|
98
|
+
class="absolute right-2 top-1/2 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full bg-primary text-primary-foreground shadow transition-opacity hover:opacity-90"
|
|
99
|
+
>
|
|
100
|
+
<ChevronRight class="h-5 w-5" />
|
|
101
|
+
</button>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<div class="flex items-center justify-center gap-2">
|
|
105
|
+
<For each={props.slides}>
|
|
106
|
+
{(_, i) => (
|
|
107
|
+
<button
|
|
108
|
+
type="button"
|
|
109
|
+
aria-label={`Go to slide ${i() + 1}`}
|
|
110
|
+
onClick={() => go(i())}
|
|
111
|
+
class={cn(
|
|
112
|
+
'h-2 w-2 rounded-full transition-colors',
|
|
113
|
+
index() === i() ? 'bg-primary' : 'bg-muted',
|
|
114
|
+
)}
|
|
115
|
+
/>
|
|
116
|
+
)}
|
|
117
|
+
</For>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
)
|
|
121
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Cascading multi-level select — pick a value by drilling through columns.
|
|
2
|
+
import { ChevronDown, ChevronRight } from 'lucide-solid'
|
|
3
|
+
import type { JSX } from 'solid-js'
|
|
4
|
+
import { For, Show, createMemo, createSignal, onCleanup, onMount } from 'solid-js'
|
|
5
|
+
|
|
6
|
+
import { cn } from '../lib/cn'
|
|
7
|
+
|
|
8
|
+
/** A node in a {@link Cascader} tree. `children` makes it a branch column. */
|
|
9
|
+
export interface CascaderOption {
|
|
10
|
+
value: string
|
|
11
|
+
label: string
|
|
12
|
+
children?: CascaderOption[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CascaderProps {
|
|
16
|
+
options: CascaderOption[]
|
|
17
|
+
/** The currently selected value-path (root → leaf). */
|
|
18
|
+
value?: string[]
|
|
19
|
+
/** Fired when a leaf is chosen, with the full value-path and label-path. */
|
|
20
|
+
onChange: (path: string[], labels: string[]) => void
|
|
21
|
+
placeholder?: string
|
|
22
|
+
class?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const TRIGGER_BASE =
|
|
26
|
+
'flex w-full items-center justify-between gap-2 rounded-md border border-input bg-background text-foreground px-3 py-2 text-sm outline-none transition-colors a4-field'
|
|
27
|
+
|
|
28
|
+
// Walk the tree along `value`, collecting the matched options so we can resolve
|
|
29
|
+
// their labels. Stops early if the path diverges from the current options.
|
|
30
|
+
function resolvePath(options: CascaderOption[], value: string[]): CascaderOption[] {
|
|
31
|
+
const chain: CascaderOption[] = []
|
|
32
|
+
let level: CascaderOption[] | undefined = options
|
|
33
|
+
for (const v of value) {
|
|
34
|
+
const match: CascaderOption | undefined = level?.find((o) => o.value === v)
|
|
35
|
+
if (!match) break
|
|
36
|
+
chain.push(match)
|
|
37
|
+
level = match.children
|
|
38
|
+
}
|
|
39
|
+
return chain
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Cascading multi-level select. Renders a Select-styled trigger showing the
|
|
44
|
+
* selected path (labels joined with " / "); clicking opens a floating panel of
|
|
45
|
+
* side-by-side columns. Hovering an option with children reveals the next
|
|
46
|
+
* column; clicking a leaf commits the selection and closes the panel.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```tsx
|
|
50
|
+
* <Cascader
|
|
51
|
+
* options={[
|
|
52
|
+
* { value: 'mx', label: 'Mexico', children: [
|
|
53
|
+
* { value: 'son', label: 'Sonora' },
|
|
54
|
+
* { value: 'jal', label: 'Jalisco' },
|
|
55
|
+
* ] },
|
|
56
|
+
* ]}
|
|
57
|
+
* value={path()}
|
|
58
|
+
* onChange={(p, labels) => { setPath(p); console.log(labels.join(' / ')) }}
|
|
59
|
+
* placeholder="Select a region"
|
|
60
|
+
* />
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
export function Cascader(props: CascaderProps): JSX.Element {
|
|
64
|
+
const [open, setOpen] = createSignal(false)
|
|
65
|
+
// The chain of options hovered/chosen so far — drives which columns show.
|
|
66
|
+
const [activePath, setActivePath] = createSignal<CascaderOption[]>([])
|
|
67
|
+
let root!: HTMLDivElement
|
|
68
|
+
|
|
69
|
+
const selected = createMemo(() => resolvePath(props.options, props.value ?? []))
|
|
70
|
+
const selectedLabel = createMemo(() =>
|
|
71
|
+
selected()
|
|
72
|
+
.map((o) => o.label)
|
|
73
|
+
.join(' / '),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
// Columns to render: level 0 is the root, each further level is the children
|
|
77
|
+
// of the corresponding option on the active path (if it has any).
|
|
78
|
+
const columns = createMemo(() => {
|
|
79
|
+
const cols: CascaderOption[][] = [props.options]
|
|
80
|
+
for (const opt of activePath()) {
|
|
81
|
+
if (opt.children && opt.children.length) cols.push(opt.children)
|
|
82
|
+
else break
|
|
83
|
+
}
|
|
84
|
+
return cols
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const onDocClick = (ev: MouseEvent) => {
|
|
88
|
+
if (root && !root.contains(ev.target as Node)) setOpen(false)
|
|
89
|
+
}
|
|
90
|
+
onMount(() => document.addEventListener('mousedown', onDocClick))
|
|
91
|
+
onCleanup(() => document.removeEventListener('mousedown', onDocClick))
|
|
92
|
+
|
|
93
|
+
function toggle() {
|
|
94
|
+
if (!open()) setActivePath(selected())
|
|
95
|
+
setOpen((o) => !o)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Set the active path up to (and including) `opt` at `colIndex`.
|
|
99
|
+
function enter(colIndex: number, opt: CascaderOption) {
|
|
100
|
+
setActivePath([...activePath().slice(0, colIndex), opt])
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function choose(colIndex: number, opt: CascaderOption) {
|
|
104
|
+
enter(colIndex, opt)
|
|
105
|
+
if (opt.children && opt.children.length) return
|
|
106
|
+
const chain = [...activePath().slice(0, colIndex), opt]
|
|
107
|
+
props.onChange(
|
|
108
|
+
chain.map((o) => o.value),
|
|
109
|
+
chain.map((o) => o.label),
|
|
110
|
+
)
|
|
111
|
+
setOpen(false)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<div ref={root} class={cn('relative inline-block', props.class)}>
|
|
116
|
+
<button type="button" class={TRIGGER_BASE} aria-haspopup="true" aria-expanded={open()} onClick={toggle}>
|
|
117
|
+
<Show
|
|
118
|
+
when={selectedLabel()}
|
|
119
|
+
fallback={<span class="text-muted-foreground">{props.placeholder}</span>}
|
|
120
|
+
>
|
|
121
|
+
<span class="truncate">{selectedLabel()}</span>
|
|
122
|
+
</Show>
|
|
123
|
+
<ChevronDown class="h-4 w-4 shrink-0 opacity-60" />
|
|
124
|
+
</button>
|
|
125
|
+
<Show when={open()}>
|
|
126
|
+
<div class="absolute z-50 mt-1 flex overflow-hidden rounded-md border border-border bg-card text-card-foreground shadow-md">
|
|
127
|
+
<For each={columns()}>
|
|
128
|
+
{(column, colIndex) => (
|
|
129
|
+
<ul class="min-w-[10rem] max-h-64 overflow-y-auto border-r border-border p-1 last:border-r-0">
|
|
130
|
+
<For each={column}>
|
|
131
|
+
{(opt) => {
|
|
132
|
+
const hasChildren = () => !!opt.children && opt.children.length > 0
|
|
133
|
+
const isActive = () => activePath()[colIndex()]?.value === opt.value
|
|
134
|
+
return (
|
|
135
|
+
<li
|
|
136
|
+
class={cn(
|
|
137
|
+
'flex items-center justify-between gap-4 rounded px-2 py-1.5 text-sm hover:bg-muted cursor-pointer',
|
|
138
|
+
isActive() && 'bg-muted text-foreground',
|
|
139
|
+
)}
|
|
140
|
+
onMouseEnter={() => hasChildren() && enter(colIndex(), opt)}
|
|
141
|
+
onClick={() => choose(colIndex(), opt)}
|
|
142
|
+
>
|
|
143
|
+
<span class="truncate">{opt.label}</span>
|
|
144
|
+
<Show when={hasChildren()}>
|
|
145
|
+
<ChevronRight class="h-4 w-4 shrink-0 opacity-60" />
|
|
146
|
+
</Show>
|
|
147
|
+
</li>
|
|
148
|
+
)
|
|
149
|
+
}}
|
|
150
|
+
</For>
|
|
151
|
+
</ul>
|
|
152
|
+
)}
|
|
153
|
+
</For>
|
|
154
|
+
</div>
|
|
155
|
+
</Show>
|
|
156
|
+
</div>
|
|
157
|
+
)
|
|
158
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { JSX } from 'solid-js'
|
|
2
|
+
|
|
3
|
+
import { cn } from '../lib/cn'
|
|
4
|
+
|
|
5
|
+
interface CheckboxProps {
|
|
6
|
+
checked: boolean
|
|
7
|
+
/** Called with the new checked state whenever the input is toggled. */
|
|
8
|
+
onChange: (checked: boolean) => void
|
|
9
|
+
/** Visible label rendered next to the checkbox; also makes the whole row clickable. */
|
|
10
|
+
label: string
|
|
11
|
+
class?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Plain labeled checkbox (native `<input type="checkbox">`, not a Kobalte
|
|
16
|
+
* wrapper) for simple boolean toggles with a visible label.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```tsx
|
|
20
|
+
* <Checkbox checked={agreed()} onChange={setAgreed} label="I agree to the terms" />
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export function Checkbox(props: CheckboxProps): JSX.Element {
|
|
24
|
+
return (
|
|
25
|
+
<label class={cn('inline-flex items-center gap-2 text-sm text-foreground', props.class)}>
|
|
26
|
+
<input
|
|
27
|
+
type="checkbox"
|
|
28
|
+
class="h-4 w-4 rounded border-input accent-primary"
|
|
29
|
+
checked={props.checked}
|
|
30
|
+
onChange={(ev) => props.onChange(ev.currentTarget.checked)}
|
|
31
|
+
/>
|
|
32
|
+
{props.label}
|
|
33
|
+
</label>
|
|
34
|
+
)
|
|
35
|
+
}
|