@a4ui/core 0.30.0 → 0.31.1

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,126 @@
1
+ import { JSX } from 'solid-js';
2
+ import { IconMorphButtonVariant } from './IconMorphButton';
3
+ export interface CopyButtonProps {
4
+ /** Text copied to the clipboard when the button is pressed. */
5
+ value: string;
6
+ /** Optional label shown next to the icon before copying. */
7
+ label?: JSX.Element;
8
+ /** Optional label shown next to the icon once copied. */
9
+ copiedLabel?: JSX.Element;
10
+ /** Visual style. Defaults to `'ghost'`. */
11
+ variant?: IconMorphButtonVariant;
12
+ class?: string;
13
+ 'aria-label'?: string;
14
+ }
15
+ /**
16
+ * Copy-to-clipboard button that morphs Copy → Check and auto-reverts after
17
+ * 1.5s.
18
+ *
19
+ * @example
20
+ * ```tsx
21
+ * <CopyButton value="npm install @a4ui/core" aria-label="Copy install command" />
22
+ * ```
23
+ */
24
+ export declare function CopyButton(props: CopyButtonProps): JSX.Element;
25
+ export interface PlayPauseButtonProps {
26
+ /** Controlled playing state. Omit to let the button manage its own state. */
27
+ pressed?: boolean;
28
+ /** Initial playing state when uncontrolled. Defaults to `false`. */
29
+ defaultPressed?: boolean;
30
+ /** Fired with the next playing state, controlled or uncontrolled. */
31
+ onChange?: (pressed: boolean) => void;
32
+ /** Optional label shown next to the icon while paused. */
33
+ label?: JSX.Element;
34
+ /** Optional label shown next to the icon while playing. */
35
+ activeLabel?: JSX.Element;
36
+ /** Visual style. Defaults to `'ghost'`. */
37
+ variant?: IconMorphButtonVariant;
38
+ class?: string;
39
+ 'aria-label'?: string;
40
+ }
41
+ /**
42
+ * Play/pause toggle button whose icon morphs Play → Pause.
43
+ *
44
+ * @example
45
+ * ```tsx
46
+ * <PlayPauseButton defaultPressed={false} aria-label="Play" />
47
+ * ```
48
+ */
49
+ export declare function PlayPauseButton(props: PlayPauseButtonProps): JSX.Element;
50
+ export interface MuteButtonProps {
51
+ /** Controlled muted state. Omit to let the button manage its own state. */
52
+ pressed?: boolean;
53
+ /** Initial muted state when uncontrolled. Defaults to `false`. */
54
+ defaultPressed?: boolean;
55
+ /** Fired with the next muted state, controlled or uncontrolled. */
56
+ onChange?: (pressed: boolean) => void;
57
+ /** Optional label shown next to the icon while unmuted. */
58
+ label?: JSX.Element;
59
+ /** Optional label shown next to the icon while muted. */
60
+ activeLabel?: JSX.Element;
61
+ /** Visual style. Defaults to `'ghost'`. */
62
+ variant?: IconMorphButtonVariant;
63
+ class?: string;
64
+ 'aria-label'?: string;
65
+ }
66
+ /**
67
+ * Mute toggle button whose icon morphs Volume2 → VolumeX.
68
+ *
69
+ * @example
70
+ * ```tsx
71
+ * <MuteButton defaultPressed={false} aria-label="Mute" />
72
+ * ```
73
+ */
74
+ export declare function MuteButton(props: MuteButtonProps): JSX.Element;
75
+ export interface LockButtonProps {
76
+ /** Controlled locked state. Omit to let the button manage its own state. */
77
+ pressed?: boolean;
78
+ /** Initial locked state when uncontrolled. Defaults to `false`. */
79
+ defaultPressed?: boolean;
80
+ /** Fired with the next locked state, controlled or uncontrolled. */
81
+ onChange?: (pressed: boolean) => void;
82
+ /** Optional label shown next to the icon while unlocked. */
83
+ label?: JSX.Element;
84
+ /** Optional label shown next to the icon while locked. */
85
+ activeLabel?: JSX.Element;
86
+ /** Visual style. Defaults to `'ghost'`. */
87
+ variant?: IconMorphButtonVariant;
88
+ class?: string;
89
+ 'aria-label'?: string;
90
+ }
91
+ /**
92
+ * Lock toggle button whose icon morphs Unlock → Lock.
93
+ *
94
+ * @example
95
+ * ```tsx
96
+ * <LockButton defaultPressed={false} aria-label="Lock" />
97
+ * ```
98
+ */
99
+ export declare function LockButton(props: LockButtonProps): JSX.Element;
100
+ export interface ThemeMorphButtonProps {
101
+ /** Controlled dark-mode state. Omit to let the button manage its own state. */
102
+ pressed?: boolean;
103
+ /** Initial dark-mode state when uncontrolled. Defaults to `false`. */
104
+ defaultPressed?: boolean;
105
+ /** Fired with the next dark-mode state, controlled or uncontrolled. */
106
+ onChange?: (pressed: boolean) => void;
107
+ /** Optional label shown next to the icon in light mode. */
108
+ label?: JSX.Element;
109
+ /** Optional label shown next to the icon in dark mode. */
110
+ activeLabel?: JSX.Element;
111
+ /** Visual style. Defaults to `'ghost'`. */
112
+ variant?: IconMorphButtonVariant;
113
+ class?: string;
114
+ 'aria-label'?: string;
115
+ }
116
+ /**
117
+ * Purely presentational theme toggle whose icon morphs Sun → Moon. Does not
118
+ * touch the theme system itself — wire `pressed`/`onChange` to your theme
119
+ * state.
120
+ *
121
+ * @example
122
+ * ```tsx
123
+ * <ThemeMorphButton pressed={isDark()} onChange={setIsDark} aria-label="Toggle theme" />
124
+ * ```
125
+ */
126
+ export declare function ThemeMorphButton(props: ThemeMorphButtonProps): JSX.Element;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a4ui/core",
3
- "version": "0.30.0",
3
+ "version": "0.31.1",
4
4
  "description": "A4ui — Spatial Glass design system & component library for SolidJS (Kobalte behavior + Tailwind glass tokens + motion).",
5
5
  "type": "module",
6
6
  "license": "MIT",
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.30.0'
11
+ export const A4UI_VERSION = '0.31.1'
12
12
 
13
13
  // Helpers (src/lib) — generic, framework-level utilities.
14
14
  export { cn } from './lib/cn'
@@ -179,6 +179,18 @@ export { FocusBlurGroup, type FocusBlurGroupProps } from './ui/FocusBlurGroup'
179
179
  export { CardSpread, type CardSpreadProps, type CardSpreadLayout } from './ui/CardSpread'
180
180
  export { Carousel3D, type Carousel3DProps, type Carousel3DVariant } from './ui/Carousel3D'
181
181
  export { TimeMachineStack, type TimeMachineStackProps } from './ui/TimeMachineStack'
182
+ export {
183
+ CopyButton,
184
+ type CopyButtonProps,
185
+ PlayPauseButton,
186
+ type PlayPauseButtonProps,
187
+ MuteButton,
188
+ type MuteButtonProps,
189
+ LockButton,
190
+ type LockButtonProps,
191
+ ThemeMorphButton,
192
+ type ThemeMorphButtonProps,
193
+ } from './ui/MorphPresets'
182
194
 
183
195
  // Phase 2 — code, search, navigation, master-detail.
184
196
  export { CodeTabs, type CodeTabsProps, type CodeTab } from './ui/CodeTabs'
@@ -48,10 +48,12 @@ function pseudoRandom(i: number): number {
48
48
  return (s - Math.floor(s)) * 2 - 1
49
49
  }
50
50
 
51
- function restTransform(i: number): CardTransform {
52
- // Tight near-centered stack: a hair of offset/rotation per card so the
53
- // pile still reads as separate cards.
54
- return { x: i * 1.5, y: -i * 1, rotate: (i % 2 === 0 ? 1 : -1) * 1.5, origin: 'bottom center' }
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' }
55
57
  }
56
58
 
57
59
  function openTransform(layout: CardSpreadLayout, i: number, n: number, spread: number): CardTransform {
@@ -131,7 +133,7 @@ export function CardSpread(props: CardSpreadProps): JSX.Element {
131
133
 
132
134
  cardRefs.forEach((card, i) => {
133
135
  if (!card) return
134
- const t = open ? openTransform(layout(), i, n, spread()) : restTransform(i)
136
+ const t = open ? openTransform(layout(), i, n, spread()) : restTransform(i, n)
135
137
  card.style.transformOrigin = t.origin
136
138
  card.style.zIndex = String(open ? i : n - i)
137
139
 
@@ -174,21 +176,27 @@ export function CardSpread(props: CardSpreadProps): JSX.Element {
174
176
  <div
175
177
  role="group"
176
178
  aria-label={props['aria-label']}
177
- class={cn('relative inline-block h-56 w-40', props.class)}
179
+ class={cn('relative mx-auto h-96 w-full max-w-lg', props.class)}
178
180
  onPointerEnter={handleEnter}
179
181
  onPointerLeave={handleLeave}
180
182
  >
181
- <For each={props.items}>
182
- {(item, i) => (
183
- <div
184
- ref={(el) => (cardRefs[i()] = el)}
185
- 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"
186
- style={{ transform: transformString(restTransform(i())) }}
187
- >
188
- {item}
189
- </div>
190
- )}
191
- </For>
183
+ {/* The cards fan around this fixed, centered anchor while the wider root
184
+ above stays the (stable) hover zone: a fanned spread reaches far past
185
+ a single card's box, so anchoring the hit-area to one card made the
186
+ cards leave it and flicker enter/leave. */}
187
+ <div class="absolute left-1/2 top-1/2 h-56 w-40 -translate-x-1/2 -translate-y-1/2">
188
+ <For each={props.items}>
189
+ {(item, i) => (
190
+ <div
191
+ ref={(el) => (cardRefs[i()] = el)}
192
+ class="absolute inset-0 rounded-xl border border-border bg-card text-card-foreground shadow-sm will-change-transform"
193
+ style={{ transform: transformString(restTransform(i(), props.items.length)) }}
194
+ >
195
+ {item}
196
+ </div>
197
+ )}
198
+ </For>
199
+ </div>
192
200
  </div>
193
201
  )
194
202
  }
@@ -201,19 +201,31 @@ export function Carousel3D(props: Carousel3DProps): JSX.Element {
201
201
 
202
202
  // --- pointer drag: horizontal movement past a threshold advances one slide
203
203
  // per threshold crossed, so a long drag can page through several slides. --
204
- const [dragging, setDragging] = createSignal(false)
204
+ // Capture is DEFERRED until the pointer actually moves past a small
205
+ // threshold. Capturing on pointerdown (the old behaviour) retargeted the
206
+ // pointer to the stage and swallowed the `click` on the chevron buttons and
207
+ // any interactive slide content. A plain click no longer starts a drag, so
208
+ // those clicks work again.
205
209
  let startX = 0
210
+ let pointerId: number | null = null
211
+ let captured = false
212
+ const CAPTURE_THRESHOLD = 8
206
213
  const DRAG_THRESHOLD = 60
207
214
 
208
215
  const onPointerDown = (e: PointerEvent): void => {
209
216
  if (count() < 2) return
210
217
  startX = e.clientX
211
- setDragging(true)
212
- ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
218
+ pointerId = e.pointerId
219
+ captured = false
213
220
  }
214
221
  const onPointerMove = (e: PointerEvent): void => {
215
- if (!dragging()) return
222
+ if (pointerId === null) return
216
223
  const dx = e.clientX - startX
224
+ if (!captured) {
225
+ if (Math.abs(dx) < CAPTURE_THRESHOLD) return
226
+ captured = true
227
+ ;(e.currentTarget as HTMLElement).setPointerCapture(pointerId)
228
+ }
217
229
  if (dx <= -DRAG_THRESHOLD) {
218
230
  next()
219
231
  startX = e.clientX
@@ -222,8 +234,16 @@ export function Carousel3D(props: Carousel3DProps): JSX.Element {
222
234
  startX = e.clientX
223
235
  }
224
236
  }
225
- const endDrag = (): void => {
226
- setDragging(false)
237
+ const endDrag = (e: PointerEvent): void => {
238
+ if (pointerId !== null && captured) {
239
+ try {
240
+ ;(e.currentTarget as HTMLElement).releasePointerCapture(pointerId)
241
+ } catch {
242
+ /* already released */
243
+ }
244
+ }
245
+ pointerId = null
246
+ captured = false
227
247
  }
228
248
 
229
249
  return (
@@ -266,6 +286,7 @@ export function Carousel3D(props: Carousel3DProps): JSX.Element {
266
286
  type="button"
267
287
  aria-label="Previous slide"
268
288
  disabled={count() === 0 || active() === 0}
289
+ onPointerDown={(e) => e.stopPropagation()}
269
290
  onClick={prev}
270
291
  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
292
  >
@@ -275,6 +296,7 @@ export function Carousel3D(props: Carousel3DProps): JSX.Element {
275
296
  type="button"
276
297
  aria-label="Next slide"
277
298
  disabled={count() === 0 || active() === count() - 1}
299
+ onPointerDown={(e) => e.stopPropagation()}
278
300
  onClick={next}
279
301
  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
302
  >
@@ -0,0 +1,218 @@
1
+ // MorphPresets — thin, opinionated wrappers over IconMorphButton for common
2
+ // two-state icon toggles (copy, play/pause, mute, lock, theme). Each preset
3
+ // only supplies icons + sensible defaults; the morph animation itself lives
4
+ // in IconMorphButton and is not reimplemented here.
5
+ import type { JSX } from 'solid-js'
6
+ import { Check, Copy, Lock, Moon, Pause, Play, Sun, Unlock, Volume2, VolumeX } from 'lucide-solid'
7
+
8
+ import { IconMorphButton, type IconMorphButtonVariant } from './IconMorphButton'
9
+
10
+ export interface CopyButtonProps {
11
+ /** Text copied to the clipboard when the button is pressed. */
12
+ value: string
13
+ /** Optional label shown next to the icon before copying. */
14
+ label?: JSX.Element
15
+ /** Optional label shown next to the icon once copied. */
16
+ copiedLabel?: JSX.Element
17
+ /** Visual style. Defaults to `'ghost'`. */
18
+ variant?: IconMorphButtonVariant
19
+ class?: string
20
+ 'aria-label'?: string
21
+ }
22
+
23
+ /**
24
+ * Copy-to-clipboard button that morphs Copy → Check and auto-reverts after
25
+ * 1.5s.
26
+ *
27
+ * @example
28
+ * ```tsx
29
+ * <CopyButton value="npm install @a4ui/core" aria-label="Copy install command" />
30
+ * ```
31
+ */
32
+ export function CopyButton(props: CopyButtonProps): JSX.Element {
33
+ return (
34
+ <IconMorphButton
35
+ inactive={<Copy size={18} />}
36
+ active={<Check size={18} />}
37
+ label={props.label}
38
+ activeLabel={props.copiedLabel}
39
+ variant={props.variant}
40
+ class={props.class}
41
+ aria-label={props['aria-label']}
42
+ revertAfter={1500}
43
+ onChange={(pressed) => {
44
+ if (pressed) void navigator.clipboard?.writeText(props.value)
45
+ }}
46
+ />
47
+ )
48
+ }
49
+
50
+ export interface PlayPauseButtonProps {
51
+ /** Controlled playing state. Omit to let the button manage its own state. */
52
+ pressed?: boolean
53
+ /** Initial playing state when uncontrolled. Defaults to `false`. */
54
+ defaultPressed?: boolean
55
+ /** Fired with the next playing state, controlled or uncontrolled. */
56
+ onChange?: (pressed: boolean) => void
57
+ /** Optional label shown next to the icon while paused. */
58
+ label?: JSX.Element
59
+ /** Optional label shown next to the icon while playing. */
60
+ activeLabel?: JSX.Element
61
+ /** Visual style. Defaults to `'ghost'`. */
62
+ variant?: IconMorphButtonVariant
63
+ class?: string
64
+ 'aria-label'?: string
65
+ }
66
+
67
+ /**
68
+ * Play/pause toggle button whose icon morphs Play → Pause.
69
+ *
70
+ * @example
71
+ * ```tsx
72
+ * <PlayPauseButton defaultPressed={false} aria-label="Play" />
73
+ * ```
74
+ */
75
+ export function PlayPauseButton(props: PlayPauseButtonProps): JSX.Element {
76
+ return (
77
+ <IconMorphButton
78
+ inactive={<Play size={18} />}
79
+ active={<Pause size={18} />}
80
+ pressed={props.pressed}
81
+ defaultPressed={props.defaultPressed}
82
+ onChange={props.onChange}
83
+ label={props.label}
84
+ activeLabel={props.activeLabel}
85
+ variant={props.variant}
86
+ class={props.class}
87
+ aria-label={props['aria-label']}
88
+ />
89
+ )
90
+ }
91
+
92
+ export interface MuteButtonProps {
93
+ /** Controlled muted state. Omit to let the button manage its own state. */
94
+ pressed?: boolean
95
+ /** Initial muted state when uncontrolled. Defaults to `false`. */
96
+ defaultPressed?: boolean
97
+ /** Fired with the next muted state, controlled or uncontrolled. */
98
+ onChange?: (pressed: boolean) => void
99
+ /** Optional label shown next to the icon while unmuted. */
100
+ label?: JSX.Element
101
+ /** Optional label shown next to the icon while muted. */
102
+ activeLabel?: JSX.Element
103
+ /** Visual style. Defaults to `'ghost'`. */
104
+ variant?: IconMorphButtonVariant
105
+ class?: string
106
+ 'aria-label'?: string
107
+ }
108
+
109
+ /**
110
+ * Mute toggle button whose icon morphs Volume2 → VolumeX.
111
+ *
112
+ * @example
113
+ * ```tsx
114
+ * <MuteButton defaultPressed={false} aria-label="Mute" />
115
+ * ```
116
+ */
117
+ export function MuteButton(props: MuteButtonProps): JSX.Element {
118
+ return (
119
+ <IconMorphButton
120
+ inactive={<Volume2 size={18} />}
121
+ active={<VolumeX size={18} />}
122
+ pressed={props.pressed}
123
+ defaultPressed={props.defaultPressed}
124
+ onChange={props.onChange}
125
+ label={props.label}
126
+ activeLabel={props.activeLabel}
127
+ variant={props.variant}
128
+ class={props.class}
129
+ aria-label={props['aria-label']}
130
+ />
131
+ )
132
+ }
133
+
134
+ export interface LockButtonProps {
135
+ /** Controlled locked state. Omit to let the button manage its own state. */
136
+ pressed?: boolean
137
+ /** Initial locked state when uncontrolled. Defaults to `false`. */
138
+ defaultPressed?: boolean
139
+ /** Fired with the next locked state, controlled or uncontrolled. */
140
+ onChange?: (pressed: boolean) => void
141
+ /** Optional label shown next to the icon while unlocked. */
142
+ label?: JSX.Element
143
+ /** Optional label shown next to the icon while locked. */
144
+ activeLabel?: JSX.Element
145
+ /** Visual style. Defaults to `'ghost'`. */
146
+ variant?: IconMorphButtonVariant
147
+ class?: string
148
+ 'aria-label'?: string
149
+ }
150
+
151
+ /**
152
+ * Lock toggle button whose icon morphs Unlock → Lock.
153
+ *
154
+ * @example
155
+ * ```tsx
156
+ * <LockButton defaultPressed={false} aria-label="Lock" />
157
+ * ```
158
+ */
159
+ export function LockButton(props: LockButtonProps): JSX.Element {
160
+ return (
161
+ <IconMorphButton
162
+ inactive={<Unlock size={18} />}
163
+ active={<Lock size={18} />}
164
+ pressed={props.pressed}
165
+ defaultPressed={props.defaultPressed}
166
+ onChange={props.onChange}
167
+ label={props.label}
168
+ activeLabel={props.activeLabel}
169
+ variant={props.variant}
170
+ class={props.class}
171
+ aria-label={props['aria-label']}
172
+ />
173
+ )
174
+ }
175
+
176
+ export interface ThemeMorphButtonProps {
177
+ /** Controlled dark-mode state. Omit to let the button manage its own state. */
178
+ pressed?: boolean
179
+ /** Initial dark-mode state when uncontrolled. Defaults to `false`. */
180
+ defaultPressed?: boolean
181
+ /** Fired with the next dark-mode state, controlled or uncontrolled. */
182
+ onChange?: (pressed: boolean) => void
183
+ /** Optional label shown next to the icon in light mode. */
184
+ label?: JSX.Element
185
+ /** Optional label shown next to the icon in dark mode. */
186
+ activeLabel?: JSX.Element
187
+ /** Visual style. Defaults to `'ghost'`. */
188
+ variant?: IconMorphButtonVariant
189
+ class?: string
190
+ 'aria-label'?: string
191
+ }
192
+
193
+ /**
194
+ * Purely presentational theme toggle whose icon morphs Sun → Moon. Does not
195
+ * touch the theme system itself — wire `pressed`/`onChange` to your theme
196
+ * state.
197
+ *
198
+ * @example
199
+ * ```tsx
200
+ * <ThemeMorphButton pressed={isDark()} onChange={setIsDark} aria-label="Toggle theme" />
201
+ * ```
202
+ */
203
+ export function ThemeMorphButton(props: ThemeMorphButtonProps): JSX.Element {
204
+ return (
205
+ <IconMorphButton
206
+ inactive={<Sun size={18} />}
207
+ active={<Moon size={18} />}
208
+ pressed={props.pressed}
209
+ defaultPressed={props.defaultPressed}
210
+ onChange={props.onChange}
211
+ label={props.label}
212
+ activeLabel={props.activeLabel}
213
+ variant={props.variant}
214
+ class={props.class}
215
+ aria-label={props['aria-label']}
216
+ />
217
+ )
218
+ }