@a4ui/core 0.33.0 → 0.34.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.
@@ -0,0 +1,250 @@
1
+ // Kanban board: columns of draggable cards, reorderable within a column and
2
+ // movable across columns. Pointer-events based drag (no HTML5 drag/drop),
3
+ // the same grip-handle + floating-clone idiom as Sortable.tsx, extended to
4
+ // track which column the pointer is currently over so cards can cross
5
+ // container boundaries. No transition/animation is applied to the drag
6
+ // itself (same as Sortable), so there's nothing to gate on reduced motion —
7
+ // drops are instant either way.
8
+ import { createEffect, createSignal, For, Show, type JSX } from 'solid-js'
9
+ import { Portal } from 'solid-js/web'
10
+ import { GripVertical } from 'lucide-solid'
11
+
12
+ import { cn } from '../lib/cn'
13
+ import { Badge } from './Badge'
14
+ import { Card } from './Card'
15
+
16
+ /** A single card on a {@link Kanban} board. */
17
+ export interface KanbanCard {
18
+ /** Stable unique id (used for drag tracking + list keying). */
19
+ id: string
20
+ /** Card body — usually a title/label. */
21
+ title: JSX.Element
22
+ /** Optional trailing badge (e.g. priority, assignee initials). */
23
+ badge?: JSX.Element
24
+ }
25
+
26
+ /** A column of a {@link Kanban} board. */
27
+ export interface KanbanColumn {
28
+ /** Stable unique id. */
29
+ id: string
30
+ /** Column heading. */
31
+ title: string
32
+ /** Cards in the column, top to bottom. */
33
+ cards: KanbanCard[]
34
+ /** Optional WIP limit — the count badge switches to a warning tone past it. */
35
+ limit?: number
36
+ }
37
+
38
+ export interface KanbanProps {
39
+ /** Columns, in display order left to right. */
40
+ columns: KanbanColumn[]
41
+ /** Called with the full new columns array after any drag (reorder or move). */
42
+ onChange?: (columns: KanbanColumn[]) => void
43
+ class?: string
44
+ }
45
+
46
+ interface Drag {
47
+ cardId: string
48
+ card: KanbanCard
49
+ fromColumnId: string
50
+ currentColumnId: string
51
+ y: number
52
+ x: number
53
+ offsetY: number
54
+ offsetX: number
55
+ height: number
56
+ width: number
57
+ }
58
+
59
+ /**
60
+ * Horizontal Kanban board: glass column panels holding vertical lists of
61
+ * draggable cards. Drag a card by its grip handle to reorder it within a
62
+ * column or drop it into another column — a dashed placeholder marks the
63
+ * drop slot and a floating clone follows the pointer, the same pointer-events
64
+ * idiom as {@link Sortable} extended to track which column's list the
65
+ * pointer is currently over. Works controlled (pass `columns` sourced from
66
+ * your own state + `onChange` to write it back) or uncontrolled (pass
67
+ * `columns` once and just read the final layout from `onChange`).
68
+ *
69
+ * @example
70
+ * ```tsx
71
+ * const [columns, setColumns] = createSignal<KanbanColumn[]>([
72
+ * { id: 'todo', title: 'To do', cards: [{ id: '1', title: 'Write spec' }] },
73
+ * { id: 'doing', title: 'Doing', cards: [], limit: 2 },
74
+ * { id: 'done', title: 'Done', cards: [] },
75
+ * ])
76
+ * <Kanban columns={columns()} onChange={setColumns} />
77
+ * ```
78
+ */
79
+ export function Kanban(props: KanbanProps): JSX.Element {
80
+ // eslint-disable-next-line solid/reactivity -- seed once; a createEffect below keeps it in sync
81
+ const [local, setLocal] = createSignal<KanbanColumn[]>(props.columns)
82
+ const [drag, setDrag] = createSignal<Drag | null>(null)
83
+ const listRefs: Record<string, HTMLDivElement | undefined> = {}
84
+
85
+ createEffect(() => {
86
+ const next = props.columns
87
+ if (!drag()) setLocal(next)
88
+ })
89
+
90
+ const moveCard = (cardId: string, targetColumnId: string, targetIndex: number) => {
91
+ setLocal((cur) => {
92
+ const next = cur.map((col) => ({ ...col, cards: col.cards.slice() }))
93
+ const fromCol = next.find((col) => col.cards.some((card) => card.id === cardId))
94
+ const toCol = next.find((col) => col.id === targetColumnId)
95
+ if (!fromCol || !toCol) return cur
96
+ const fromIndex = fromCol.cards.findIndex((card) => card.id === cardId)
97
+ const [moved] = fromCol.cards.splice(fromIndex, 1)
98
+ toCol.cards.splice(Math.min(targetIndex, toCol.cards.length), 0, moved)
99
+ return next
100
+ })
101
+ }
102
+
103
+ const onDragMove = (e: PointerEvent) => {
104
+ const d = drag()
105
+ if (!d) return
106
+
107
+ // Which column's list is the pointer over horizontally?
108
+ let targetColumnId = d.currentColumnId
109
+ for (const [colId, el] of Object.entries(listRefs)) {
110
+ if (!el) continue
111
+ const r = el.getBoundingClientRect()
112
+ if (e.clientX >= r.left && e.clientX <= r.right) {
113
+ targetColumnId = colId
114
+ break
115
+ }
116
+ }
117
+
118
+ // Where in that column's (other) cards does the pointer sit vertically?
119
+ const listEl = listRefs[targetColumnId]
120
+ const rows = listEl
121
+ ? Array.from(listEl.querySelectorAll<HTMLElement>('[data-kanban-card]')).filter(
122
+ (row) => row.dataset.kanbanCard !== d.cardId,
123
+ )
124
+ : []
125
+ let targetIndex = rows.length
126
+ for (let i = 0; i < rows.length; i++) {
127
+ const r = rows[i].getBoundingClientRect()
128
+ if (e.clientY < r.top + r.height / 2) {
129
+ targetIndex = i
130
+ break
131
+ }
132
+ }
133
+
134
+ setDrag({ ...d, y: e.clientY, x: e.clientX, currentColumnId: targetColumnId })
135
+ moveCard(d.cardId, targetColumnId, targetIndex)
136
+ }
137
+
138
+ const onDragStart = (e: PointerEvent, card: KanbanCard, columnId: string) => {
139
+ e.preventDefault()
140
+ const row = (e.currentTarget as HTMLElement).closest<HTMLElement>('[data-kanban-card]')
141
+ if (!row) return
142
+ const r = row.getBoundingClientRect()
143
+ setDrag({
144
+ cardId: card.id,
145
+ card,
146
+ fromColumnId: columnId,
147
+ currentColumnId: columnId,
148
+ y: e.clientY,
149
+ x: e.clientX,
150
+ offsetY: e.clientY - r.top,
151
+ offsetX: e.clientX - r.left,
152
+ height: r.height,
153
+ width: r.width,
154
+ })
155
+
156
+ const move = (ev: PointerEvent) => onDragMove(ev)
157
+ const up = () => {
158
+ window.removeEventListener('pointermove', move)
159
+ window.removeEventListener('pointerup', up)
160
+ const had = drag() !== null
161
+ setDrag(null)
162
+ if (had) props.onChange?.(local())
163
+ }
164
+ window.addEventListener('pointermove', move)
165
+ window.addEventListener('pointerup', up)
166
+ }
167
+
168
+ return (
169
+ <div class={cn('flex gap-4 overflow-x-auto pb-2', drag() && 'select-none', props.class)}>
170
+ <For each={local()}>
171
+ {(column) => {
172
+ const count = () => column.cards.length
173
+ const overLimit = () => column.limit !== undefined && count() > column.limit
174
+ return (
175
+ <Card glass class="flex w-72 shrink-0 flex-col">
176
+ <div class="flex items-center justify-between gap-2 border-b border-border p-3">
177
+ <h3 class="truncate font-semibold text-foreground">{column.title}</h3>
178
+ <Badge tone={overLimit() ? 'warning' : 'neutral'}>
179
+ {column.limit !== undefined ? `${count()}/${column.limit}` : `${count()}`}
180
+ </Badge>
181
+ </div>
182
+ <div
183
+ ref={(el) => (listRefs[column.id] = el)}
184
+ class="flex min-h-[60px] flex-1 flex-col gap-2 overflow-y-auto p-3"
185
+ >
186
+ <For each={column.cards}>
187
+ {(card) => {
188
+ const isDragged = () => drag()?.cardId === card.id
189
+ return (
190
+ <Show
191
+ when={!isDragged()}
192
+ fallback={
193
+ <div
194
+ data-kanban-card={card.id}
195
+ class="rounded-lg border-2 border-dashed border-primary/40 bg-primary/5"
196
+ style={{ height: `${drag()?.height ?? 0}px` }}
197
+ />
198
+ }
199
+ >
200
+ <div
201
+ data-kanban-card={card.id}
202
+ tabIndex={0}
203
+ class="flex items-center gap-2 rounded-lg border border-border bg-card p-2 text-card-foreground"
204
+ >
205
+ <button
206
+ type="button"
207
+ onPointerDown={(e) => onDragStart(e, card, column.id)}
208
+ style={{ 'touch-action': 'none' }}
209
+ class="shrink-0 cursor-grab text-muted-foreground active:cursor-grabbing"
210
+ aria-label="Drag to move card"
211
+ >
212
+ <GripVertical class="h-4 w-4" />
213
+ </button>
214
+ <div class="min-w-0 flex-1">{card.title}</div>
215
+ <Show when={card.badge}>{card.badge}</Show>
216
+ </div>
217
+ </Show>
218
+ )
219
+ }}
220
+ </For>
221
+ </div>
222
+ </Card>
223
+ )
224
+ }}
225
+ </For>
226
+ <Show when={drag()}>
227
+ {(d) => (
228
+ <Portal>
229
+ <div
230
+ class="fixed flex items-center gap-2 rounded-lg border border-border bg-card p-2 text-card-foreground shadow-2xl"
231
+ style={{
232
+ left: `${d().x - d().offsetX}px`,
233
+ top: `${d().y - d().offsetY}px`,
234
+ width: `${d().width}px`,
235
+ 'pointer-events': 'none',
236
+ 'z-index': 70,
237
+ }}
238
+ >
239
+ <span class="shrink-0 text-muted-foreground">
240
+ <GripVertical class="h-4 w-4" />
241
+ </span>
242
+ <div class="min-w-0 flex-1">{d().card.title}</div>
243
+ <Show when={d().card.badge}>{d().card.badge}</Show>
244
+ </div>
245
+ </Portal>
246
+ )}
247
+ </Show>
248
+ </div>
249
+ )
250
+ }
@@ -0,0 +1,88 @@
1
+ // Lamp — a hero/section backdrop: a spotlight beam fanning down from the top
2
+ // center (two symmetric blurred gradient cones + a bright thin line), with
3
+ // `children` sitting in the illuminated area below. Same "self-contained
4
+ // backdrop" idiom as Aurora/SpaceBackground (dark base, token-tinted glow,
5
+ // motionReduced()-aware), just scoped to one hero/section instead of the
6
+ // whole page.
7
+ import { onMount, type JSX } from 'solid-js'
8
+
9
+ import { cn } from '../lib/cn'
10
+ import { animate, motionReduced } from '../lib/motion'
11
+
12
+ export interface LampProps {
13
+ children?: JSX.Element
14
+ class?: string
15
+ }
16
+
17
+ /**
18
+ * Hero/section backdrop: a spotlight beam fanning down from the top center —
19
+ * two symmetric blurred gradient cones (`--primary` / `--accent`) plus a
20
+ * bright thin source line — over a dark base, with `children` centered in
21
+ * the illuminated area below. Grows/brightens in on mount via Motion
22
+ * (opacity + scale); static under {@link motionReduced}. Self-contained
23
+ * (`relative overflow-hidden`); size it with `class` (e.g. `min-h-[28rem]`).
24
+ *
25
+ * @example
26
+ * ```tsx
27
+ * <Lamp class="min-h-[28rem]">
28
+ * <h1 class="text-4xl font-semibold text-foreground">Build in the light</h1>
29
+ * <p class="mt-4 text-muted-foreground">A hero backdrop with a spotlight glow.</p>
30
+ * </Lamp>
31
+ * ```
32
+ */
33
+ export function Lamp(props: LampProps): JSX.Element {
34
+ let beamEl: HTMLDivElement | undefined
35
+
36
+ onMount(() => {
37
+ if (motionReduced() || !beamEl) return
38
+ animate(beamEl, { opacity: [0, 1], scale: [0.85, 1] }, { duration: 0.9, ease: 'easeOut' })
39
+ })
40
+
41
+ return (
42
+ <div
43
+ class={cn(
44
+ 'relative flex min-h-[24rem] w-full flex-col items-center overflow-hidden bg-background',
45
+ props.class,
46
+ )}
47
+ >
48
+ <div
49
+ ref={beamEl}
50
+ aria-hidden="true"
51
+ class="pointer-events-none absolute inset-0"
52
+ style={motionReduced() ? undefined : { opacity: 0, 'transform-origin': 'top center' }}
53
+ >
54
+ {/* Left cone: apex at top-center, fanning down-left. */}
55
+ <div
56
+ class="absolute left-1/2 top-0 h-72 w-72 -translate-x-full blur-2xl"
57
+ style={{
58
+ background: 'linear-gradient(115deg, hsl(var(--primary) / 0.75), transparent 70%)',
59
+ 'clip-path': 'polygon(100% 0%, 0% 100%, 100% 100%)',
60
+ }}
61
+ />
62
+ {/* Right cone: mirror of the left, same apex. */}
63
+ <div
64
+ class="absolute left-1/2 top-0 h-72 w-72 blur-2xl"
65
+ style={{
66
+ background: 'linear-gradient(245deg, hsl(var(--accent) / 0.75), transparent 70%)',
67
+ 'clip-path': 'polygon(0% 0%, 100% 100%, 0% 100%)',
68
+ }}
69
+ />
70
+ {/* Bright thin line at the beam's source. */}
71
+ <div
72
+ class="absolute left-1/2 top-0 h-px w-64 -translate-x-1/2 blur-[1px]"
73
+ style={{ background: 'hsl(var(--primary))' }}
74
+ />
75
+ {/* Soft bloom under the line, grounding the beam. */}
76
+ <div
77
+ class="absolute left-1/2 top-0 h-40 w-80 -translate-x-1/2 blur-2xl"
78
+ style={{
79
+ background: 'radial-gradient(ellipse at top, hsl(var(--primary) / 0.5), transparent 70%)',
80
+ }}
81
+ />
82
+ </div>
83
+ <div class="relative z-10 flex flex-1 flex-col items-center justify-center px-4 py-16 text-center">
84
+ {props.children}
85
+ </div>
86
+ </div>
87
+ )
88
+ }
@@ -0,0 +1,261 @@
1
+ // Full-screen image viewer: a responsive thumbnail grid that opens (via
2
+ // Portal, dark backdrop) into an overlay with the active image, prev/next
3
+ // navigation, a close button, an alt-text caption, and a thumbnail strip —
4
+ // same overlay idiom as Modal (backdrop click / Escape closes, body scroll
5
+ // locks while open), just built directly on Portal instead of Kobalte's
6
+ // Dialog so index/open state can be driven by the caller.
7
+ import { ChevronLeft, ChevronRight, X, ZoomIn } from 'lucide-solid'
8
+ import type { JSX } from 'solid-js'
9
+ import { createEffect, createMemo, createSignal, For, onCleanup, Show } from 'solid-js'
10
+
11
+ import { cn } from '../lib/cn'
12
+ import { motionReduced } from '../lib/motion'
13
+ import { Portal } from './Portal'
14
+
15
+ /** One image in a {@link Lightbox}. */
16
+ export interface LightboxImage {
17
+ src: string
18
+ alt?: string
19
+ /** Smaller image used in the thumbnail grid/strip; falls back to `src`. */
20
+ thumb?: string
21
+ }
22
+
23
+ export interface LightboxProps {
24
+ images: LightboxImage[]
25
+ /** Controlled overlay open state. When set, wins over internal state — pair with `onOpenChange`. */
26
+ open?: boolean
27
+ /** Controlled active image index (clamped to the images array). When set, wins over internal state — pair with `onIndexChange`. */
28
+ index?: number
29
+ onOpenChange?: (open: boolean) => void
30
+ onIndexChange?: (index: number) => void
31
+ /** Show the thumbnail strip at the bottom of the overlay. @default true */
32
+ showThumbnails?: boolean
33
+ class?: string
34
+ }
35
+
36
+ /**
37
+ * Responsive thumbnail grid (`thumb || src`) that opens into a full-screen
38
+ * viewer on click: the active image centered, prev/next chevrons, a close
39
+ * button, an alt-text caption, a zoom toggle, and (by default) a thumbnail
40
+ * strip with the active image highlighted. Navigate with the arrow keys or
41
+ * Escape, click the backdrop to close. Works controlled
42
+ * (`open`/`index` + `onOpenChange`/`onIndexChange`) or uncontrolled. Body
43
+ * scroll locks while open, like `Modal`; the zoom transition is skipped
44
+ * under `prefers-reduced-motion` but stays fully functional.
45
+ *
46
+ * @example
47
+ * ```tsx
48
+ * <Lightbox
49
+ * images={[
50
+ * { src: '/photos/1-full.jpg', thumb: '/photos/1-thumb.jpg', alt: 'Sunset over the bay' },
51
+ * { src: '/photos/2-full.jpg', thumb: '/photos/2-thumb.jpg', alt: 'Mountain trail' },
52
+ * ]}
53
+ * />
54
+ * ```
55
+ */
56
+ export function Lightbox(props: LightboxProps): JSX.Element {
57
+ const [internalOpen, setInternalOpen] = createSignal(false)
58
+ const open = createMemo(() => props.open ?? internalOpen())
59
+
60
+ const [internalIndex, setInternalIndex] = createSignal(0)
61
+ const rawIndex = createMemo(() => props.index ?? internalIndex())
62
+
63
+ const lastIndex = createMemo(() => Math.max(0, props.images.length - 1))
64
+ const clampIndex = (i: number): number => Math.min(Math.max(i, 0), lastIndex())
65
+ const index = createMemo(() => clampIndex(rawIndex()))
66
+ const current = createMemo(() => props.images[index()])
67
+
68
+ const setOpen = (next: boolean): void => {
69
+ setInternalOpen(next)
70
+ props.onOpenChange?.(next)
71
+ }
72
+ const setIndex = (next: number): void => {
73
+ const clamped = clampIndex(next)
74
+ setInternalIndex(clamped)
75
+ props.onIndexChange?.(clamped)
76
+ }
77
+
78
+ const openAt = (i: number): void => {
79
+ setIndex(i)
80
+ setOpen(true)
81
+ }
82
+ const close = (): void => setOpen(false)
83
+ const prev = (): void => setIndex(index() - 1)
84
+ const next = (): void => setIndex(index() + 1)
85
+ const showThumbnails = (): boolean => props.showThumbnails !== false
86
+
87
+ // Zoom toggle for the active image; reset whenever the image or open state changes.
88
+ const [zoomed, setZoomed] = createSignal(false)
89
+ createEffect(() => {
90
+ index()
91
+ open()
92
+ setZoomed(false)
93
+ })
94
+
95
+ // Lock body scroll while the overlay is open — same idiom as Modal's Dialog.
96
+ createEffect(() => {
97
+ if (!open() || typeof document === 'undefined') return
98
+ const previousOverflow = document.body.style.overflow
99
+ document.body.style.overflow = 'hidden'
100
+ onCleanup(() => {
101
+ document.body.style.overflow = previousOverflow
102
+ })
103
+ })
104
+
105
+ // Escape closes, ArrowLeft/ArrowRight navigate, while the overlay is open.
106
+ createEffect(() => {
107
+ if (!open() || typeof window === 'undefined') return
108
+ const onKeyDown = (e: KeyboardEvent): void => {
109
+ if (e.key === 'Escape') {
110
+ e.preventDefault()
111
+ close()
112
+ } else if (e.key === 'ArrowLeft') {
113
+ e.preventDefault()
114
+ prev()
115
+ } else if (e.key === 'ArrowRight') {
116
+ e.preventDefault()
117
+ next()
118
+ }
119
+ }
120
+ window.addEventListener('keydown', onKeyDown)
121
+ onCleanup(() => window.removeEventListener('keydown', onKeyDown))
122
+ })
123
+
124
+ return (
125
+ <div class={cn('grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4', props.class)}>
126
+ <For each={props.images}>
127
+ {(image, i) => (
128
+ <button
129
+ type="button"
130
+ class="group relative aspect-square overflow-hidden rounded-lg border border-border bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
131
+ aria-label={image.alt ?? `Open image ${i() + 1}`}
132
+ onClick={() => openAt(i())}
133
+ >
134
+ <img
135
+ src={image.thumb ?? image.src}
136
+ alt={image.alt ?? ''}
137
+ loading="lazy"
138
+ class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
139
+ />
140
+ </button>
141
+ )}
142
+ </For>
143
+
144
+ <Show when={open() && current()}>
145
+ <Portal>
146
+ <div
147
+ role="dialog"
148
+ aria-modal="true"
149
+ aria-label={current()?.alt ?? 'Image viewer'}
150
+ tabindex="-1"
151
+ class="fixed inset-0 z-50 flex flex-col bg-background/95 backdrop-blur-sm outline-none"
152
+ onClick={close}
153
+ ref={(el) => {
154
+ queueMicrotask(() => el.focus())
155
+ }}
156
+ >
157
+ <div class="flex items-center justify-end p-4" onClick={(e) => e.stopPropagation()}>
158
+ <button
159
+ type="button"
160
+ aria-label="Close"
161
+ onClick={close}
162
+ class="grid h-10 w-10 place-items-center rounded-lg bg-card/80 text-foreground transition hover:bg-muted"
163
+ >
164
+ <X class="h-5 w-5" />
165
+ </button>
166
+ </div>
167
+
168
+ <div class="relative flex flex-1 items-center justify-center px-4">
169
+ <Show when={props.images.length > 1}>
170
+ <button
171
+ type="button"
172
+ aria-label="Previous image"
173
+ disabled={index() === 0}
174
+ onClick={(e) => {
175
+ e.stopPropagation()
176
+ prev()
177
+ }}
178
+ class="absolute left-2 z-10 grid h-10 w-10 place-items-center rounded-full bg-card/80 text-foreground transition hover:bg-muted disabled:pointer-events-none disabled:opacity-40 sm:left-4"
179
+ >
180
+ <ChevronLeft class="h-6 w-6" />
181
+ </button>
182
+ </Show>
183
+
184
+ <img
185
+ src={current()?.src}
186
+ alt={current()?.alt ?? ''}
187
+ onClick={(e) => {
188
+ e.stopPropagation()
189
+ setZoomed((z) => !z)
190
+ }}
191
+ class={cn(
192
+ 'max-h-[calc(100vh-10rem)] max-w-[92vw] rounded-lg object-contain',
193
+ !motionReduced() && 'transition-transform duration-300 ease-out',
194
+ zoomed() ? 'scale-150 cursor-zoom-out' : 'cursor-zoom-in',
195
+ )}
196
+ />
197
+
198
+ <Show when={props.images.length > 1}>
199
+ <button
200
+ type="button"
201
+ aria-label="Next image"
202
+ disabled={index() === lastIndex()}
203
+ onClick={(e) => {
204
+ e.stopPropagation()
205
+ next()
206
+ }}
207
+ class="absolute right-2 z-10 grid h-10 w-10 place-items-center rounded-full bg-card/80 text-foreground transition hover:bg-muted disabled:pointer-events-none disabled:opacity-40 sm:right-4"
208
+ >
209
+ <ChevronRight class="h-6 w-6" />
210
+ </button>
211
+ </Show>
212
+ </div>
213
+
214
+ <div class="flex flex-col items-center gap-3 p-4" onClick={(e) => e.stopPropagation()}>
215
+ <div class="flex items-center gap-3">
216
+ <Show when={current()?.alt}>
217
+ <p class="text-center text-sm text-muted-foreground">{current()?.alt}</p>
218
+ </Show>
219
+ <button
220
+ type="button"
221
+ aria-label={zoomed() ? 'Zoom out' : 'Zoom in'}
222
+ aria-pressed={zoomed()}
223
+ onClick={() => setZoomed((z) => !z)}
224
+ class={cn(
225
+ 'grid h-8 w-8 shrink-0 place-items-center rounded-lg text-muted-foreground transition hover:bg-muted hover:text-foreground',
226
+ zoomed() && 'bg-muted text-foreground',
227
+ )}
228
+ >
229
+ <ZoomIn class="h-4 w-4" />
230
+ </button>
231
+ </div>
232
+
233
+ <Show when={showThumbnails() && props.images.length > 1}>
234
+ <div class="flex max-w-full gap-2 overflow-x-auto py-1">
235
+ <For each={props.images}>
236
+ {(image, i) => (
237
+ <button
238
+ type="button"
239
+ aria-label={image.alt ?? `Go to image ${i() + 1}`}
240
+ aria-current={index() === i() ? 'true' : undefined}
241
+ onClick={() => setIndex(i())}
242
+ class={cn(
243
+ 'h-14 w-14 shrink-0 overflow-hidden rounded-md border-2 transition',
244
+ index() === i()
245
+ ? 'border-primary'
246
+ : 'border-transparent opacity-60 hover:opacity-100',
247
+ )}
248
+ >
249
+ <img src={image.thumb ?? image.src} alt="" class="h-full w-full object-cover" />
250
+ </button>
251
+ )}
252
+ </For>
253
+ </div>
254
+ </Show>
255
+ </div>
256
+ </div>
257
+ </Portal>
258
+ </Show>
259
+ </div>
260
+ )
261
+ }