@a4ui/core 0.29.0 → 0.30.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,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
+ }
@@ -0,0 +1,220 @@
1
+ // IconMorphButton — a two-state icon toggle whose glyph morphs between an
2
+ // inactive and active icon with a spring: the outgoing icon scales down +
3
+ // rotates + fades out while the incoming scales up + rotates in + fades in,
4
+ // both stacked absolutely inside a fixed-size box. Built for icon actions
5
+ // with an obvious before/after (copy → copied, mute → muted, follow →
6
+ // following). Optional label crossfades alongside the icon.
7
+ import type { JSX } from 'solid-js'
8
+ import { Show, createEffect, createSignal, onCleanup } from 'solid-js'
9
+
10
+ import { cn } from '../lib/cn'
11
+ import { animate, motionReduced } from '../lib/motion'
12
+
13
+ /** Visual style of an {@link IconMorphButton}. Defaults to `'ghost'`. */
14
+ export type IconMorphButtonVariant = 'solid' | 'ghost' | 'outline'
15
+
16
+ const VARIANT_CLASSES: Record<IconMorphButtonVariant, string> = {
17
+ solid: 'bg-primary text-primary-foreground hover:bg-primary/90',
18
+ outline: 'border border-border bg-transparent text-foreground hover:bg-muted',
19
+ ghost: 'bg-transparent text-muted-foreground hover:bg-muted hover:text-foreground',
20
+ }
21
+
22
+ const BASE =
23
+ 'inline-flex h-9 items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-ring disabled:pointer-events-none disabled:opacity-50'
24
+
25
+ const SPRING = { type: 'spring', stiffness: 380, damping: 30 } as const
26
+
27
+ export interface IconMorphButtonProps {
28
+ /** Icon shown while `pressed` is `false`, e.g. `<Copy size={18} />`. */
29
+ inactive: JSX.Element
30
+ /** Icon shown while `pressed` is `true`, e.g. `<Check size={18} />`. */
31
+ active: JSX.Element
32
+ /** Controlled pressed state. Omit to let the button manage its own state. */
33
+ pressed?: boolean
34
+ /** Initial pressed state when uncontrolled. Defaults to `false`. */
35
+ defaultPressed?: boolean
36
+ /** Fired with the next pressed state, controlled or uncontrolled. */
37
+ onChange?: (pressed: boolean) => void
38
+ /**
39
+ * When greater than `0`, auto-reverts to `inactive` after this many ms once
40
+ * pressed (e.g. copy → check → copy). The timer is cleared on cleanup and
41
+ * restarted on every re-press. @default 0
42
+ */
43
+ revertAfter?: number
44
+ /** Optional label shown next to the icon while `pressed` is `false`. */
45
+ label?: JSX.Element
46
+ /** Optional label shown next to the icon while `pressed` is `true`. */
47
+ activeLabel?: JSX.Element
48
+ /** Visual style. Defaults to `'ghost'`. */
49
+ variant?: IconMorphButtonVariant
50
+ class?: string
51
+ 'aria-label'?: string
52
+ }
53
+
54
+ /**
55
+ * Two-state icon toggle button whose icon morphs (spring scale + rotate +
56
+ * fade) between `inactive` and `active` glyphs, stacked in a fixed-size box.
57
+ * Supports controlled (`pressed`/`onChange`) or uncontrolled
58
+ * (`defaultPressed`) use, an optional auto-revert timer, and an optional
59
+ * crossfading label. Falls back to an instant swap under reduced motion.
60
+ *
61
+ * @example
62
+ * ```tsx
63
+ * <IconMorphButton
64
+ * inactive={<Copy size={18} />}
65
+ * active={<Check size={18} />}
66
+ * aria-label="Copy to clipboard"
67
+ * revertAfter={1500}
68
+ * onChange={(pressed) => {
69
+ * if (pressed) void navigator.clipboard?.writeText('npm install @a4ui/core')
70
+ * }}
71
+ * />
72
+ * ```
73
+ */
74
+ export function IconMorphButton(props: IconMorphButtonProps): JSX.Element {
75
+ const [internalPressed, setInternalPressed] = createSignal(props.defaultPressed ?? false)
76
+ const pressed = (): boolean => props.pressed ?? internalPressed()
77
+
78
+ const setPressed = (next: boolean): void => {
79
+ if (props.pressed === undefined) setInternalPressed(next)
80
+ props.onChange?.(next)
81
+ }
82
+
83
+ let revertTimer: ReturnType<typeof setTimeout> | undefined
84
+
85
+ const handleClick = (): void => {
86
+ clearTimeout(revertTimer)
87
+ const next = !pressed()
88
+ setPressed(next)
89
+ const revertAfter = props.revertAfter ?? 0
90
+ if (next && revertAfter > 0) {
91
+ revertTimer = setTimeout(() => setPressed(false), revertAfter)
92
+ }
93
+ }
94
+
95
+ let inactiveIconEl: HTMLSpanElement | undefined
96
+ let activeIconEl: HTMLSpanElement | undefined
97
+ let inactiveLabelEl: HTMLSpanElement | undefined
98
+ let activeLabelEl: HTMLSpanElement | undefined
99
+
100
+ let iconOutControls: ReturnType<typeof animate> | undefined
101
+ let iconInControls: ReturnType<typeof animate> | undefined
102
+ let labelOutControls: ReturnType<typeof animate> | undefined
103
+ let labelInControls: ReturnType<typeof animate> | undefined
104
+
105
+ const setInstant = (el: HTMLElement | undefined, visible: boolean, morph: boolean): void => {
106
+ if (!el) return
107
+ el.style.opacity = visible ? '1' : '0'
108
+ if (morph) el.style.transform = visible ? 'scale(1) rotate(0deg)' : 'scale(0.6) rotate(0deg)'
109
+ }
110
+
111
+ const applyInstant = (isPressed: boolean): void => {
112
+ setInstant(inactiveIconEl, !isPressed, true)
113
+ setInstant(activeIconEl, isPressed, true)
114
+ setInstant(inactiveLabelEl, !isPressed, false)
115
+ setInstant(activeLabelEl, isPressed, false)
116
+ }
117
+
118
+ let isFirstRun = true
119
+
120
+ createEffect(() => {
121
+ const isPressed = pressed()
122
+
123
+ if (isFirstRun) {
124
+ isFirstRun = false
125
+ applyInstant(isPressed)
126
+ return
127
+ }
128
+
129
+ iconOutControls?.stop()
130
+ iconInControls?.stop()
131
+ labelOutControls?.stop()
132
+ labelInControls?.stop()
133
+
134
+ if (motionReduced()) {
135
+ applyInstant(isPressed)
136
+ return
137
+ }
138
+
139
+ const outgoingIcon = isPressed ? inactiveIconEl : activeIconEl
140
+ const incomingIcon = isPressed ? activeIconEl : inactiveIconEl
141
+ const outRotate = isPressed ? 90 : -90
142
+ const inRotate = isPressed ? -90 : 90
143
+
144
+ if (outgoingIcon) {
145
+ iconOutControls = animate(
146
+ outgoingIcon,
147
+ { opacity: [1, 0], scale: [1, 0.6], rotate: [0, outRotate] },
148
+ SPRING,
149
+ )
150
+ }
151
+ if (incomingIcon) {
152
+ iconInControls = animate(
153
+ incomingIcon,
154
+ { opacity: [0, 1], scale: [0.6, 1], rotate: [inRotate, 0] },
155
+ SPRING,
156
+ )
157
+ }
158
+
159
+ const outgoingLabel = isPressed ? inactiveLabelEl : activeLabelEl
160
+ const incomingLabel = isPressed ? activeLabelEl : inactiveLabelEl
161
+
162
+ if (outgoingLabel) {
163
+ labelOutControls = animate(outgoingLabel, { opacity: [1, 0] }, { duration: 0.18, ease: 'easeOut' })
164
+ }
165
+ if (incomingLabel) {
166
+ labelInControls = animate(incomingLabel, { opacity: [0, 1] }, { duration: 0.18, ease: 'easeOut' })
167
+ }
168
+ })
169
+
170
+ onCleanup(() => {
171
+ clearTimeout(revertTimer)
172
+ iconOutControls?.stop()
173
+ iconInControls?.stop()
174
+ labelOutControls?.stop()
175
+ labelInControls?.stop()
176
+ })
177
+
178
+ const hasLabel = (): boolean => props.label !== undefined || props.activeLabel !== undefined
179
+
180
+ return (
181
+ <button
182
+ type="button"
183
+ aria-pressed={pressed()}
184
+ aria-label={props['aria-label']}
185
+ onClick={handleClick}
186
+ class={cn(BASE, hasLabel() ? 'px-3' : 'w-9', VARIANT_CLASSES[props.variant ?? 'ghost'], props.class)}
187
+ >
188
+ <span class="relative inline-block h-5 w-5 shrink-0">
189
+ <span
190
+ ref={inactiveIconEl}
191
+ class="absolute inset-0 flex items-center justify-center will-change-transform"
192
+ aria-hidden={pressed()}
193
+ >
194
+ {props.inactive}
195
+ </span>
196
+ <span
197
+ ref={activeIconEl}
198
+ class="absolute inset-0 flex items-center justify-center will-change-transform"
199
+ aria-hidden={!pressed()}
200
+ >
201
+ {props.active}
202
+ </span>
203
+ </span>
204
+ <Show when={hasLabel()}>
205
+ <span class="inline-grid">
206
+ <Show when={props.label}>
207
+ <span ref={inactiveLabelEl} class="col-start-1 row-start-1" aria-hidden={pressed()}>
208
+ {props.label}
209
+ </span>
210
+ </Show>
211
+ <Show when={props.activeLabel}>
212
+ <span ref={activeLabelEl} class="col-start-1 row-start-1" aria-hidden={!pressed()}>
213
+ {props.activeLabel}
214
+ </span>
215
+ </Show>
216
+ </span>
217
+ </Show>
218
+ </button>
219
+ )
220
+ }