@a4ui/core 0.16.0 → 0.18.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/{Checkbox-B5Gb3h5J.js → Checkbox-BZjX1GMG.js} +153 -146
- package/dist/commerce/ProductCard.d.ts +10 -0
- package/dist/commerce/createCart.d.ts +53 -0
- package/dist/commerce/index.d.ts +1 -0
- package/dist/commerce.js +160 -133
- package/dist/elements.css +244 -0
- package/dist/elements.iife.js +3 -3
- package/dist/elements.js +590 -583
- package/dist/full.css +244 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3090 -2798
- package/dist/layout/ActionBar.d.ts +53 -0
- package/dist/layout/Section.d.ts +31 -0
- package/dist/styles.css +66 -0
- package/dist/ui/Badge.d.ts +6 -0
- package/dist/ui/BeforeAfter.d.ts +34 -0
- package/dist/ui/Carousel.d.ts +6 -0
- package/dist/ui/FloatingActionButton.d.ts +6 -0
- package/dist/ui/Image.d.ts +6 -0
- package/dist/ui/List.d.ts +5 -0
- package/dist/ui/PricingTable.d.ts +60 -0
- package/dist/ui/Skeleton.d.ts +10 -3
- package/package.json +1 -1
- package/src/commerce/ProductCard.tsx +26 -2
- package/src/commerce/createCart.ts +102 -0
- package/src/commerce/index.ts +1 -0
- package/src/index.ts +5 -1
- package/src/layout/ActionBar.tsx +121 -0
- package/src/layout/Section.tsx +69 -0
- package/src/ui/Accordion.tsx +3 -1
- package/src/ui/Badge.tsx +14 -2
- package/src/ui/BeforeAfter.tsx +162 -0
- package/src/ui/Carousel.tsx +48 -3
- package/src/ui/Collapse.tsx +13 -4
- package/src/ui/FloatingActionButton.tsx +16 -1
- package/src/ui/Image.tsx +29 -1
- package/src/ui/List.tsx +10 -2
- package/src/ui/PricingTable.tsx +196 -0
- package/src/ui/Skeleton.tsx +17 -5
- package/src/ui/SpeedDial.tsx +3 -1
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Section — a centered content container with a max width and vertical rhythm.
|
|
2
|
+
// The layout wrapper every page repeats: `mx-auto` + a max-width + horizontal
|
|
3
|
+
// padding + section padding, with an optional `id` anchor for in-page nav. No
|
|
4
|
+
// visuals of its own — put a full-bleed background on a parent and let Section
|
|
5
|
+
// hold the centered content.
|
|
6
|
+
import type { JSX, ParentProps } from 'solid-js'
|
|
7
|
+
import { splitProps } from 'solid-js'
|
|
8
|
+
|
|
9
|
+
import { cn } from '../lib/cn'
|
|
10
|
+
|
|
11
|
+
/** Max content width of a {@link Section}. Defaults to `'6xl'`. */
|
|
12
|
+
export type SectionWidth = 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '4xl' | '6xl' | '7xl' | 'full'
|
|
13
|
+
/** Vertical padding of a {@link Section}. Defaults to `'lg'`. */
|
|
14
|
+
export type SectionPad = 'none' | 'sm' | 'md' | 'lg'
|
|
15
|
+
|
|
16
|
+
const WIDTH: Record<SectionWidth, string> = {
|
|
17
|
+
sm: 'max-w-sm',
|
|
18
|
+
md: 'max-w-md',
|
|
19
|
+
lg: 'max-w-lg',
|
|
20
|
+
xl: 'max-w-xl',
|
|
21
|
+
'2xl': 'max-w-2xl',
|
|
22
|
+
'4xl': 'max-w-4xl',
|
|
23
|
+
'6xl': 'max-w-6xl',
|
|
24
|
+
'7xl': 'max-w-7xl',
|
|
25
|
+
full: 'max-w-full',
|
|
26
|
+
}
|
|
27
|
+
const PAD: Record<SectionPad, string> = {
|
|
28
|
+
none: 'py-0',
|
|
29
|
+
sm: 'py-8',
|
|
30
|
+
md: 'py-14',
|
|
31
|
+
lg: 'py-20',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface SectionProps extends ParentProps, JSX.HTMLAttributes<HTMLElement> {
|
|
35
|
+
/** Max content width. Defaults to `'6xl'`. */
|
|
36
|
+
size?: SectionWidth
|
|
37
|
+
/** Vertical padding rhythm. Defaults to `'lg'`. */
|
|
38
|
+
py?: SectionPad
|
|
39
|
+
class?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Centered content container with a max width and consistent vertical rhythm —
|
|
44
|
+
* the layout wrapper repeated on every marketing/company page. Pass an `id` for
|
|
45
|
+
* anchor-based in-page navigation. For a full-bleed colored band, wrap Section
|
|
46
|
+
* in a parent that carries the background.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```tsx
|
|
50
|
+
* <Section id="services">
|
|
51
|
+
* <h2>Servicios</h2>
|
|
52
|
+
* </Section>
|
|
53
|
+
*
|
|
54
|
+
* <div class="bg-muted/30">
|
|
55
|
+
* <Section size="4xl" py="md">…</Section>
|
|
56
|
+
* </div>
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
export function Section(props: SectionProps): JSX.Element {
|
|
60
|
+
const [local, rest] = splitProps(props, ['size', 'py', 'class', 'children'])
|
|
61
|
+
return (
|
|
62
|
+
<section
|
|
63
|
+
class={cn('mx-auto w-full px-5', WIDTH[local.size ?? '6xl'], PAD[local.py ?? 'lg'], local.class)}
|
|
64
|
+
{...rest}
|
|
65
|
+
>
|
|
66
|
+
{local.children}
|
|
67
|
+
</section>
|
|
68
|
+
)
|
|
69
|
+
}
|
package/src/ui/Accordion.tsx
CHANGED
|
@@ -56,7 +56,9 @@ export function Accordion(props: AccordionProps): JSX.Element {
|
|
|
56
56
|
</svg>
|
|
57
57
|
</KAccordion.Trigger>
|
|
58
58
|
</KAccordion.Header>
|
|
59
|
-
<KAccordion.Content class="
|
|
59
|
+
<KAccordion.Content class="accordion-content text-sm text-muted-foreground">
|
|
60
|
+
<div class="pb-3">{item.content}</div>
|
|
61
|
+
</KAccordion.Content>
|
|
60
62
|
</KAccordion.Item>
|
|
61
63
|
)}
|
|
62
64
|
</For>
|
package/src/ui/Badge.tsx
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// (flowBadgeProps, statusBadgeTone, priorityBadgeTone, …) stay in the consuming
|
|
3
3
|
// app: they encode business vocabulary (income/expense, project priority, user
|
|
4
4
|
// roles), not design-system concerns.
|
|
5
|
-
import { type JSX, type ParentProps, splitProps } from 'solid-js'
|
|
5
|
+
import { type JSX, type ParentProps, Show, splitProps } from 'solid-js'
|
|
6
6
|
|
|
7
7
|
import { cn } from '../lib/cn'
|
|
8
8
|
|
|
@@ -23,6 +23,12 @@ const BADGE_BASE =
|
|
|
23
23
|
interface BadgeProps extends ParentProps {
|
|
24
24
|
/** Visual/semantic tone. Defaults to `'neutral'`. */
|
|
25
25
|
tone?: BadgeTone
|
|
26
|
+
/**
|
|
27
|
+
* Show a pinging dot before the label for "live"/"recording"/"online"
|
|
28
|
+
* states. Tinted with the tone's color (`currentColor`); pure CSS
|
|
29
|
+
* (`animate-ping`), reduced-motion aware.
|
|
30
|
+
*/
|
|
31
|
+
pulse?: boolean
|
|
26
32
|
class?: string
|
|
27
33
|
}
|
|
28
34
|
|
|
@@ -37,9 +43,15 @@ interface BadgeProps extends ParentProps {
|
|
|
37
43
|
* ```
|
|
38
44
|
*/
|
|
39
45
|
export function Badge(props: BadgeProps): JSX.Element {
|
|
40
|
-
const [local, rest] = splitProps(props, ['tone', 'class', 'children'])
|
|
46
|
+
const [local, rest] = splitProps(props, ['tone', 'class', 'children', 'pulse'])
|
|
41
47
|
return (
|
|
42
48
|
<span class={cn(BADGE_BASE, TONE_CLASSES[local.tone ?? 'neutral'], local.class)} {...rest}>
|
|
49
|
+
<Show when={local.pulse}>
|
|
50
|
+
<span class="relative flex h-1.5 w-1.5" aria-hidden="true">
|
|
51
|
+
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-75" />
|
|
52
|
+
<span class="relative inline-flex h-1.5 w-1.5 rounded-full bg-current" />
|
|
53
|
+
</span>
|
|
54
|
+
</Show>
|
|
43
55
|
{local.children}
|
|
44
56
|
</span>
|
|
45
57
|
)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// BeforeAfter — an image comparison slider: two images stacked in the same
|
|
2
|
+
// box, the "after" image clipped to a draggable split so dragging the handle
|
|
3
|
+
// reveals more of one and hides the other. Pure signal + CSS `clip-path`
|
|
4
|
+
// (engine-free — no `motion` dependency), same spirit as TiltCard/Spotlight.
|
|
5
|
+
// Dragging is instant (no transition to fight the pointer); clicking the
|
|
6
|
+
// track to jump the split animates smoothly unless reduced motion is on.
|
|
7
|
+
import { createSignal, Show, type JSX } from 'solid-js'
|
|
8
|
+
import { ChevronsLeftRight } from 'lucide-solid'
|
|
9
|
+
|
|
10
|
+
import { cn } from '../lib/cn'
|
|
11
|
+
import { motionReduced } from '../lib/motion'
|
|
12
|
+
|
|
13
|
+
export interface BeforeAfterProps {
|
|
14
|
+
/** Image URL shown fully, underneath. */
|
|
15
|
+
before: string
|
|
16
|
+
/** Image URL revealed by the handle, on top, clipped to the split. */
|
|
17
|
+
after: string
|
|
18
|
+
/** Accessible description of the comparison, used as the slider's label. */
|
|
19
|
+
alt: string
|
|
20
|
+
/** Corner labels as `[beforeLabel, afterLabel]`, e.g. `['Antes', 'Después']`. */
|
|
21
|
+
labels?: [string, string]
|
|
22
|
+
/** Initial split position, 0..100 (percent of width revealing `after`). @default 50 */
|
|
23
|
+
start?: number
|
|
24
|
+
class?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const clamp = (value: number): number => Math.min(100, Math.max(0, value))
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A before/after image comparison slider: drag the handle (pointer or
|
|
31
|
+
* Left/Right arrow keys once focused) to reveal more of `after` versus
|
|
32
|
+
* `before`. The `after` image is clipped with `clip-path: inset(...)` driven
|
|
33
|
+
* by a `0..100` split signal — no canvas, no animation engine. Clicking
|
|
34
|
+
* anywhere on the track jumps the split there with a short transition
|
|
35
|
+
* (skipped under `prefers-reduced-motion`); dragging itself is always instant.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```tsx
|
|
39
|
+
* <BeforeAfter
|
|
40
|
+
* before="/room-before.jpg"
|
|
41
|
+
* after="/room-after.jpg"
|
|
42
|
+
* alt="Living room before and after renovation"
|
|
43
|
+
* labels={['Antes', 'Después']}
|
|
44
|
+
* class="aspect-video rounded-2xl border border-border"
|
|
45
|
+
* />
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export function BeforeAfter(props: BeforeAfterProps): JSX.Element {
|
|
49
|
+
const [x, setX] = createSignal(clamp(props.start ?? 50))
|
|
50
|
+
const [dragging, setDragging] = createSignal(false)
|
|
51
|
+
|
|
52
|
+
let container: HTMLDivElement | undefined
|
|
53
|
+
|
|
54
|
+
const updateFromClientX = (clientX: number): void => {
|
|
55
|
+
if (!container) return
|
|
56
|
+
const rect = container.getBoundingClientRect()
|
|
57
|
+
setX(clamp(((clientX - rect.left) / rect.width) * 100))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const onHandlePointerDown = (event: PointerEvent): void => {
|
|
61
|
+
const handle = event.currentTarget as HTMLElement
|
|
62
|
+
handle.setPointerCapture(event.pointerId)
|
|
63
|
+
handle.focus()
|
|
64
|
+
setDragging(true)
|
|
65
|
+
updateFromClientX(event.clientX)
|
|
66
|
+
|
|
67
|
+
const move = (moveEvent: PointerEvent): void => updateFromClientX(moveEvent.clientX)
|
|
68
|
+
const release = (): void => {
|
|
69
|
+
setDragging(false)
|
|
70
|
+
handle.removeEventListener('pointermove', move)
|
|
71
|
+
handle.removeEventListener('pointerup', release)
|
|
72
|
+
handle.removeEventListener('lostpointercapture', release)
|
|
73
|
+
}
|
|
74
|
+
handle.addEventListener('pointermove', move)
|
|
75
|
+
handle.addEventListener('pointerup', release)
|
|
76
|
+
handle.addEventListener('lostpointercapture', release)
|
|
77
|
+
event.preventDefault()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const onTrackClick = (event: MouseEvent): void => {
|
|
81
|
+
updateFromClientX(event.clientX)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const onKeyDown = (event: KeyboardEvent): void => {
|
|
85
|
+
const step = event.shiftKey ? 10 : 2
|
|
86
|
+
if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') {
|
|
87
|
+
event.preventDefault()
|
|
88
|
+
setX((value) => clamp(value - step))
|
|
89
|
+
} else if (event.key === 'ArrowRight' || event.key === 'ArrowUp') {
|
|
90
|
+
event.preventDefault()
|
|
91
|
+
setX((value) => clamp(value + step))
|
|
92
|
+
} else if (event.key === 'Home') {
|
|
93
|
+
event.preventDefault()
|
|
94
|
+
setX(0)
|
|
95
|
+
} else if (event.key === 'End') {
|
|
96
|
+
event.preventDefault()
|
|
97
|
+
setX(100)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const transitionClass = () =>
|
|
102
|
+
!dragging() && !motionReduced() ? 'transition-[clip-path] duration-200 ease-out' : ''
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div
|
|
106
|
+
ref={container}
|
|
107
|
+
class={cn('relative aspect-video w-full select-none overflow-hidden', props.class)}
|
|
108
|
+
onClick={onTrackClick}
|
|
109
|
+
>
|
|
110
|
+
<img
|
|
111
|
+
src={props.before}
|
|
112
|
+
alt=""
|
|
113
|
+
aria-hidden="true"
|
|
114
|
+
draggable={false}
|
|
115
|
+
class="absolute inset-0 h-full w-full object-cover"
|
|
116
|
+
/>
|
|
117
|
+
<img
|
|
118
|
+
src={props.after}
|
|
119
|
+
alt={props.alt}
|
|
120
|
+
draggable={false}
|
|
121
|
+
class={cn('absolute inset-0 h-full w-full object-cover', transitionClass())}
|
|
122
|
+
style={{ 'clip-path': `inset(0 ${100 - x()}% 0 0)` }}
|
|
123
|
+
/>
|
|
124
|
+
|
|
125
|
+
<Show when={props.labels}>
|
|
126
|
+
{(labels) => (
|
|
127
|
+
<>
|
|
128
|
+
<span class="pointer-events-none absolute bottom-3 left-3 rounded-md border border-border bg-background/70 px-2 py-1 text-xs font-medium text-foreground backdrop-blur-sm">
|
|
129
|
+
{labels()[0]}
|
|
130
|
+
</span>
|
|
131
|
+
<span class="pointer-events-none absolute bottom-3 right-3 rounded-md border border-border bg-background/70 px-2 py-1 text-xs font-medium text-foreground backdrop-blur-sm">
|
|
132
|
+
{labels()[1]}
|
|
133
|
+
</span>
|
|
134
|
+
</>
|
|
135
|
+
)}
|
|
136
|
+
</Show>
|
|
137
|
+
|
|
138
|
+
<div
|
|
139
|
+
role="slider"
|
|
140
|
+
tabindex={0}
|
|
141
|
+
aria-label={props.alt}
|
|
142
|
+
aria-orientation="horizontal"
|
|
143
|
+
aria-valuemin={0}
|
|
144
|
+
aria-valuemax={100}
|
|
145
|
+
aria-valuenow={Math.round(x())}
|
|
146
|
+
onPointerDown={onHandlePointerDown}
|
|
147
|
+
onKeyDown={onKeyDown}
|
|
148
|
+
onClick={(event) => event.stopPropagation()}
|
|
149
|
+
class={cn(
|
|
150
|
+
'group absolute inset-y-0 flex w-8 -translate-x-1/2 cursor-ew-resize touch-none items-center justify-center',
|
|
151
|
+
"before:pointer-events-none before:absolute before:inset-y-0 before:left-1/2 before:w-px before:-translate-x-1/2 before:bg-background before:content-['']",
|
|
152
|
+
'focus-visible:outline-none',
|
|
153
|
+
)}
|
|
154
|
+
style={{ left: `${x()}%` }}
|
|
155
|
+
>
|
|
156
|
+
<span class="grid h-8 w-8 place-items-center rounded-full border border-border bg-background text-foreground shadow-md group-focus-visible:ring-2 group-focus-visible:ring-primary group-focus-visible:ring-offset-2 group-focus-visible:ring-offset-background">
|
|
157
|
+
<ChevronsLeftRight class="h-4 w-4" />
|
|
158
|
+
</span>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
)
|
|
162
|
+
}
|
package/src/ui/Carousel.tsx
CHANGED
|
@@ -13,6 +13,12 @@ export interface CarouselProps {
|
|
|
13
13
|
slides: JSX.Element[]
|
|
14
14
|
/** Auto-advance interval in ms. Omit to disable autoplay (also skipped under reduced motion / hover). */
|
|
15
15
|
autoplayMs?: number
|
|
16
|
+
/**
|
|
17
|
+
* Drag/touch to swipe between slides (in addition to arrows, dots, and arrow
|
|
18
|
+
* keys). The track follows the pointer and snaps on release — pure CSS, no
|
|
19
|
+
* engine. @default true
|
|
20
|
+
*/
|
|
21
|
+
swipe?: boolean
|
|
16
22
|
class?: string
|
|
17
23
|
}
|
|
18
24
|
|
|
@@ -52,6 +58,34 @@ export function Carousel(props: CarouselProps): JSX.Element {
|
|
|
52
58
|
}
|
|
53
59
|
}
|
|
54
60
|
|
|
61
|
+
// --- drag/touch swipe (engine-free; the track follows the pointer, then the
|
|
62
|
+
// CSS transition snaps to the resolved slide on release) -------------------
|
|
63
|
+
const swipeOn = (): boolean => props.swipe !== false
|
|
64
|
+
let viewport: HTMLDivElement | undefined
|
|
65
|
+
const [dragging, setDragging] = createSignal(false)
|
|
66
|
+
const [dragPx, setDragPx] = createSignal(0)
|
|
67
|
+
let startX = 0
|
|
68
|
+
|
|
69
|
+
const onPointerDown = (e: PointerEvent): void => {
|
|
70
|
+
if (!swipeOn() || count() < 2) return
|
|
71
|
+
startX = e.clientX
|
|
72
|
+
setDragging(true)
|
|
73
|
+
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
|
74
|
+
}
|
|
75
|
+
const onPointerMove = (e: PointerEvent): void => {
|
|
76
|
+
if (dragging()) setDragPx(e.clientX - startX)
|
|
77
|
+
}
|
|
78
|
+
const endDrag = (): void => {
|
|
79
|
+
if (!dragging()) return
|
|
80
|
+
const width = viewport?.clientWidth ?? 1
|
|
81
|
+
const dx = dragPx()
|
|
82
|
+
const threshold = Math.max(48, width * 0.2)
|
|
83
|
+
if (dx <= -threshold) next()
|
|
84
|
+
else if (dx >= threshold) prev()
|
|
85
|
+
setDragPx(0)
|
|
86
|
+
setDragging(false)
|
|
87
|
+
}
|
|
88
|
+
|
|
55
89
|
// --- autoplay (paused on hover, skipped under reduced motion) --------------
|
|
56
90
|
const [paused, setPaused] = createSignal(false)
|
|
57
91
|
onMount(() => {
|
|
@@ -75,10 +109,21 @@ export function Carousel(props: CarouselProps): JSX.Element {
|
|
|
75
109
|
onPointerEnter={() => setPaused(true)}
|
|
76
110
|
onPointerLeave={() => setPaused(false)}
|
|
77
111
|
>
|
|
78
|
-
<div
|
|
112
|
+
<div
|
|
113
|
+
ref={viewport}
|
|
114
|
+
class="relative overflow-hidden rounded-xl border border-border bg-card text-foreground"
|
|
115
|
+
>
|
|
79
116
|
<div
|
|
80
|
-
class=
|
|
81
|
-
|
|
117
|
+
class={cn(
|
|
118
|
+
'flex',
|
|
119
|
+
swipeOn() && count() > 1 && 'touch-pan-y cursor-grab active:cursor-grabbing',
|
|
120
|
+
!dragging() && 'transition-transform duration-500 ease-out',
|
|
121
|
+
)}
|
|
122
|
+
style={{ transform: `translateX(calc(-${index() * 100}% + ${dragPx()}px))` }}
|
|
123
|
+
onPointerDown={onPointerDown}
|
|
124
|
+
onPointerMove={onPointerMove}
|
|
125
|
+
onPointerUp={endDrag}
|
|
126
|
+
onPointerCancel={endDrag}
|
|
82
127
|
>
|
|
83
128
|
<For each={props.slides}>{(slide) => <div class="w-full shrink-0">{slide}</div>}</For>
|
|
84
129
|
</div>
|
package/src/ui/Collapse.tsx
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// Single controlled collapsible region with a toggle header.
|
|
2
2
|
import { ChevronDown } from 'lucide-solid'
|
|
3
3
|
import type { JSX, ParentProps } from 'solid-js'
|
|
4
|
-
import { Show } from 'solid-js'
|
|
5
4
|
import { cn } from '../lib/cn'
|
|
6
5
|
|
|
7
6
|
interface CollapseProps extends ParentProps {
|
|
@@ -40,9 +39,19 @@ export function Collapse(props: CollapseProps): JSX.Element {
|
|
|
40
39
|
class={cn('h-4 w-4 shrink-0 transition-transform duration-200', props.open && 'rotate-180')}
|
|
41
40
|
/>
|
|
42
41
|
</button>
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
{/* grid-rows 0fr->1fr animates the panel height with pure CSS (no measuring,
|
|
43
|
+
no engine); the inner div is `overflow-hidden` so the collapsed content
|
|
44
|
+
is clipped, not just transparent. */}
|
|
45
|
+
<div
|
|
46
|
+
class={cn(
|
|
47
|
+
'grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none',
|
|
48
|
+
props.open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
|
|
49
|
+
)}
|
|
50
|
+
>
|
|
51
|
+
<div class="overflow-hidden">
|
|
52
|
+
<div class="px-3 py-2 text-sm text-muted-foreground">{props.children}</div>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
46
55
|
</div>
|
|
47
56
|
)
|
|
48
57
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { JSX } from 'solid-js'
|
|
2
2
|
|
|
3
3
|
import { cn } from '../lib/cn'
|
|
4
|
+
import { spawnRipple } from './Ripple'
|
|
4
5
|
|
|
5
6
|
/** Screen corner a {@link FloatingActionButton} anchors to. Defaults to `'bottom-right'`. */
|
|
6
7
|
export type FloatingActionButtonPosition = 'bottom-right' | 'bottom-left'
|
|
@@ -23,6 +24,12 @@ export interface FloatingActionButtonProps {
|
|
|
23
24
|
onClick?: () => void
|
|
24
25
|
/** Screen corner to anchor to. Defaults to `'bottom-right'`. */
|
|
25
26
|
position?: FloatingActionButtonPosition
|
|
27
|
+
/**
|
|
28
|
+
* Material-style click ripple from the press position. Engine-free (Web
|
|
29
|
+
* Animations API, shared with {@link spawnRipple}) and reduced-motion aware —
|
|
30
|
+
* costs nothing when off.
|
|
31
|
+
*/
|
|
32
|
+
ripple?: boolean
|
|
26
33
|
class?: string
|
|
27
34
|
}
|
|
28
35
|
|
|
@@ -40,7 +47,15 @@ export function FloatingActionButton(props: FloatingActionButtonProps): JSX.Elem
|
|
|
40
47
|
type="button"
|
|
41
48
|
aria-label={props.label}
|
|
42
49
|
onClick={() => props.onClick?.()}
|
|
43
|
-
|
|
50
|
+
onPointerDown={(event) => {
|
|
51
|
+
if (props.ripple) spawnRipple(event.currentTarget, event, { opacity: 0.35 })
|
|
52
|
+
}}
|
|
53
|
+
class={cn(
|
|
54
|
+
FAB_BASE,
|
|
55
|
+
POSITION_CLASSES[props.position ?? 'bottom-right'],
|
|
56
|
+
props.ripple && 'relative overflow-hidden',
|
|
57
|
+
props.class,
|
|
58
|
+
)}
|
|
44
59
|
>
|
|
45
60
|
{props.icon}
|
|
46
61
|
</button>
|
package/src/ui/Image.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import type { JSX } from 'solid-js'
|
|
|
8
8
|
import { createSignal, Show } from 'solid-js'
|
|
9
9
|
|
|
10
10
|
import { cn } from '../lib/cn'
|
|
11
|
+
import { motionReduced } from '../lib/motion'
|
|
11
12
|
|
|
12
13
|
export interface ImageProps {
|
|
13
14
|
src: string
|
|
@@ -15,6 +16,12 @@ export interface ImageProps {
|
|
|
15
16
|
class?: string
|
|
16
17
|
/** When true, clicking the image opens a zoomable lightbox. @default true */
|
|
17
18
|
preview?: boolean
|
|
19
|
+
/**
|
|
20
|
+
* Reveal the thumbnail with a blur-up: it starts blurred and slightly scaled,
|
|
21
|
+
* then eases to sharp once the image finishes loading. Pure CSS transition,
|
|
22
|
+
* no-op under reduced motion. @default false
|
|
23
|
+
*/
|
|
24
|
+
blurUp?: boolean
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
/**
|
|
@@ -32,8 +39,29 @@ export function Image(props: ImageProps): JSX.Element {
|
|
|
32
39
|
const [open, setOpen] = createSignal(false)
|
|
33
40
|
const preview = () => props.preview !== false
|
|
34
41
|
|
|
42
|
+
// blur-up: hidden behind the blur until `load` fires (or immediately, if the
|
|
43
|
+
// image is already cached or reduced motion is on).
|
|
44
|
+
const [loaded, setLoaded] = createSignal(false)
|
|
45
|
+
const blurUp = () => props.blurUp === true && !motionReduced()
|
|
46
|
+
const revealed = () => !blurUp() || loaded()
|
|
47
|
+
|
|
35
48
|
const img = (
|
|
36
|
-
<img
|
|
49
|
+
<img
|
|
50
|
+
src={props.src}
|
|
51
|
+
alt={props.alt}
|
|
52
|
+
loading="lazy"
|
|
53
|
+
onLoad={() => setLoaded(true)}
|
|
54
|
+
ref={(el) => {
|
|
55
|
+
// A cached image can be complete before `onLoad` binds — reveal it now.
|
|
56
|
+
if (el.complete) setLoaded(true)
|
|
57
|
+
}}
|
|
58
|
+
class={cn(
|
|
59
|
+
'rounded-lg object-cover',
|
|
60
|
+
blurUp() && 'transition-[filter,transform] duration-500 ease-out',
|
|
61
|
+
blurUp() && !revealed() && 'scale-[1.03] blur-lg',
|
|
62
|
+
props.class,
|
|
63
|
+
)}
|
|
64
|
+
/>
|
|
37
65
|
)
|
|
38
66
|
|
|
39
67
|
return (
|
package/src/ui/List.tsx
CHANGED
|
@@ -16,6 +16,11 @@ export interface ListItem {
|
|
|
16
16
|
|
|
17
17
|
interface ListProps {
|
|
18
18
|
items: ListItem[]
|
|
19
|
+
/**
|
|
20
|
+
* Cascade the rows in on mount — each row fades/slides up staggered by its
|
|
21
|
+
* index. Pure CSS (`list-row-stagger`), reduced-motion aware. @default false
|
|
22
|
+
*/
|
|
23
|
+
stagger?: boolean
|
|
19
24
|
class?: string
|
|
20
25
|
}
|
|
21
26
|
|
|
@@ -46,8 +51,11 @@ export function List(props: ListProps): JSX.Element {
|
|
|
46
51
|
return (
|
|
47
52
|
<ul class={cn('divide-y divide-border rounded-lg border border-border', props.class)}>
|
|
48
53
|
<For each={props.items}>
|
|
49
|
-
{(item) => (
|
|
50
|
-
<li
|
|
54
|
+
{(item, i) => (
|
|
55
|
+
<li
|
|
56
|
+
class={cn('flex items-center gap-3 px-4 py-3', props.stagger && 'list-row-stagger')}
|
|
57
|
+
style={props.stagger ? { 'animation-delay': `${i() * 60}ms` } : undefined}
|
|
58
|
+
>
|
|
51
59
|
<Show when={item.avatar}>
|
|
52
60
|
<div class="shrink-0">{item.avatar}</div>
|
|
53
61
|
</Show>
|