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