@a4ui/core 0.29.1 → 0.31.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,196 @@
1
+ // CardSpread — a stack of cards that fans out into one of several layouts.
2
+ // Rest state is a tight near-centered stack; opening (controlled via `open`,
3
+ // or on hover when uncontrolled) spreads the cards per `layout`. Only
4
+ // `transform`/`opacity` are animated (Motion spring), never layout properties,
5
+ // so the fan stays compositor-only. Reduced motion renders the spread layout
6
+ // statically (no transition, no hover animation).
7
+ import { createEffect, createSignal, For, on, onCleanup, onMount, type JSX } from 'solid-js'
8
+
9
+ import { cn } from '../lib/cn'
10
+ import { animate, motionReduced } from '../lib/motion'
11
+
12
+ /** Fan shape applied when a {@link CardSpread} opens. Defaults to `'arc'`. */
13
+ export type CardSpreadLayout = 'arc' | 'long-arc' | 'linear' | 'corner' | 'cascade' | 'scatter' | 'wheel'
14
+
15
+ export interface CardSpreadProps {
16
+ /** Card contents; each entry becomes one card in the stack. */
17
+ items: JSX.Element[]
18
+ /** Fan shape. @default 'arc' */
19
+ layout?: CardSpreadLayout
20
+ /** Controlled open state. Omit to spread on hover instead. */
21
+ open?: boolean
22
+ /** Strength knob: degrees for arced/wheel layouts, px for linear/corner/cascade/scatter. Per-layout default applies when omitted. */
23
+ spread?: number
24
+ class?: string
25
+ 'aria-label'?: string
26
+ }
27
+
28
+ interface CardTransform {
29
+ x: number
30
+ y: number
31
+ rotate: number
32
+ origin: string
33
+ }
34
+
35
+ const DEFAULT_SPREAD: Record<CardSpreadLayout, number> = {
36
+ arc: 10,
37
+ 'long-arc': 6,
38
+ linear: 56,
39
+ corner: 12,
40
+ cascade: 18,
41
+ scatter: 14,
42
+ wheel: 16,
43
+ }
44
+
45
+ // Deterministic pseudo-random offset in [-1, 1] from an index — no Math.random.
46
+ function pseudoRandom(i: number): number {
47
+ const s = Math.sin(i * 12.9898) * 43758.5453
48
+ return (s - Math.floor(s)) * 2 - 1
49
+ }
50
+
51
+ function restTransform(i: number, n: number): CardTransform {
52
+ // Resting deck: a gentle symmetric peek-fan (cards' edges show) so the pile
53
+ // reads as multiple cards even before it opens, without stealing the reveal.
54
+ const mid = (n - 1) / 2
55
+ const d = i - mid
56
+ return { x: d * 7, y: Math.abs(d) * 2.5, rotate: d * 2.5, origin: 'bottom center' }
57
+ }
58
+
59
+ function openTransform(layout: CardSpreadLayout, i: number, n: number, spread: number): CardTransform {
60
+ const mid = (n - 1) / 2
61
+ const d = i - mid
62
+
63
+ switch (layout) {
64
+ case 'arc': {
65
+ const gap = 44
66
+ return { x: d * gap, y: Math.abs(d) ** 2 * 6, rotate: d * spread, origin: 'bottom center' }
67
+ }
68
+ case 'long-arc': {
69
+ const gap = 68
70
+ return { x: d * gap, y: Math.abs(d) ** 2 * 4, rotate: d * spread, origin: 'bottom center' }
71
+ }
72
+ case 'linear':
73
+ return { x: d * spread, y: 0, rotate: 0, origin: 'bottom center' }
74
+ case 'corner':
75
+ return { x: i * (spread * 0.6), y: -i * (spread * 0.15), rotate: i * spread, origin: 'bottom left' }
76
+ case 'cascade':
77
+ return {
78
+ x: i * spread,
79
+ y: i * (spread * 0.55),
80
+ rotate: (i % 2 === 0 ? 1 : -1) * 3,
81
+ origin: 'bottom center',
82
+ }
83
+ case 'scatter': {
84
+ const r = pseudoRandom(i)
85
+ const r2 = pseudoRandom(i + 100)
86
+ return { x: r * spread * 3, y: r2 * spread * 1.5, rotate: r * spread, origin: 'center center' }
87
+ }
88
+ case 'wheel':
89
+ return { x: d * 18, y: Math.abs(d) * 10, rotate: d * spread, origin: 'bottom center' }
90
+ }
91
+ }
92
+
93
+ function transformString(t: CardTransform): string {
94
+ return `translate(${t.x}px, ${t.y}px) rotate(${t.rotate}deg)`
95
+ }
96
+
97
+ /**
98
+ * A stack of cards that fans out into an arc, line, corner, cascade, scatter,
99
+ * or wheel shape. At rest the cards sit in a near-centered pile; opening
100
+ * (controlled via `open`, or on hover when uncontrolled) spreads them.
101
+ * Animates only `transform`/`opacity` (Motion spring) so the fan stays
102
+ * compositor-only, and falls back to a static fanned layout under reduced
103
+ * motion.
104
+ *
105
+ * @example
106
+ * ```tsx
107
+ * <CardSpread
108
+ * layout="arc"
109
+ * aria-label="Trip itinerary cards"
110
+ * items={[
111
+ * <div class="p-4">Day 1 — Arrival</div>,
112
+ * <div class="p-4">Day 2 — Old Town</div>,
113
+ * <div class="p-4">Day 3 — Coast drive</div>,
114
+ * ]}
115
+ * />
116
+ * ```
117
+ */
118
+ export function CardSpread(props: CardSpreadProps): JSX.Element {
119
+ const cardRefs: (HTMLElement | undefined)[] = []
120
+ const controls: (ReturnType<typeof animate> | undefined)[] = []
121
+ const [hovered, setHovered] = createSignal(false)
122
+
123
+ const layout = () => props.layout ?? 'arc'
124
+ const spread = () => props.spread ?? DEFAULT_SPREAD[layout()]
125
+ const isOpen = () => (props.open !== undefined ? props.open : hovered())
126
+
127
+ // Reduced motion always renders the fanned layout, statically, regardless
128
+ // of open/hover state — so `open`/`hover` never trigger visible motion.
129
+ const applyTransforms = (animated: boolean) => {
130
+ const n = props.items.length
131
+ const reduced = motionReduced()
132
+ const open = reduced ? true : isOpen()
133
+
134
+ cardRefs.forEach((card, i) => {
135
+ if (!card) return
136
+ const t = open ? openTransform(layout(), i, n, spread()) : restTransform(i, n)
137
+ card.style.transformOrigin = t.origin
138
+ card.style.zIndex = String(open ? i : n - i)
139
+
140
+ controls[i]?.stop()
141
+ if (reduced || !animated) {
142
+ card.style.transform = transformString(t)
143
+ return
144
+ }
145
+ controls[i] = animate(
146
+ card,
147
+ { x: t.x, y: t.y, rotate: t.rotate },
148
+ { type: 'spring', stiffness: 260, damping: 24 },
149
+ )
150
+ })
151
+ }
152
+
153
+ // Initial paint only: snap to the correct state (no animation) so a controlled
154
+ // `open` or a reduced-motion preference doesn't flash the closed stack first.
155
+ // Deliberately untracked (onMount, not createEffect) — it must run exactly
156
+ // once, otherwise it would re-fire on every hover/open change and snap the
157
+ // cards straight to their target, leaving the animated effect below nothing
158
+ // to spring from.
159
+ onMount(() => applyTransforms(false))
160
+ // Subsequent changes to open/hover/layout/spread animate in.
161
+ createEffect(
162
+ on([() => props.open, hovered, layout, spread, () => props.items.length], () => applyTransforms(true), {
163
+ defer: true,
164
+ }),
165
+ )
166
+ onCleanup(() => controls.forEach((c) => c?.stop()))
167
+
168
+ const handleEnter = () => {
169
+ if (props.open === undefined && !motionReduced()) setHovered(true)
170
+ }
171
+ const handleLeave = () => {
172
+ if (props.open === undefined && !motionReduced()) setHovered(false)
173
+ }
174
+
175
+ return (
176
+ <div
177
+ role="group"
178
+ aria-label={props['aria-label']}
179
+ class={cn('relative inline-block h-56 w-40', props.class)}
180
+ onPointerEnter={handleEnter}
181
+ onPointerLeave={handleLeave}
182
+ >
183
+ <For each={props.items}>
184
+ {(item, i) => (
185
+ <div
186
+ ref={(el) => (cardRefs[i()] = el)}
187
+ class="absolute inset-x-0 top-0 h-56 w-40 rounded-xl border border-border bg-card text-card-foreground shadow-sm will-change-transform"
188
+ style={{ transform: transformString(restTransform(i(), props.items.length)) }}
189
+ >
190
+ {item}
191
+ </div>
192
+ )}
193
+ </For>
194
+ </div>
195
+ )
196
+ }
@@ -0,0 +1,308 @@
1
+ // Carousel3D — a 3D coverflow/arc carousel. A `perspective` + `preserve-3d`
2
+ // stage holds absolutely-positioned slides that are repositioned ONLY via
3
+ // `transform` (translate/rotateY/scale) + `opacity` — never width/height/
4
+ // top/left — so every pose change stays compositor-only. Transform changes
5
+ // are driven by a Motion spring when the active slide changes. Falls back to
6
+ // a flat opacity crossfade (no 3D) under reduced motion.
7
+ import { ChevronLeft, ChevronRight } from 'lucide-solid'
8
+ import { createEffect, createMemo, createSignal, For, on, onCleanup, onMount, type JSX } from 'solid-js'
9
+
10
+ import { cn } from '../lib/cn'
11
+ import { animate, motionReduced } from '../lib/motion'
12
+
13
+ /** Layout curve for a {@link Carousel3D}. Defaults to `'coverflow'`. */
14
+ export type Carousel3DVariant = 'coverflow' | 'arc'
15
+
16
+ export interface Carousel3DProps {
17
+ /** Slides shown around the active one, arranged by distance from it. */
18
+ slides: JSX.Element[]
19
+ /** Layout curve. @default 'coverflow' */
20
+ variant?: Carousel3DVariant
21
+ /** Controlled active index. */
22
+ active?: number
23
+ /** Initial active index when uncontrolled. @default 0 */
24
+ defaultActive?: number
25
+ /** Fires with the new index whenever the active slide changes. */
26
+ onChange?: (index: number) => void
27
+ class?: string
28
+ }
29
+
30
+ interface SlidePose {
31
+ x: number
32
+ y: number
33
+ z: number
34
+ rotateY: number
35
+ scale: number
36
+ opacity: number
37
+ }
38
+
39
+ // Base position (before the per-offset pose) centers each slide in the stage;
40
+ // the transform's x always starts at `-50%` and adds pixel offsets on top.
41
+ const FLAT_TRANSFORM = 'translate3d(-50%, 0, 0)'
42
+
43
+ const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value))
44
+
45
+ function coverflowPose(offset: number): SlidePose {
46
+ const spacing = 130
47
+ const depth = 160
48
+ const angle = 55
49
+ return {
50
+ x: offset * spacing,
51
+ y: 0,
52
+ z: -Math.abs(offset) * depth,
53
+ rotateY: clamp(-offset * angle, -45, 45),
54
+ scale: offset === 0 ? 1 : 0.82,
55
+ opacity: clamp(1 - Math.abs(offset) * 0.35, 0, 1),
56
+ }
57
+ }
58
+
59
+ function arcPose(offset: number): SlidePose {
60
+ const spacing = 110
61
+ const angle = 14
62
+ const dip = 16
63
+ return {
64
+ x: offset * spacing,
65
+ y: offset ** 2 * dip,
66
+ z: -Math.abs(offset) * 70,
67
+ rotateY: clamp(offset * angle, -50, 50),
68
+ scale: clamp(1 - Math.abs(offset) * 0.16, 0.55, 1),
69
+ opacity: clamp(1 - Math.abs(offset) * 0.3, 0, 1),
70
+ }
71
+ }
72
+
73
+ function poseFor(variant: Carousel3DVariant, offset: number): SlidePose {
74
+ return variant === 'arc' ? arcPose(offset) : coverflowPose(offset)
75
+ }
76
+
77
+ function poseTransform(pose: SlidePose): string {
78
+ return `translate3d(calc(-50% + ${pose.x}px), ${pose.y}px, ${pose.z}px) rotateY(${pose.rotateY}deg) scale(${pose.scale})`
79
+ }
80
+
81
+ /**
82
+ * A 3D carousel that arranges slides around the active one on a coverflow or
83
+ * shallow arc curve — `perspective` + `preserve-3d`, each slide positioned
84
+ * purely by `transform`/`opacity` (Motion spring) so it stays
85
+ * compositor-only. Supports controlled/uncontrolled `active`, prev/next
86
+ * buttons, dot indicators, arrow-key navigation, and pointer-drag swiping.
87
+ * Falls back to a flat opacity crossfade (no 3D transforms) under reduced
88
+ * motion.
89
+ *
90
+ * @example
91
+ * ```tsx
92
+ * <Carousel3D
93
+ * variant="coverflow"
94
+ * slides={[
95
+ * <div class="grid h-full place-items-center rounded-xl border border-border bg-card p-6 text-center">Trail A — Ridge Loop</div>,
96
+ * <div class="grid h-full place-items-center rounded-xl border border-border bg-card p-6 text-center">Trail B — Falls Overlook</div>,
97
+ * <div class="grid h-full place-items-center rounded-xl border border-border bg-card p-6 text-center">Trail C — Summit Pass</div>,
98
+ * ]}
99
+ * />
100
+ * ```
101
+ */
102
+ export function Carousel3D(props: Carousel3DProps): JSX.Element {
103
+ const [uncontrolled, setUncontrolled] = createSignal(props.defaultActive ?? 0)
104
+ const count = (): number => props.slides.length
105
+ const clampIndex = (i: number): number => clamp(i, 0, Math.max(0, count() - 1))
106
+ const isControlled = (): boolean => props.active !== undefined
107
+ const active = createMemo((): number =>
108
+ clampIndex(isControlled() ? (props.active as number) : uncontrolled()),
109
+ )
110
+ const variant = (): Carousel3DVariant => props.variant ?? 'coverflow'
111
+
112
+ const setActive = (index: number): void => {
113
+ const next = clampIndex(index)
114
+ if (next === active()) return
115
+ if (!isControlled()) setUncontrolled(next)
116
+ props.onChange?.(next)
117
+ }
118
+ const prev = (): void => setActive(active() - 1)
119
+ const next = (): void => setActive(active() + 1)
120
+
121
+ const onKeyDown = (e: KeyboardEvent): void => {
122
+ if (e.key === 'ArrowLeft') {
123
+ e.preventDefault()
124
+ prev()
125
+ } else if (e.key === 'ArrowRight') {
126
+ e.preventDefault()
127
+ next()
128
+ }
129
+ }
130
+
131
+ // --- pose application (static snap on mount, Motion spring on change) ----
132
+ let stage: HTMLDivElement | undefined
133
+ const controls = new Map<number, ReturnType<typeof animate>>()
134
+
135
+ const stopAll = (): void => {
136
+ controls.forEach((c) => c.stop())
137
+ controls.clear()
138
+ }
139
+
140
+ const items = (): HTMLElement[] =>
141
+ stage ? Array.from(stage.querySelectorAll<HTMLElement>('[data-carousel3d-item]')) : []
142
+
143
+ function applyStatic(): void {
144
+ const a = active()
145
+ const v = variant()
146
+ const reduced = motionReduced()
147
+ items().forEach((el, i) => {
148
+ el.style.zIndex = String(-Math.abs(i - a))
149
+ if (reduced) {
150
+ el.style.transform = FLAT_TRANSFORM
151
+ el.style.opacity = i === a ? '1' : '0'
152
+ return
153
+ }
154
+ const pose = poseFor(v, i - a)
155
+ el.style.transform = poseTransform(pose)
156
+ el.style.opacity = String(pose.opacity)
157
+ })
158
+ }
159
+
160
+ function applyAnimated(): void {
161
+ if (!stage) return
162
+ const a = active()
163
+ const v = variant()
164
+ const reduced = motionReduced()
165
+ stopAll()
166
+ items().forEach((el, i) => {
167
+ el.style.zIndex = String(-Math.abs(i - a))
168
+ if (reduced) {
169
+ el.style.transform = FLAT_TRANSFORM
170
+ controls.set(i, animate(el, { opacity: i === a ? 1 : 0 }, { duration: 0.25, ease: 'easeOut' }))
171
+ return
172
+ }
173
+ const pose = poseFor(v, i - a)
174
+ controls.set(
175
+ i,
176
+ animate(
177
+ el,
178
+ {
179
+ x: pose.x,
180
+ y: pose.y,
181
+ z: pose.z,
182
+ rotateY: pose.rotateY,
183
+ scale: pose.scale,
184
+ opacity: pose.opacity,
185
+ },
186
+ { type: 'spring', stiffness: 300, damping: 32 },
187
+ ),
188
+ )
189
+ })
190
+ }
191
+
192
+ onMount(applyStatic)
193
+
194
+ // Re-pose (animated) whenever the active slide, variant, slide count, or
195
+ // reduced-motion state changes. `defer: true` skips the initial run — the
196
+ // onMount `applyStatic` above already seeds the correct pose with no
197
+ // animation, so a slide never flashes in from an untransformed position.
198
+ createEffect(on([active, variant, count, motionReduced], () => applyAnimated(), { defer: true }))
199
+
200
+ onCleanup(stopAll)
201
+
202
+ // --- pointer drag: horizontal movement past a threshold advances one slide
203
+ // per threshold crossed, so a long drag can page through several slides. --
204
+ const [dragging, setDragging] = createSignal(false)
205
+ let startX = 0
206
+ const DRAG_THRESHOLD = 60
207
+
208
+ const onPointerDown = (e: PointerEvent): void => {
209
+ if (count() < 2) return
210
+ startX = e.clientX
211
+ setDragging(true)
212
+ ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
213
+ }
214
+ const onPointerMove = (e: PointerEvent): void => {
215
+ if (!dragging()) return
216
+ const dx = e.clientX - startX
217
+ if (dx <= -DRAG_THRESHOLD) {
218
+ next()
219
+ startX = e.clientX
220
+ } else if (dx >= DRAG_THRESHOLD) {
221
+ prev()
222
+ startX = e.clientX
223
+ }
224
+ }
225
+ const endDrag = (): void => {
226
+ setDragging(false)
227
+ }
228
+
229
+ return (
230
+ <div
231
+ role="region"
232
+ aria-roledescription="carousel"
233
+ tabindex="0"
234
+ class={cn(
235
+ 'flex w-full flex-col gap-4 outline-none focus-visible:ring-2 focus-visible:ring-primary',
236
+ props.class,
237
+ )}
238
+ onKeyDown={onKeyDown}
239
+ >
240
+ <div
241
+ class={cn(
242
+ 'relative h-80 select-none',
243
+ count() > 1 && 'touch-pan-y cursor-grab active:cursor-grabbing',
244
+ )}
245
+ style={{ perspective: '1200px' }}
246
+ onPointerDown={onPointerDown}
247
+ onPointerMove={onPointerMove}
248
+ onPointerUp={endDrag}
249
+ onPointerCancel={endDrag}
250
+ >
251
+ <div ref={stage} class="relative h-full w-full" style={{ 'transform-style': 'preserve-3d' }}>
252
+ <For each={props.slides}>
253
+ {(slide, i) => (
254
+ <div
255
+ data-carousel3d-item
256
+ class="absolute left-1/2 top-0 h-full w-64 will-change-transform"
257
+ aria-hidden={i() !== active()}
258
+ >
259
+ {slide}
260
+ </div>
261
+ )}
262
+ </For>
263
+ </div>
264
+
265
+ <button
266
+ type="button"
267
+ aria-label="Previous slide"
268
+ disabled={count() === 0 || active() === 0}
269
+ onClick={prev}
270
+ 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 disabled:pointer-events-none disabled:opacity-40"
271
+ >
272
+ <ChevronLeft class="h-5 w-5" />
273
+ </button>
274
+ <button
275
+ type="button"
276
+ aria-label="Next slide"
277
+ disabled={count() === 0 || active() === count() - 1}
278
+ onClick={next}
279
+ 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 disabled:pointer-events-none disabled:opacity-40"
280
+ >
281
+ <ChevronRight class="h-5 w-5" />
282
+ </button>
283
+ </div>
284
+
285
+ <div class="flex items-center justify-center gap-1">
286
+ <For each={props.slides}>
287
+ {(_, i) => (
288
+ // 24×24 hit target (a11y target-size) around a small visual dot.
289
+ <button
290
+ type="button"
291
+ aria-label={`Go to slide ${i() + 1}`}
292
+ aria-current={active() === i() ? 'true' : undefined}
293
+ onClick={() => setActive(i())}
294
+ class="grid h-6 w-6 place-items-center rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
295
+ >
296
+ <span
297
+ class={cn(
298
+ 'h-2 w-2 rounded-full transition-colors',
299
+ active() === i() ? 'bg-primary' : 'bg-muted',
300
+ )}
301
+ />
302
+ </button>
303
+ )}
304
+ </For>
305
+ </div>
306
+ </div>
307
+ )
308
+ }
@@ -0,0 +1,69 @@
1
+ // Spotlight-the-hovered-one container: hovering/focusing one item keeps it
2
+ // sharp/full-opacity while the rest blur + dim. Purely visual — pointer/focus
3
+ // driven, never traps focus or hijacks keyboard. Only `opacity` and `filter`
4
+ // (blur) are animated so the effect stays a compositor-only transition (no
5
+ // layout props ever transition).
6
+ import { createSignal, For, type JSX } from 'solid-js'
7
+
8
+ import { cn } from '../lib/cn'
9
+ import { motionReduced } from '../lib/motion'
10
+
11
+ export interface FocusBlurGroupProps<T> {
12
+ /** The full data set to render. */
13
+ items: T[]
14
+ /** Row renderer. */
15
+ children: (item: T, index: number) => JSX.Element
16
+ /** Blur (px) applied to non-focused items. @default 2 */
17
+ blur?: number
18
+ /** Opacity (0..1) applied to non-focused items. @default 0.5 */
19
+ dim?: number
20
+ /** Applied to the container — use it for layout (e.g. `flex gap-6`). */
21
+ class?: string
22
+ }
23
+
24
+ /**
25
+ * Container that spotlights whichever item is hovered or keyboard-focused:
26
+ * that item stays sharp and fully opaque while its siblings blur and dim.
27
+ * Only `opacity`/`filter` transition — never a layout property — and under
28
+ * `motionReduced()` the blur is skipped (dim-only, no transition) since blur
29
+ * motion reads as motion-sickness-adjacent.
30
+ *
31
+ * @example
32
+ * ```tsx
33
+ * <FocusBlurGroup items={navLinks} class="flex gap-6">
34
+ * {(link) => (
35
+ * <a href={link.href} class="text-sm font-medium text-foreground">
36
+ * {link.label}
37
+ * </a>
38
+ * )}
39
+ * </FocusBlurGroup>
40
+ * ```
41
+ */
42
+ export function FocusBlurGroup<T>(props: FocusBlurGroupProps<T>): JSX.Element {
43
+ const [hovered, setHovered] = createSignal<number | null>(null)
44
+
45
+ return (
46
+ <div class={cn(props.class)} onPointerLeave={() => setHovered(null)} onFocusOut={() => setHovered(null)}>
47
+ <For each={props.items}>
48
+ {(item, index) => {
49
+ const isFocused = () => hovered() === null || hovered() === index()
50
+ const reduced = motionReduced()
51
+
52
+ return (
53
+ <div
54
+ onPointerEnter={() => setHovered(index())}
55
+ onFocusIn={() => setHovered(index())}
56
+ style={{
57
+ opacity: isFocused() ? 1 : (props.dim ?? 0.5),
58
+ filter: reduced || isFocused() ? 'none' : `blur(${props.blur ?? 2}px)`,
59
+ transition: reduced ? 'none' : 'opacity 200ms ease, filter 200ms ease',
60
+ }}
61
+ >
62
+ {props.children(item, index())}
63
+ </div>
64
+ )
65
+ }}
66
+ </For>
67
+ </div>
68
+ )
69
+ }