@a4ui/core 0.16.0 → 0.17.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-DMx0y81E.js} +152 -145
- package/dist/commerce/ProductCard.d.ts +10 -0
- package/dist/commerce.js +54 -48
- package/dist/elements.css +111 -0
- package/dist/elements.iife.js +3 -3
- package/dist/elements.js +590 -583
- package/dist/full.css +111 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2660 -2635
- package/dist/styles.css +66 -0
- package/dist/ui/Badge.d.ts +6 -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/Skeleton.d.ts +10 -3
- package/package.json +1 -1
- package/src/commerce/ProductCard.tsx +17 -1
- package/src/index.ts +1 -1
- package/src/ui/Accordion.tsx +3 -1
- package/src/ui/Badge.tsx +14 -2
- 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/Skeleton.tsx +17 -5
- package/src/ui/SpeedDial.tsx +3 -1
package/dist/styles.css
CHANGED
|
@@ -162,6 +162,72 @@ body {
|
|
|
162
162
|
animation: toast-out 0.18s ease-in;
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
/* Skeleton shimmer — a light band sweeping across the placeholder (opt-in
|
|
166
|
+
alternative to the default pulse). The band color is token-driven so it reads
|
|
167
|
+
on every theme; the sweep is a pure CSS animation (no engine). */
|
|
168
|
+
@keyframes skeleton-shimmer {
|
|
169
|
+
100% {
|
|
170
|
+
transform: translateX(100%);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
.skeleton-shimmer {
|
|
174
|
+
position: relative;
|
|
175
|
+
overflow: hidden;
|
|
176
|
+
}
|
|
177
|
+
.skeleton-shimmer::after {
|
|
178
|
+
content: '';
|
|
179
|
+
position: absolute;
|
|
180
|
+
inset: 0;
|
|
181
|
+
transform: translateX(-100%);
|
|
182
|
+
background: linear-gradient(90deg, transparent, hsl(var(--foreground) / 0.08), transparent);
|
|
183
|
+
animation: skeleton-shimmer 1.5s ease-in-out infinite;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/* Accordion open/close — animate the panel height using Kobalte's measured
|
|
187
|
+
`--kb-accordion-content-height` var (presence keyed off data-expanded/closed,
|
|
188
|
+
animationend-driven like Modal). Engine-free. */
|
|
189
|
+
@keyframes accordion-down {
|
|
190
|
+
from {
|
|
191
|
+
height: 0;
|
|
192
|
+
}
|
|
193
|
+
to {
|
|
194
|
+
height: var(--kb-accordion-content-height);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
@keyframes accordion-up {
|
|
198
|
+
from {
|
|
199
|
+
height: var(--kb-accordion-content-height);
|
|
200
|
+
}
|
|
201
|
+
to {
|
|
202
|
+
height: 0;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
.accordion-content {
|
|
206
|
+
overflow: hidden;
|
|
207
|
+
}
|
|
208
|
+
.accordion-content[data-expanded] {
|
|
209
|
+
animation: accordion-down 0.2s ease-out;
|
|
210
|
+
}
|
|
211
|
+
.accordion-content[data-closed] {
|
|
212
|
+
animation: accordion-up 0.2s ease-out;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/* List stagger — rows fade/slide in on mount, delayed per index (set inline as
|
|
216
|
+
`animation-delay`). Opt-in via <List stagger>. */
|
|
217
|
+
@keyframes list-row-in {
|
|
218
|
+
from {
|
|
219
|
+
opacity: 0;
|
|
220
|
+
transform: translateY(6px);
|
|
221
|
+
}
|
|
222
|
+
to {
|
|
223
|
+
opacity: 1;
|
|
224
|
+
transform: none;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
.list-row-stagger {
|
|
228
|
+
animation: list-row-in 0.35s both ease-out;
|
|
229
|
+
}
|
|
230
|
+
|
|
165
231
|
/* Table row enter/exit fade. */
|
|
166
232
|
.row-enter-active,
|
|
167
233
|
.row-exit-active {
|
package/dist/ui/Badge.d.ts
CHANGED
|
@@ -4,6 +4,12 @@ export type BadgeTone = 'neutral' | 'success' | 'warning' | 'danger' | 'info';
|
|
|
4
4
|
interface BadgeProps extends ParentProps {
|
|
5
5
|
/** Visual/semantic tone. Defaults to `'neutral'`. */
|
|
6
6
|
tone?: BadgeTone;
|
|
7
|
+
/**
|
|
8
|
+
* Show a pinging dot before the label for "live"/"recording"/"online"
|
|
9
|
+
* states. Tinted with the tone's color (`currentColor`); pure CSS
|
|
10
|
+
* (`animate-ping`), reduced-motion aware.
|
|
11
|
+
*/
|
|
12
|
+
pulse?: boolean;
|
|
7
13
|
class?: string;
|
|
8
14
|
}
|
|
9
15
|
/**
|
package/dist/ui/Carousel.d.ts
CHANGED
|
@@ -4,6 +4,12 @@ export interface CarouselProps {
|
|
|
4
4
|
slides: JSX.Element[];
|
|
5
5
|
/** Auto-advance interval in ms. Omit to disable autoplay (also skipped under reduced motion / hover). */
|
|
6
6
|
autoplayMs?: number;
|
|
7
|
+
/**
|
|
8
|
+
* Drag/touch to swipe between slides (in addition to arrows, dots, and arrow
|
|
9
|
+
* keys). The track follows the pointer and snaps on release — pure CSS, no
|
|
10
|
+
* engine. @default true
|
|
11
|
+
*/
|
|
12
|
+
swipe?: boolean;
|
|
7
13
|
class?: string;
|
|
8
14
|
}
|
|
9
15
|
/**
|
|
@@ -9,6 +9,12 @@ export interface FloatingActionButtonProps {
|
|
|
9
9
|
onClick?: () => void;
|
|
10
10
|
/** Screen corner to anchor to. Defaults to `'bottom-right'`. */
|
|
11
11
|
position?: FloatingActionButtonPosition;
|
|
12
|
+
/**
|
|
13
|
+
* Material-style click ripple from the press position. Engine-free (Web
|
|
14
|
+
* Animations API, shared with {@link spawnRipple}) and reduced-motion aware —
|
|
15
|
+
* costs nothing when off.
|
|
16
|
+
*/
|
|
17
|
+
ripple?: boolean;
|
|
12
18
|
class?: string;
|
|
13
19
|
}
|
|
14
20
|
/**
|
package/dist/ui/Image.d.ts
CHANGED
|
@@ -5,6 +5,12 @@ export interface ImageProps {
|
|
|
5
5
|
class?: string;
|
|
6
6
|
/** When true, clicking the image opens a zoomable lightbox. @default true */
|
|
7
7
|
preview?: boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Reveal the thumbnail with a blur-up: it starts blurred and slightly scaled,
|
|
10
|
+
* then eases to sharp once the image finishes loading. Pure CSS transition,
|
|
11
|
+
* no-op under reduced motion. @default false
|
|
12
|
+
*/
|
|
13
|
+
blurUp?: boolean;
|
|
8
14
|
}
|
|
9
15
|
/**
|
|
10
16
|
* Lazy-loaded content image. Unless `preview` is disabled, the thumbnail is a
|
package/dist/ui/List.d.ts
CHANGED
|
@@ -9,6 +9,11 @@ export interface ListItem {
|
|
|
9
9
|
}
|
|
10
10
|
interface ListProps {
|
|
11
11
|
items: ListItem[];
|
|
12
|
+
/**
|
|
13
|
+
* Cascade the rows in on mount — each row fades/slides up staggered by its
|
|
14
|
+
* index. Pure CSS (`list-row-stagger`), reduced-motion aware. @default false
|
|
15
|
+
*/
|
|
16
|
+
stagger?: boolean;
|
|
12
17
|
class?: string;
|
|
13
18
|
}
|
|
14
19
|
export type { ListProps };
|
package/dist/ui/Skeleton.d.ts
CHANGED
|
@@ -2,15 +2,22 @@ import { JSX } from 'solid-js';
|
|
|
2
2
|
interface SkeletonProps {
|
|
3
3
|
/** Size/shape the placeholder to match the content it stands in for, e.g. `"h-4 w-32 rounded-full"`. */
|
|
4
4
|
class?: string;
|
|
5
|
+
/**
|
|
6
|
+
* Use a light band sweeping across the placeholder instead of the default
|
|
7
|
+
* pulse. Pure CSS (`.skeleton-shimmer`), token-tinted, reduced-motion aware.
|
|
8
|
+
*/
|
|
9
|
+
shimmer?: boolean;
|
|
5
10
|
}
|
|
6
11
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
12
|
+
* Placeholder block for content that is still loading. Plain `div`, no Kobalte
|
|
13
|
+
* primitive — size it via `class` to match the shape of the real content (text
|
|
14
|
+
* line, avatar, card, etc.). Pulses by default; pass `shimmer` for a sweeping
|
|
15
|
+
* light band instead.
|
|
10
16
|
*
|
|
11
17
|
* @example
|
|
12
18
|
* ```tsx
|
|
13
19
|
* <Skeleton class="h-4 w-48" />
|
|
20
|
+
* <Skeleton shimmer class="h-4 w-48" /> // sweeping band instead of pulse
|
|
14
21
|
* ```
|
|
15
22
|
*/
|
|
16
23
|
export declare function Skeleton(props: SkeletonProps): JSX.Element;
|
package/package.json
CHANGED
|
@@ -37,6 +37,16 @@ export interface ProductCardProps {
|
|
|
37
37
|
currency?: string
|
|
38
38
|
locale?: string
|
|
39
39
|
onAddToCart?: () => void
|
|
40
|
+
/**
|
|
41
|
+
* 3D hover tilt toward the cursor — forwarded to the underlying {@link Card}
|
|
42
|
+
* ({@link attachTilt}). Engine-free and reduced-motion aware.
|
|
43
|
+
*/
|
|
44
|
+
tilt?: boolean
|
|
45
|
+
/**
|
|
46
|
+
* Cursor-following radial glow inside the card — forwarded to the underlying
|
|
47
|
+
* {@link Card} ({@link attachSpotlight}). Engine-free and reduced-motion aware.
|
|
48
|
+
*/
|
|
49
|
+
spotlight?: boolean
|
|
40
50
|
class?: string
|
|
41
51
|
}
|
|
42
52
|
|
|
@@ -72,7 +82,13 @@ function inferTone(badge: string | undefined, tone: ProductBadgeTone | undefined
|
|
|
72
82
|
*/
|
|
73
83
|
export function ProductCard(props: ProductCardProps): JSX.Element {
|
|
74
84
|
return (
|
|
75
|
-
<Card
|
|
85
|
+
<Card
|
|
86
|
+
glass
|
|
87
|
+
glow
|
|
88
|
+
tilt={props.tilt}
|
|
89
|
+
spotlight={props.spotlight}
|
|
90
|
+
class={cn('flex h-full flex-col overflow-hidden p-4', props.class)}
|
|
91
|
+
>
|
|
76
92
|
<div class="relative aspect-square overflow-hidden rounded-lg bg-muted">
|
|
77
93
|
<img src={props.image} alt={props.title} loading="lazy" class="h-full w-full object-cover" />
|
|
78
94
|
<Show when={props.badge}>
|
package/src/index.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// import '@a4ui/core/styles.css'
|
|
9
9
|
// import { Button, Card, Modal } from '@a4ui/core'
|
|
10
10
|
|
|
11
|
-
export const A4UI_VERSION = '0.
|
|
11
|
+
export const A4UI_VERSION = '0.17.0'
|
|
12
12
|
|
|
13
13
|
// Helpers (src/lib) — generic, framework-level utilities.
|
|
14
14
|
export { cn } from './lib/cn'
|
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
|
)
|
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>
|
package/src/ui/Skeleton.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Placeholder for loading content — plain div, no primitive. Pulses by default;
|
|
2
|
+
// opt into a sweeping shimmer band with `shimmer`.
|
|
2
3
|
import type { JSX } from 'solid-js'
|
|
3
4
|
|
|
4
5
|
import { cn } from '../lib/cn'
|
|
@@ -6,18 +7,29 @@ import { cn } from '../lib/cn'
|
|
|
6
7
|
interface SkeletonProps {
|
|
7
8
|
/** Size/shape the placeholder to match the content it stands in for, e.g. `"h-4 w-32 rounded-full"`. */
|
|
8
9
|
class?: string
|
|
10
|
+
/**
|
|
11
|
+
* Use a light band sweeping across the placeholder instead of the default
|
|
12
|
+
* pulse. Pure CSS (`.skeleton-shimmer`), token-tinted, reduced-motion aware.
|
|
13
|
+
*/
|
|
14
|
+
shimmer?: boolean
|
|
9
15
|
}
|
|
10
16
|
|
|
11
17
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
18
|
+
* Placeholder block for content that is still loading. Plain `div`, no Kobalte
|
|
19
|
+
* primitive — size it via `class` to match the shape of the real content (text
|
|
20
|
+
* line, avatar, card, etc.). Pulses by default; pass `shimmer` for a sweeping
|
|
21
|
+
* light band instead.
|
|
15
22
|
*
|
|
16
23
|
* @example
|
|
17
24
|
* ```tsx
|
|
18
25
|
* <Skeleton class="h-4 w-48" />
|
|
26
|
+
* <Skeleton shimmer class="h-4 w-48" /> // sweeping band instead of pulse
|
|
19
27
|
* ```
|
|
20
28
|
*/
|
|
21
29
|
export function Skeleton(props: SkeletonProps): JSX.Element {
|
|
22
|
-
return
|
|
30
|
+
return (
|
|
31
|
+
<div
|
|
32
|
+
class={cn('rounded-md bg-muted', props.shimmer ? 'skeleton-shimmer' : 'animate-pulse', props.class)}
|
|
33
|
+
/>
|
|
34
|
+
)
|
|
23
35
|
}
|
package/src/ui/SpeedDial.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { createSignal, For, Show } from 'solid-js'
|
|
|
5
5
|
|
|
6
6
|
import { cn } from '../lib/cn'
|
|
7
7
|
import { animate, motionReduced } from '../lib/motion'
|
|
8
|
+
import { spawnRipple } from './Ripple'
|
|
8
9
|
|
|
9
10
|
/** A single action in a {@link SpeedDial} — an icon button with an accessible label. */
|
|
10
11
|
export interface SpeedDialAction {
|
|
@@ -74,7 +75,8 @@ export function SpeedDial(props: SpeedDialProps): JSX.Element {
|
|
|
74
75
|
aria-label={open() ? 'Close actions' : 'Open actions'}
|
|
75
76
|
aria-expanded={open()}
|
|
76
77
|
onClick={() => setOpen((v) => !v)}
|
|
77
|
-
|
|
78
|
+
onPointerDown={(event) => spawnRipple(event.currentTarget, event, { opacity: 0.35 })}
|
|
79
|
+
class="relative flex h-14 w-14 items-center justify-center overflow-hidden rounded-full bg-primary text-primary-foreground shadow-lg transition-transform duration-150 active:scale-[0.97] focus:outline-none focus:ring-2 focus:ring-ring"
|
|
78
80
|
>
|
|
79
81
|
<span class={cn('inline-flex transition-transform duration-150', open() && 'rotate-45')}>
|
|
80
82
|
{props.icon ?? <Plus class="h-6 w-6" />}
|