@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.
@@ -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 are visible before the rest collapse behind. @default 3 */
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. Cards behind the front one are offset down and scaled,
19
- * so they peek from the bottom edge; removing the front card promotes the rest
20
- * with a smooth transition (skipped under reduced motion). New cards fade/slide
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] = createStore<StackNotification[]>([])
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.13.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://a4uikit.github.io/a4ui/",
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
- "src",
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.13.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 newest card is
2
- // full-size on top and older cards peek out behind it, scaled down and offset.
3
- // Presentational + controlled the parent owns `items` (newest first) and
4
- // removes a card when `onDismiss` fires; the stack re-flows with a CSS transition.
5
- import { For, type JSX } from 'solid-js'
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 are visible before the rest collapse behind. @default 3 */
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. Cards behind the front one are offset down and scaled,
29
- * so they peek from the bottom edge; removing the front card promotes the rest
30
- * with a smooth transition (skipped under reduced motion). New cards fade/slide
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] = createStore<StackNotification[]>([])
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
- role="log"
48
- aria-live="polite"
49
- class={cn('relative w-full max-w-sm', props.class)}
50
- // reserve room for the front card plus the peek of those behind it
51
- style={{ 'min-height': `${88 + (Math.min(props.items.length, max()) - 1) * 10}px` }}
52
- >
53
- <For each={props.items}>
54
- {(item, i) => {
55
- const hidden = () => i() > max() - 1
56
- return (
57
- <div
58
- ref={(el) => animateIn(el, { y: -8 })}
59
- class={cn(
60
- 'card absolute inset-x-0 top-0 rounded-xl border border-border bg-card p-3 shadow-lg',
61
- !motionReduced() && 'transition-[transform,opacity] duration-300 ease-out',
62
- )}
63
- style={{
64
- transform: `translateY(${i() * 10}px) scale(${1 - i() * 0.05})`,
65
- opacity: hidden() ? 0 : 1 - i() * 0.15,
66
- 'z-index': props.items.length - i(),
67
- 'pointer-events': hidden() ? 'none' : 'auto',
68
- 'transform-origin': 'top center',
69
- }}
70
- >
71
- <div class="flex items-start gap-2.5">
72
- {item.icon && <span class="mt-0.5 shrink-0 text-primary">{item.icon}</span>}
73
- <div class="min-w-0 flex-1">
74
- <p class="truncate text-sm font-medium text-foreground">{item.title}</p>
75
- {item.description && (
76
- <p class="mt-0.5 line-clamp-2 text-sm text-muted-foreground">{item.description}</p>
77
- )}
78
- </div>
79
- <button
80
- type="button"
81
- aria-label={`Dismiss ${item.title}`}
82
- onClick={() => props.onDismiss?.(item.id)}
83
- class="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
84
- >
85
- <svg
86
- viewBox="0 0 24 24"
87
- class="h-3.5 w-3.5"
88
- fill="none"
89
- stroke="currentColor"
90
- stroke-width="2.5"
91
- stroke-linecap="round"
92
- aria-hidden="true"
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
- <path d="M6 6l12 12M18 6L6 18" />
95
- </svg>
96
- </button>
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
- </div>
99
- )
100
- }}
101
- </For>
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
+ }