@a4ui/core 0.13.0 → 0.14.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/README.md +48 -38
- package/dist/elements.css +3 -0
- package/dist/full.css +3 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2451 -2305
- package/dist/ui/NotificationStack.d.ts +7 -7
- package/dist/ui/Ripple.d.ts +25 -0
- package/dist/ui/Typewriter.d.ts +28 -0
- package/package.json +14 -6
- package/src/index.ts +3 -1
- package/src/ui/NotificationStack.tsx +101 -66
- package/src/ui/Ripple.tsx +119 -0
- package/src/ui/Typewriter.tsx +149 -0
- package/src/elements.tsx +0 -167
- package/src/styles/space.css +0 -510
- package/src/styles/tokens.css +0 -213
|
@@ -8,23 +8,23 @@ export interface StackNotification {
|
|
|
8
8
|
export interface NotificationStackProps {
|
|
9
9
|
/** Newest first. The parent owns this array. */
|
|
10
10
|
items: StackNotification[];
|
|
11
|
-
/** How many cards
|
|
11
|
+
/** How many cards peek when collapsed. @default 3 */
|
|
12
12
|
max?: number;
|
|
13
13
|
/** Fired when a card is dismissed; the parent should drop it from `items`. */
|
|
14
14
|
onDismiss?: (id: string | number) => void;
|
|
15
15
|
class?: string;
|
|
16
16
|
}
|
|
17
17
|
/**
|
|
18
|
-
* Stacked notifications.
|
|
19
|
-
* so they peek from the bottom edge;
|
|
20
|
-
*
|
|
21
|
-
* in via `animateIn`.
|
|
18
|
+
* Stacked notifications. Collapsed, cards behind the front one are offset down
|
|
19
|
+
* and scaled so they peek from the bottom edge; click the stack to expand every
|
|
20
|
+
* notification into a scrollable list, and "Show less" to collapse. Removing the
|
|
21
|
+
* front card promotes the rest; new cards fade/slide in via `animateIn`.
|
|
22
22
|
*
|
|
23
23
|
* @example
|
|
24
24
|
* ```tsx
|
|
25
|
-
* const [items, setItems] =
|
|
25
|
+
* const [items, setItems] = createSignal<StackNotification[]>([])
|
|
26
26
|
* <NotificationStack
|
|
27
|
-
* items={items}
|
|
27
|
+
* items={items()}
|
|
28
28
|
* onDismiss={(id) => setItems((xs) => xs.filter((x) => x.id !== id))}
|
|
29
29
|
* />
|
|
30
30
|
* ```
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
export interface RippleProps {
|
|
3
|
+
children: JSX.Element;
|
|
4
|
+
/** Ripple color (CSS color). @default 'currentColor' */
|
|
5
|
+
color?: string;
|
|
6
|
+
/** Ripple opacity 0..1. @default 0.3 */
|
|
7
|
+
opacity?: number;
|
|
8
|
+
class?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Wraps `children` in a `position: relative; overflow: hidden` layer that
|
|
12
|
+
* spawns a Material-style ripple — a circle centered on the pointer's down
|
|
13
|
+
* position, sized to cover the wrapped content, expanding and fading out —
|
|
14
|
+
* on every `pointerdown`. Rapid clicks stack multiple ripples; each removes
|
|
15
|
+
* itself once its animation finishes. A no-op under reduced motion: children
|
|
16
|
+
* still render, but no ripple ever spawns.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```tsx
|
|
20
|
+
* <Ripple color="white" opacity={0.25}>
|
|
21
|
+
* <button class="rounded-lg bg-primary px-4 py-2 text-primary-foreground">Save</button>
|
|
22
|
+
* </Ripple>
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export declare function Ripple(props: RippleProps): JSX.Element;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
export interface TypewriterProps {
|
|
3
|
+
/** The full string (or strings, cycled) to type out. */
|
|
4
|
+
text: string | string[];
|
|
5
|
+
/** ms per character. @default 55 */
|
|
6
|
+
speed?: number;
|
|
7
|
+
/** ms to pause on a completed string before deleting (only when `text` is an array). @default 1400 */
|
|
8
|
+
pause?: number;
|
|
9
|
+
/** Show a blinking caret. @default true */
|
|
10
|
+
caret?: boolean;
|
|
11
|
+
/** Loop through the array forever. @default true when text is an array */
|
|
12
|
+
loop?: boolean;
|
|
13
|
+
class?: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Types `text` out one character at a time, like a terminal. When `text` is
|
|
17
|
+
* an array, each string types in, holds for `pause` ms, then deletes before
|
|
18
|
+
* the next one types in — cycling forever unless `loop` is `false` (defaults
|
|
19
|
+
* to looping only when there's more than one string). Shows the first (or
|
|
20
|
+
* only) string statically, with no typing and no caret blink, when the user
|
|
21
|
+
* prefers reduced motion.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```tsx
|
|
25
|
+
* <Typewriter text={['Building things…', 'Shipping things…']} speed={40} />
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export declare function Typewriter(props: TypewriterProps): JSX.Element;
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@a4ui/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
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",
|
|
7
7
|
"author": "Luis Alfredo Rivera Acuña <1alfredorivera@gmail.com>",
|
|
8
|
-
"homepage": "https://
|
|
8
|
+
"homepage": "https://a4ui.pages.dev/",
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|
|
11
11
|
"url": "git+https://github.com/A4uikit/a4ui.git"
|
|
@@ -29,10 +29,16 @@
|
|
|
29
29
|
],
|
|
30
30
|
"files": [
|
|
31
31
|
"dist",
|
|
32
|
-
"
|
|
32
|
+
"preset.js",
|
|
33
|
+
"src/index.ts",
|
|
34
|
+
"src/ui",
|
|
35
|
+
"src/lib",
|
|
36
|
+
"src/layout",
|
|
37
|
+
"src/themes",
|
|
38
|
+
"src/commerce",
|
|
39
|
+
"src/charts",
|
|
33
40
|
"!src/**/*.test.ts",
|
|
34
|
-
"!src/**/*.test.tsx"
|
|
35
|
-
"preset.js"
|
|
41
|
+
"!src/**/*.test.tsx"
|
|
36
42
|
],
|
|
37
43
|
"main": "./dist/index.js",
|
|
38
44
|
"module": "./dist/index.js",
|
|
@@ -72,10 +78,12 @@
|
|
|
72
78
|
"engines": {
|
|
73
79
|
"node": ">=20"
|
|
74
80
|
},
|
|
81
|
+
"packageManager": "npm@10.9.3",
|
|
75
82
|
"publishConfig": {
|
|
76
83
|
"access": "public"
|
|
77
84
|
},
|
|
78
85
|
"scripts": {
|
|
86
|
+
"prebuild": "node scripts/sync-version.mjs",
|
|
79
87
|
"build": "vite build && npm run build:elements",
|
|
80
88
|
"build:elements": "vite build --config vite.elements.config.ts && node scripts/build-elements-css.mjs",
|
|
81
89
|
"dev": "vite build --watch",
|
|
@@ -92,7 +100,7 @@
|
|
|
92
100
|
"test:a11y": "playwright test a11y --project=desktop",
|
|
93
101
|
"test:report": "playwright show-report",
|
|
94
102
|
"test:package": "node scripts/test-package.mjs",
|
|
95
|
-
"validate": "npm run typecheck && npm run lint && npm run test:unit && npm run build",
|
|
103
|
+
"validate": "npm run typecheck && npm run lint && npm run format:check && npm run test:unit && npm run build && npm run test:package",
|
|
96
104
|
"prepublishOnly": "npm run validate",
|
|
97
105
|
"prepare": "husky"
|
|
98
106
|
},
|
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.14.0'
|
|
12
12
|
|
|
13
13
|
// Helpers (src/lib) — generic, framework-level utilities.
|
|
14
14
|
export { cn } from './lib/cn'
|
|
@@ -149,6 +149,8 @@ export {
|
|
|
149
149
|
export { MultiStateBadge, type MultiStateBadgeProps, type BadgeState } from './ui/MultiStateBadge'
|
|
150
150
|
export { NowPlaying, type NowPlayingProps } from './ui/NowPlaying'
|
|
151
151
|
export { Expandable, type ExpandableProps } from './ui/Expandable'
|
|
152
|
+
export { Typewriter, type TypewriterProps } from './ui/Typewriter'
|
|
153
|
+
export { Ripple, type RippleProps } from './ui/Ripple'
|
|
152
154
|
export { flyToCart, type FlyToCartOptions } from './lib/flyToCart'
|
|
153
155
|
|
|
154
156
|
// Layout (src/layout) — generic shell + backdrop + toggles. App-coupled pieces
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
// A stacked-notifications widget (phone lock-screen style): the
|
|
2
|
-
// full-size on top and older
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
1
|
+
// A stacked-notifications widget (phone lock-screen style): collapsed, the
|
|
2
|
+
// newest card is full-size on top and older ones peek out behind it, scaled
|
|
3
|
+
// down and offset. Click the stack (or "Show all") to EXPAND it into a full,
|
|
4
|
+
// scrollable list; "Show less" collapses it back. Presentational + controlled —
|
|
5
|
+
// the parent owns `items` (newest first) and removes a card on `onDismiss`.
|
|
6
|
+
import { createSignal, For, Show, type JSX } from 'solid-js'
|
|
6
7
|
|
|
7
8
|
import { cn } from '../lib/cn'
|
|
8
9
|
import { animateIn, motionReduced } from '../lib/motion'
|
|
@@ -17,7 +18,7 @@ export interface StackNotification {
|
|
|
17
18
|
export interface NotificationStackProps {
|
|
18
19
|
/** Newest first. The parent owns this array. */
|
|
19
20
|
items: StackNotification[]
|
|
20
|
-
/** How many cards
|
|
21
|
+
/** How many cards peek when collapsed. @default 3 */
|
|
21
22
|
max?: number
|
|
22
23
|
/** Fired when a card is dismissed; the parent should drop it from `items`. */
|
|
23
24
|
onDismiss?: (id: string | number) => void
|
|
@@ -25,80 +26,114 @@ export interface NotificationStackProps {
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
|
-
* Stacked notifications.
|
|
29
|
-
* so they peek from the bottom edge;
|
|
30
|
-
*
|
|
31
|
-
* in via `animateIn`.
|
|
29
|
+
* Stacked notifications. Collapsed, cards behind the front one are offset down
|
|
30
|
+
* and scaled so they peek from the bottom edge; click the stack to expand every
|
|
31
|
+
* notification into a scrollable list, and "Show less" to collapse. Removing the
|
|
32
|
+
* front card promotes the rest; new cards fade/slide in via `animateIn`.
|
|
32
33
|
*
|
|
33
34
|
* @example
|
|
34
35
|
* ```tsx
|
|
35
|
-
* const [items, setItems] =
|
|
36
|
+
* const [items, setItems] = createSignal<StackNotification[]>([])
|
|
36
37
|
* <NotificationStack
|
|
37
|
-
* items={items}
|
|
38
|
+
* items={items()}
|
|
38
39
|
* onDismiss={(id) => setItems((xs) => xs.filter((x) => x.id !== id))}
|
|
39
40
|
* />
|
|
40
41
|
* ```
|
|
41
42
|
*/
|
|
42
43
|
export function NotificationStack(props: NotificationStackProps): JSX.Element {
|
|
43
44
|
const max = () => props.max ?? 3
|
|
45
|
+
const [expanded, setExpanded] = createSignal(false)
|
|
46
|
+
const canExpand = () => props.items.length > 1
|
|
44
47
|
|
|
45
48
|
return (
|
|
46
|
-
<div
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
49
|
+
<div role="log" aria-live="polite" class={cn('w-full max-w-sm', props.class)}>
|
|
50
|
+
<div
|
|
51
|
+
class={cn(
|
|
52
|
+
expanded()
|
|
53
|
+
? 'max-h-[60vh] space-y-2 overflow-y-auto pr-1'
|
|
54
|
+
: cn('relative', canExpand() && 'cursor-pointer'),
|
|
55
|
+
)}
|
|
56
|
+
style={
|
|
57
|
+
expanded()
|
|
58
|
+
? undefined
|
|
59
|
+
: // reserve room for the front card plus the peek of those behind it
|
|
60
|
+
{ 'min-height': `${88 + (Math.min(props.items.length, max()) - 1) * 10}px` }
|
|
61
|
+
}
|
|
62
|
+
onClick={() => {
|
|
63
|
+
if (!expanded() && canExpand()) setExpanded(true)
|
|
64
|
+
}}
|
|
65
|
+
>
|
|
66
|
+
<For each={props.items}>
|
|
67
|
+
{(item, i) => {
|
|
68
|
+
const collapsedHidden = () => !expanded() && i() > max() - 1
|
|
69
|
+
return (
|
|
70
|
+
<div
|
|
71
|
+
ref={(el) => animateIn(el, { y: -8 })}
|
|
72
|
+
class={cn(
|
|
73
|
+
'card rounded-xl border border-border bg-card p-3 shadow-lg',
|
|
74
|
+
expanded() ? 'relative' : 'absolute inset-x-0 top-0',
|
|
75
|
+
!motionReduced() && !expanded() && 'transition-[transform,opacity] duration-300 ease-out',
|
|
76
|
+
)}
|
|
77
|
+
style={
|
|
78
|
+
expanded()
|
|
79
|
+
? undefined
|
|
80
|
+
: {
|
|
81
|
+
transform: `translateY(${i() * 10}px) scale(${1 - i() * 0.05})`,
|
|
82
|
+
opacity: collapsedHidden() ? 0 : 1 - i() * 0.15,
|
|
83
|
+
'z-index': props.items.length - i(),
|
|
84
|
+
'pointer-events': collapsedHidden() ? 'none' : 'auto',
|
|
85
|
+
'transform-origin': 'top center',
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
>
|
|
89
|
+
<div class="flex items-start gap-2.5">
|
|
90
|
+
{item.icon && <span class="mt-0.5 shrink-0 text-primary">{item.icon}</span>}
|
|
91
|
+
<div class="min-w-0 flex-1">
|
|
92
|
+
<p class={cn('text-sm font-medium text-foreground', !expanded() && 'truncate')}>
|
|
93
|
+
{item.title}
|
|
94
|
+
</p>
|
|
95
|
+
{item.description && (
|
|
96
|
+
<p class="mt-0.5 line-clamp-2 text-sm text-muted-foreground">{item.description}</p>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
aria-label={`Dismiss ${item.title}`}
|
|
102
|
+
onClick={(e) => {
|
|
103
|
+
e.stopPropagation()
|
|
104
|
+
props.onDismiss?.(item.id)
|
|
105
|
+
}}
|
|
106
|
+
class="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
93
107
|
>
|
|
94
|
-
<
|
|
95
|
-
|
|
96
|
-
|
|
108
|
+
<svg
|
|
109
|
+
viewBox="0 0 24 24"
|
|
110
|
+
class="h-3.5 w-3.5"
|
|
111
|
+
fill="none"
|
|
112
|
+
stroke="currentColor"
|
|
113
|
+
stroke-width="2.5"
|
|
114
|
+
stroke-linecap="round"
|
|
115
|
+
aria-hidden="true"
|
|
116
|
+
>
|
|
117
|
+
<path d="M6 6l12 12M18 6L6 18" />
|
|
118
|
+
</svg>
|
|
119
|
+
</button>
|
|
120
|
+
</div>
|
|
97
121
|
</div>
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
</
|
|
122
|
+
)
|
|
123
|
+
}}
|
|
124
|
+
</For>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<Show when={canExpand()}>
|
|
128
|
+
<button
|
|
129
|
+
type="button"
|
|
130
|
+
aria-expanded={expanded()}
|
|
131
|
+
onClick={() => setExpanded((v) => !v)}
|
|
132
|
+
class="mt-2 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
|
|
133
|
+
>
|
|
134
|
+
{expanded() ? 'Show less' : `Show all ${props.items.length}`}
|
|
135
|
+
</button>
|
|
136
|
+
</Show>
|
|
102
137
|
</div>
|
|
103
138
|
)
|
|
104
139
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Material-style click ripple: a wrapper that spawns an expanding, fading
|
|
2
|
+
// circle from the pointer's down position on every `pointerdown` — Motion's
|
|
3
|
+
// `animate` drives the scale/opacity tween since each ripple is a one-shot
|
|
4
|
+
// spawn-then-discard element, not a persistent CSS transition. `x`/`y` are
|
|
5
|
+
// animated to a constant -50% alongside `scale` so Motion (which owns the
|
|
6
|
+
// element's `transform` once it's animating any transform sub-property)
|
|
7
|
+
// composes `translate(-50%,-50%) scale(...)` itself rather than clobbering a
|
|
8
|
+
// translate we'd otherwise have set by hand via inline style.
|
|
9
|
+
import { createSignal, For, onCleanup, type JSX } from 'solid-js'
|
|
10
|
+
|
|
11
|
+
import { cn } from '../lib/cn'
|
|
12
|
+
import { animate, motionReduced } from '../lib/motion'
|
|
13
|
+
|
|
14
|
+
export interface RippleProps {
|
|
15
|
+
children: JSX.Element
|
|
16
|
+
/** Ripple color (CSS color). @default 'currentColor' */
|
|
17
|
+
color?: string
|
|
18
|
+
/** Ripple opacity 0..1. @default 0.3 */
|
|
19
|
+
opacity?: number
|
|
20
|
+
class?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface RippleInstance {
|
|
24
|
+
id: number
|
|
25
|
+
x: number
|
|
26
|
+
y: number
|
|
27
|
+
size: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let nextRippleId = 0
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Wraps `children` in a `position: relative; overflow: hidden` layer that
|
|
34
|
+
* spawns a Material-style ripple — a circle centered on the pointer's down
|
|
35
|
+
* position, sized to cover the wrapped content, expanding and fading out —
|
|
36
|
+
* on every `pointerdown`. Rapid clicks stack multiple ripples; each removes
|
|
37
|
+
* itself once its animation finishes. A no-op under reduced motion: children
|
|
38
|
+
* still render, but no ripple ever spawns.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```tsx
|
|
42
|
+
* <Ripple color="white" opacity={0.25}>
|
|
43
|
+
* <button class="rounded-lg bg-primary px-4 py-2 text-primary-foreground">Save</button>
|
|
44
|
+
* </Ripple>
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export function Ripple(props: RippleProps): JSX.Element {
|
|
48
|
+
const [ripples, setRipples] = createSignal<RippleInstance[]>([])
|
|
49
|
+
const active = new Set<ReturnType<typeof animate>>()
|
|
50
|
+
|
|
51
|
+
let containerEl: HTMLSpanElement | undefined
|
|
52
|
+
|
|
53
|
+
const remove = (id: number): void => {
|
|
54
|
+
setRipples((current) => current.filter((ripple) => ripple.id !== id))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const spawnAnimation = (el: HTMLSpanElement, id: number, opacity: number): void => {
|
|
58
|
+
const controls = animate(
|
|
59
|
+
el,
|
|
60
|
+
{ x: ['-50%', '-50%'], y: ['-50%', '-50%'], scale: [0, 1], opacity: [opacity, 0] },
|
|
61
|
+
{ duration: 0.6, ease: 'easeOut' },
|
|
62
|
+
)
|
|
63
|
+
active.add(controls)
|
|
64
|
+
controls.finished
|
|
65
|
+
.then(() => {
|
|
66
|
+
active.delete(controls)
|
|
67
|
+
remove(id)
|
|
68
|
+
})
|
|
69
|
+
.catch(() => {})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const handlePointerDown = (event: PointerEvent): void => {
|
|
73
|
+
if (motionReduced() || !containerEl) return
|
|
74
|
+
|
|
75
|
+
const rect = containerEl.getBoundingClientRect()
|
|
76
|
+
const x = event.clientX - rect.left
|
|
77
|
+
const y = event.clientY - rect.top
|
|
78
|
+
const maxDist = Math.max(
|
|
79
|
+
Math.hypot(x, y),
|
|
80
|
+
Math.hypot(rect.width - x, y),
|
|
81
|
+
Math.hypot(x, rect.height - y),
|
|
82
|
+
Math.hypot(rect.width - x, rect.height - y),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
setRipples((current) => [...current, { id: nextRippleId++, x, y, size: maxDist * 2 }])
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
onCleanup(() => {
|
|
89
|
+
active.forEach((controls) => controls.stop())
|
|
90
|
+
active.clear()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<span
|
|
95
|
+
ref={containerEl}
|
|
96
|
+
class={cn('relative inline-block overflow-hidden', props.class)}
|
|
97
|
+
onPointerDown={handlePointerDown}
|
|
98
|
+
>
|
|
99
|
+
{props.children}
|
|
100
|
+
<span aria-hidden="true" class="pointer-events-none absolute inset-0">
|
|
101
|
+
<For each={ripples()}>
|
|
102
|
+
{(ripple) => (
|
|
103
|
+
<span
|
|
104
|
+
ref={(el) => spawnAnimation(el, ripple.id, props.opacity ?? 0.3)}
|
|
105
|
+
class="absolute rounded-full"
|
|
106
|
+
style={{
|
|
107
|
+
left: `${ripple.x}px`,
|
|
108
|
+
top: `${ripple.y}px`,
|
|
109
|
+
width: `${ripple.size}px`,
|
|
110
|
+
height: `${ripple.size}px`,
|
|
111
|
+
background: props.color ?? 'currentColor',
|
|
112
|
+
}}
|
|
113
|
+
/>
|
|
114
|
+
)}
|
|
115
|
+
</For>
|
|
116
|
+
</span>
|
|
117
|
+
</span>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Terminal-style text reveal: characters appear one at a time via a plain
|
|
2
|
+
// setTimeout loop (not a CSS transition, since each character is a discrete
|
|
3
|
+
// reveal step, not a tween). Accepts a single string or an array to cycle
|
|
4
|
+
// through — type, pause, delete, type the next — with an optional blinking
|
|
5
|
+
// caret, whose keyframe is injected once into the document head (mirroring
|
|
6
|
+
// LoadingDots's approach to a component-scoped CSS animation).
|
|
7
|
+
import { createSignal, onCleanup, onMount, type JSX } from 'solid-js'
|
|
8
|
+
|
|
9
|
+
import { cn } from '../lib/cn'
|
|
10
|
+
import { motionReduced } from '../lib/motion'
|
|
11
|
+
|
|
12
|
+
export interface TypewriterProps {
|
|
13
|
+
/** The full string (or strings, cycled) to type out. */
|
|
14
|
+
text: string | string[]
|
|
15
|
+
/** ms per character. @default 55 */
|
|
16
|
+
speed?: number
|
|
17
|
+
/** ms to pause on a completed string before deleting (only when `text` is an array). @default 1400 */
|
|
18
|
+
pause?: number
|
|
19
|
+
/** Show a blinking caret. @default true */
|
|
20
|
+
caret?: boolean
|
|
21
|
+
/** Loop through the array forever. @default true when text is an array */
|
|
22
|
+
loop?: boolean
|
|
23
|
+
class?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const STYLE_ID = 'a4ui-typewriter-style'
|
|
27
|
+
|
|
28
|
+
function ensureStyleInjected(): void {
|
|
29
|
+
if (typeof document === 'undefined' || document.getElementById(STYLE_ID)) return
|
|
30
|
+
const style = document.createElement('style')
|
|
31
|
+
style.id = STYLE_ID
|
|
32
|
+
style.textContent = `
|
|
33
|
+
@keyframes a4-caret-blink {
|
|
34
|
+
0%, 49% { opacity: 1; }
|
|
35
|
+
50%, 100% { opacity: 0; }
|
|
36
|
+
}
|
|
37
|
+
`
|
|
38
|
+
document.head.appendChild(style)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function toList(text: string | string[]): string[] {
|
|
42
|
+
return Array.isArray(text) ? text : [text]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Types `text` out one character at a time, like a terminal. When `text` is
|
|
47
|
+
* an array, each string types in, holds for `pause` ms, then deletes before
|
|
48
|
+
* the next one types in — cycling forever unless `loop` is `false` (defaults
|
|
49
|
+
* to looping only when there's more than one string). Shows the first (or
|
|
50
|
+
* only) string statically, with no typing and no caret blink, when the user
|
|
51
|
+
* prefers reduced motion.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* ```tsx
|
|
55
|
+
* <Typewriter text={['Building things…', 'Shipping things…']} speed={40} />
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
export function Typewriter(props: TypewriterProps): JSX.Element {
|
|
59
|
+
ensureStyleInjected()
|
|
60
|
+
|
|
61
|
+
// eslint-disable-next-line solid/reactivity -- one-time seed; run() re-reads props.text on each (re)start
|
|
62
|
+
const [display, setDisplay] = createSignal(motionReduced() ? (toList(props.text)[0] ?? '') : '')
|
|
63
|
+
|
|
64
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
65
|
+
|
|
66
|
+
const stop = (): void => {
|
|
67
|
+
if (timer === undefined) return
|
|
68
|
+
clearTimeout(timer)
|
|
69
|
+
timer = undefined
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const run = (): void => {
|
|
73
|
+
stop()
|
|
74
|
+
const list = toList(props.text)
|
|
75
|
+
const first = list[0] ?? ''
|
|
76
|
+
|
|
77
|
+
if (motionReduced() || list.length === 0) {
|
|
78
|
+
setDisplay(first)
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const speed = props.speed ?? 55
|
|
83
|
+
const pause = props.pause ?? 1400
|
|
84
|
+
const shouldLoop = props.loop ?? list.length > 1
|
|
85
|
+
|
|
86
|
+
let stringIndex = 0
|
|
87
|
+
let charIndex = 0
|
|
88
|
+
let phase: 'typing' | 'pausing' | 'deleting' = 'typing'
|
|
89
|
+
|
|
90
|
+
const step = (): void => {
|
|
91
|
+
const text = list[stringIndex] ?? ''
|
|
92
|
+
|
|
93
|
+
if (phase === 'typing') {
|
|
94
|
+
charIndex += 1
|
|
95
|
+
setDisplay(text.slice(0, charIndex))
|
|
96
|
+
if (charIndex >= text.length) {
|
|
97
|
+
const isLastString = stringIndex >= list.length - 1
|
|
98
|
+
if (list.length <= 1 || (isLastString && !shouldLoop)) {
|
|
99
|
+
stop()
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
phase = 'pausing'
|
|
103
|
+
timer = setTimeout(step, pause)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
timer = setTimeout(step, speed)
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (phase === 'pausing') {
|
|
111
|
+
phase = 'deleting'
|
|
112
|
+
timer = setTimeout(step, speed)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// deleting
|
|
117
|
+
charIndex -= 1
|
|
118
|
+
setDisplay(text.slice(0, Math.max(0, charIndex)))
|
|
119
|
+
if (charIndex <= 0) {
|
|
120
|
+
stringIndex = (stringIndex + 1) % list.length
|
|
121
|
+
phase = 'typing'
|
|
122
|
+
}
|
|
123
|
+
timer = setTimeout(step, speed)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
setDisplay('')
|
|
127
|
+
timer = setTimeout(step, speed)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
onMount(run)
|
|
131
|
+
onCleanup(stop)
|
|
132
|
+
|
|
133
|
+
const showCaret = (): boolean => (props.caret ?? true) && !motionReduced()
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<span class={cn('font-mono', props.class)}>
|
|
137
|
+
{display()}
|
|
138
|
+
{showCaret() && (
|
|
139
|
+
<span
|
|
140
|
+
aria-hidden="true"
|
|
141
|
+
class="inline-block"
|
|
142
|
+
style={{ animation: 'a4-caret-blink 1s step-end infinite' }}
|
|
143
|
+
>
|
|
144
|
+
|
|
|
145
|
+
</span>
|
|
146
|
+
)}
|
|
147
|
+
</span>
|
|
148
|
+
)
|
|
149
|
+
}
|