@a4ui/core 0.12.0 → 0.13.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.
@@ -4,6 +4,7 @@ import type { JSX } from 'solid-js'
4
4
  import { createSignal, For, Show } from 'solid-js'
5
5
 
6
6
  import { cn } from '../lib/cn'
7
+ import { animate, motionReduced } from '../lib/motion'
7
8
 
8
9
  /** A single action in a {@link SpeedDial} — an icon button with an accessible label. */
9
10
  export interface SpeedDialAction {
@@ -42,8 +43,17 @@ export function SpeedDial(props: SpeedDialProps): JSX.Element {
42
43
  <Show when={open()}>
43
44
  <div class="flex flex-col items-center gap-3">
44
45
  <For each={props.actions}>
45
- {(action) => (
46
+ {(action, i) => (
46
47
  <button
48
+ ref={(el) => {
49
+ // Fan out with a staggered spring as the dial opens.
50
+ if (!motionReduced())
51
+ animate(
52
+ el,
53
+ { opacity: [0, 1], y: [12, 0], scale: [0.8, 1] },
54
+ { type: 'spring', stiffness: 500, damping: 24, delay: i() * 0.05 },
55
+ )
56
+ }}
47
57
  type="button"
48
58
  aria-label={action.label}
49
59
  title={action.label}
@@ -51,7 +61,7 @@ export function SpeedDial(props: SpeedDialProps): JSX.Element {
51
61
  action.onClick()
52
62
  setOpen(false)
53
63
  }}
54
- class="flex h-11 w-11 translate-y-0 items-center justify-center rounded-full border border-border bg-card text-foreground opacity-100 shadow transition-[opacity,transform] duration-150 hover:bg-muted focus:outline-none focus:ring-2 focus:ring-ring"
64
+ class="flex h-11 w-11 items-center justify-center rounded-full border border-border bg-card text-foreground shadow hover:bg-muted focus:outline-none focus:ring-2 focus:ring-ring"
55
65
  >
56
66
  {action.icon}
57
67
  </button>
@@ -0,0 +1,93 @@
1
+ // Reveal text word-by-word or char-by-char with a Motion (motion.dev) stagger:
2
+ // each unit starts translated down and transparent, then fades/slides in with
3
+ // `animate`'s `delay: stagger(...)`, either on mount or the first time the
4
+ // text scrolls into view (`inView`). Whitespace units are rendered as plain
5
+ // text (not wrapped/animated) so word spacing stays exactly what it would be
6
+ // without the effect.
7
+ import { createMemo, onMount, For, type JSX } from 'solid-js'
8
+
9
+ import { cn } from '../lib/cn'
10
+ import { animate, inView, motionReduced, stagger } from '../lib/motion'
11
+
12
+ export interface TextRevealProps {
13
+ text: string
14
+ /** Split unit. @default 'word' */
15
+ by?: 'word' | 'char'
16
+ /** Reveal when scrolled into view instead of on mount. @default false */
17
+ onView?: boolean
18
+ class?: string
19
+ }
20
+
21
+ /** Split `text` into render units: words with their surrounding spaces kept as separate whitespace units, or individual characters. */
22
+ function splitUnits(text: string, by: 'word' | 'char'): string[] {
23
+ if (by === 'char') return text.split('')
24
+ return text.split(/(\s+)/).filter((unit) => unit.length > 0)
25
+ }
26
+
27
+ const isWhitespace = (unit: string): boolean => /^\s+$/.test(unit)
28
+
29
+ /**
30
+ * Reveals `text`, split into words or characters, with a staggered
31
+ * fade + slide-up (via Motion's `animate` + `stagger`). Runs once on mount by
32
+ * default; pass `onView` to instead reveal the first time the component
33
+ * scrolls into view. Whitespace between words renders as plain text, not an
34
+ * animated unit. Under reduced motion, everything stays visible and no
35
+ * animation runs.
36
+ *
37
+ * @example
38
+ * ```tsx
39
+ * <TextReveal text="Ship it." by="char" onView />
40
+ * ```
41
+ */
42
+ export function TextReveal(props: TextRevealProps): JSX.Element {
43
+ const units = createMemo(() => splitUnits(props.text, props.by ?? 'word'))
44
+ const spans: (HTMLSpanElement | undefined)[] = []
45
+ let root: HTMLSpanElement | undefined
46
+
47
+ const reveal = (): void => {
48
+ const targets = spans.filter((el): el is HTMLSpanElement => el !== undefined)
49
+ if (targets.length === 0) return
50
+ animate(
51
+ targets,
52
+ { opacity: [0, 1], y: [12, 0] },
53
+ { delay: stagger(0.03), duration: 0.4, ease: 'easeOut' },
54
+ )
55
+ }
56
+
57
+ onMount(() => {
58
+ if (motionReduced()) return
59
+
60
+ for (const el of spans) {
61
+ if (el) el.style.opacity = '0'
62
+ }
63
+
64
+ if (props.onView) {
65
+ if (!root) return
66
+ inView(root, () => reveal(), { amount: 0.3 })
67
+ return
68
+ }
69
+
70
+ reveal()
71
+ })
72
+
73
+ return (
74
+ <span class={cn(props.class)} ref={root}>
75
+ <For each={units()}>
76
+ {(unit, index) =>
77
+ isWhitespace(unit) ? (
78
+ unit
79
+ ) : (
80
+ <span
81
+ class="inline-block will-change-transform"
82
+ ref={(el) => {
83
+ spans[index()] = el
84
+ }}
85
+ >
86
+ {unit}
87
+ </span>
88
+ )
89
+ }
90
+ </For>
91
+ </span>
92
+ )
93
+ }