@a4ui/core 0.33.0 → 0.35.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/dist/elements.css +299 -0
- package/dist/full.css +299 -0
- package/dist/index.d.ts +27 -1
- package/dist/index.js +9140 -5065
- package/dist/ui/ActivityFeed.d.ts +49 -0
- package/dist/ui/AudioWaveform.d.ts +22 -0
- package/dist/ui/AvailabilityPicker.d.ts +35 -0
- package/dist/ui/CodeEditor.d.ts +25 -0
- package/dist/ui/CouponField.d.ts +33 -0
- package/dist/ui/EmojiPicker.d.ts +18 -0
- package/dist/ui/EventScheduler.d.ts +41 -0
- package/dist/ui/FollowingPointer.d.ts +24 -0
- package/dist/ui/GanttChart.d.ts +37 -0
- package/dist/ui/InteractiveMap.d.ts +50 -0
- package/dist/ui/JsonViewer.d.ts +24 -0
- package/dist/ui/Kanban.d.ts +49 -0
- package/dist/ui/Lamp.d.ts +22 -0
- package/dist/ui/Lightbox.d.ts +41 -0
- package/dist/ui/LocationPicker.d.ts +25 -0
- package/dist/ui/MaskedInput.d.ts +26 -0
- package/dist/ui/OnboardingChecklist.d.ts +51 -0
- package/dist/ui/OtpInput.d.ts +29 -0
- package/dist/ui/PivotTable.d.ts +37 -0
- package/dist/ui/PresenceAvatars.d.ts +54 -0
- package/dist/ui/QueryBuilder.d.ts +56 -0
- package/dist/ui/SheetSnap.d.ts +28 -0
- package/dist/ui/SignaturePad.d.ts +19 -0
- package/dist/ui/SpreadsheetGrid.d.ts +27 -0
- package/dist/ui/TreeTable.d.ts +52 -0
- package/dist/ui/VideoPlayerShell.d.ts +19 -0
- package/package.json +1 -1
- package/src/index.ts +60 -1
- package/src/ui/ActivityFeed.tsx +178 -0
- package/src/ui/AudioWaveform.tsx +190 -0
- package/src/ui/AvailabilityPicker.tsx +163 -0
- package/src/ui/CodeEditor.tsx +277 -0
- package/src/ui/CouponField.tsx +120 -0
- package/src/ui/EmojiPicker.tsx +424 -0
- package/src/ui/EventScheduler.tsx +280 -0
- package/src/ui/FollowingPointer.tsx +112 -0
- package/src/ui/GanttChart.tsx +283 -0
- package/src/ui/InteractiveMap.tsx +349 -0
- package/src/ui/JsonViewer.tsx +189 -0
- package/src/ui/Kanban.tsx +250 -0
- package/src/ui/Lamp.tsx +88 -0
- package/src/ui/Lightbox.tsx +261 -0
- package/src/ui/LocationPicker.tsx +96 -0
- package/src/ui/MaskedInput.tsx +108 -0
- package/src/ui/OnboardingChecklist.tsx +163 -0
- package/src/ui/OtpInput.tsx +153 -0
- package/src/ui/PivotTable.tsx +0 -0
- package/src/ui/PresenceAvatars.tsx +142 -0
- package/src/ui/QueryBuilder.tsx +280 -0
- package/src/ui/SheetSnap.tsx +264 -0
- package/src/ui/SignaturePad.tsx +221 -0
- package/src/ui/SpreadsheetGrid.tsx +304 -0
- package/src/ui/TreeTable.tsx +172 -0
- package/src/ui/VideoPlayerShell.tsx +282 -0
|
@@ -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
|
+
}
|
package/src/ui/Lamp.tsx
ADDED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// A form field pairing a free-text label with an InteractiveMap: click (or
|
|
2
|
+
// drag-release) on the map to drop the pin, or use the browser's geolocation
|
|
3
|
+
// to jump straight to the user's position. Controlled (`value`/`onChange`) or
|
|
4
|
+
// uncontrolled (internal signal, seeded once from `value`).
|
|
5
|
+
import { Crosshair } from 'lucide-solid'
|
|
6
|
+
import { createSignal, type JSX } from 'solid-js'
|
|
7
|
+
|
|
8
|
+
import { cn } from '../lib/cn'
|
|
9
|
+
import { Button } from './Button'
|
|
10
|
+
import { InteractiveMap, type MapMarker } from './InteractiveMap'
|
|
11
|
+
|
|
12
|
+
export interface LocationValue {
|
|
13
|
+
lat: number
|
|
14
|
+
lng: number
|
|
15
|
+
label?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface LocationPickerProps {
|
|
19
|
+
/** Controlled value. Omit to let the component manage its own state. */
|
|
20
|
+
value?: LocationValue
|
|
21
|
+
/** Fires with the full value whenever the label, the pin, or geolocation changes it. */
|
|
22
|
+
onChange?: (v: LocationValue) => void
|
|
23
|
+
class?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const MAP_ZOOM = 12
|
|
27
|
+
const MAP_HEIGHT = 240
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Text label + compact map field for picking a location: click or drag the
|
|
31
|
+
* map to drop the pin (no geocoding — the label is a free-text tag the caller
|
|
32
|
+
* attaches to the coordinates), or use the "use my location" button.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```tsx
|
|
36
|
+
* const [place, setPlace] = createSignal<LocationValue>({ lat: 48.8584, lng: 2.2945, label: 'Eiffel Tower' })
|
|
37
|
+
* <LocationPicker value={place()} onChange={setPlace} />
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export function LocationPicker(props: LocationPickerProps): JSX.Element {
|
|
41
|
+
const [internal, setInternal] = createSignal<LocationValue>(props.value ?? { lat: 0, lng: 0 })
|
|
42
|
+
|
|
43
|
+
// Controlled when `value` is passed (read fresh every time, like a normal
|
|
44
|
+
// controlled input); falls back to internal state otherwise.
|
|
45
|
+
const current = (): LocationValue => props.value ?? internal()
|
|
46
|
+
|
|
47
|
+
const emit = (next: LocationValue): void => {
|
|
48
|
+
setInternal(next)
|
|
49
|
+
props.onChange?.(next)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const setCoord = (coord: { lat: number; lng: number }): void => {
|
|
53
|
+
emit({ ...current(), lat: coord.lat, lng: coord.lng })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const setLabel = (label: string): void => {
|
|
57
|
+
emit({ ...current(), label })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const useMyLocation = (): void => {
|
|
61
|
+
if (typeof navigator === 'undefined' || !navigator.geolocation) return
|
|
62
|
+
navigator.geolocation.getCurrentPosition(
|
|
63
|
+
(position) => setCoord({ lat: position.coords.latitude, lng: position.coords.longitude }),
|
|
64
|
+
(error) => console.error('LocationPicker: geolocation failed', error),
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const pin = (): MapMarker[] => {
|
|
69
|
+
const { lat, lng, label } = current()
|
|
70
|
+
return [{ lat, lng, label: label || undefined, id: 'picked' }]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div class={cn('flex flex-col gap-2', props.class)}>
|
|
75
|
+
<div class="flex gap-2">
|
|
76
|
+
<input
|
|
77
|
+
type="text"
|
|
78
|
+
value={current().label ?? ''}
|
|
79
|
+
onInput={(event) => setLabel(event.currentTarget.value)}
|
|
80
|
+
placeholder="Location label"
|
|
81
|
+
class="h-9 flex-1 rounded-md border border-border bg-input px-3 text-sm text-foreground placeholder:text-muted-foreground focus:ring-2 focus:ring-ring focus:outline-none"
|
|
82
|
+
/>
|
|
83
|
+
<Button variant="outline" aria-label="Use my location" onClick={useMyLocation}>
|
|
84
|
+
<Crosshair class="h-4 w-4" />
|
|
85
|
+
</Button>
|
|
86
|
+
</div>
|
|
87
|
+
<InteractiveMap
|
|
88
|
+
center={{ lat: current().lat, lng: current().lng }}
|
|
89
|
+
zoom={MAP_ZOOM}
|
|
90
|
+
height={MAP_HEIGHT}
|
|
91
|
+
markers={pin()}
|
|
92
|
+
onClick={setCoord}
|
|
93
|
+
/>
|
|
94
|
+
</div>
|
|
95
|
+
)
|
|
96
|
+
}
|