@a4ui/core 0.13.1 → 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.
@@ -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.1",
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.1'
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
@@ -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
+ }
package/src/elements.tsx DELETED
@@ -1,167 +0,0 @@
1
- // Web Components entry (`@a4ui/core/elements`) — a curated subset of A4ui's
2
- // presentational components registered as framework-agnostic custom elements so
3
- // they can be used from React/Next.js, Vue, Svelte, or plain HTML.
4
- //
5
- // Design notes:
6
- // - Each element renders in LIGHT DOM (noShadowDOM) so the global stylesheet
7
- // (`@a4ui/core/elements.css`) and CSS theme variables apply normally — Shadow
8
- // DOM would isolate the Tailwind utility classes the components rely on.
9
- // - Props map to attributes/properties (solid-element coerces by the declared
10
- // default's type: number/boolean/string). Text content is taken from a `label`
11
- // or `text` attribute, falling back to the element's initial text.
12
- // - Only leaf, primitive-prop components are wrapped. Rich/interactive/portalled
13
- // components (Modal, Combobox, DataGrid, …) remain SolidJS-only — see the docs.
14
- //
15
- // This bundle is self-contained (solid-js is bundled in), so non-Solid apps need
16
- // no Solid toolchain: `import '@a4ui/core/elements'` once, then use the tags.
17
- import { customElement, noShadowDOM } from 'solid-element'
18
- import type { JSX } from 'solid-js'
19
-
20
- import { Alert, type AlertTone } from './ui/Alert'
21
- import { Avatar } from './ui/Avatar'
22
- import { Badge, type BadgeTone } from './ui/Badge'
23
- import { Button, type ButtonVariant } from './ui/Button'
24
- import { Clock } from './ui/Clock'
25
- import { Countdown } from './ui/Countdown'
26
- import { Kbd } from './ui/Kbd'
27
- import { Meter } from './ui/Meter'
28
- import { Progress } from './ui/Progress'
29
- import { Rating } from './ui/Rating'
30
- import { RingProgress } from './ui/RingProgress'
31
- import { Separator } from './ui/Separator'
32
- import { Spinner } from './ui/Spinner'
33
- import { Stat, type StatTone } from './ui/Stat'
34
-
35
- /** The initial text an author put between the tags, before Solid renders over it. */
36
- function initialText(element: unknown): string {
37
- return ((element as HTMLElement).textContent ?? '').trim()
38
- }
39
-
40
- /** Register every A4ui custom element. Import for its side effect. */
41
- export function defineA4uiElements(): void {
42
- customElement(
43
- 'a4-button',
44
- { variant: 'primary', disabled: false, type: 'button', label: '' },
45
- (props, { element }): JSX.Element => {
46
- noShadowDOM()
47
- const text = props.label || initialText(element)
48
- return (
49
- <Button
50
- variant={props.variant as ButtonVariant}
51
- disabled={props.disabled}
52
- type={props.type as 'button' | 'submit' | 'reset'}
53
- >
54
- {text}
55
- </Button>
56
- )
57
- },
58
- )
59
-
60
- customElement('a4-badge', { tone: 'neutral', label: '' }, (props, { element }): JSX.Element => {
61
- noShadowDOM()
62
- return <Badge tone={props.tone as BadgeTone}>{props.label || initialText(element)}</Badge>
63
- })
64
-
65
- customElement(
66
- // `heading`, not `title` — the latter is a global HTML attribute and would
67
- // clash with the element's native `title` (tooltip) property.
68
- 'a4-alert',
69
- { tone: 'info', heading: '', text: '' },
70
- (props, { element }): JSX.Element => {
71
- noShadowDOM()
72
- return (
73
- <Alert tone={props.tone as AlertTone} title={props.heading || undefined}>
74
- {props.text || initialText(element)}
75
- </Alert>
76
- )
77
- },
78
- )
79
-
80
- customElement('a4-spinner', { label: 'Loading' }, (props): JSX.Element => {
81
- noShadowDOM()
82
- return <Spinner label={props.label} />
83
- })
84
-
85
- customElement('a4-avatar', { src: '', alt: '', fallback: '' }, (props): JSX.Element => {
86
- noShadowDOM()
87
- return <Avatar src={props.src || undefined} alt={props.alt} fallback={props.fallback || ''} />
88
- })
89
-
90
- customElement('a4-progress', { value: 0, max: 100, label: '' }, (props): JSX.Element => {
91
- noShadowDOM()
92
- return <Progress value={props.value} max={props.max} label={props.label || undefined} />
93
- })
94
-
95
- customElement('a4-meter', { value: 0, max: 100, label: '' }, (props): JSX.Element => {
96
- noShadowDOM()
97
- return <Meter value={props.value} max={props.max} label={props.label || undefined} />
98
- })
99
-
100
- customElement('a4-ring-progress', { value: 0, size: 96, thickness: 8, label: '' }, (props): JSX.Element => {
101
- noShadowDOM()
102
- return (
103
- <RingProgress
104
- value={props.value}
105
- size={props.size}
106
- thickness={props.thickness}
107
- label={props.label || undefined}
108
- />
109
- )
110
- })
111
-
112
- customElement('a4-stat', { label: '', value: 0, tone: 'neutral' }, (props): JSX.Element => {
113
- noShadowDOM()
114
- return <Stat label={props.label} value={props.value} tone={props.tone as StatTone} />
115
- })
116
-
117
- customElement('a4-kbd', { label: '' }, (props, { element }): JSX.Element => {
118
- noShadowDOM()
119
- return <Kbd>{props.label || initialText(element)}</Kbd>
120
- })
121
-
122
- customElement('a4-separator', { orientation: 'horizontal' }, (props): JSX.Element => {
123
- noShadowDOM()
124
- return <Separator orientation={props.orientation as 'horizontal' | 'vertical'} />
125
- })
126
-
127
- customElement('a4-rating', { value: 0, max: 5, readonly: false }, (props, { element }): JSX.Element => {
128
- noShadowDOM()
129
- return (
130
- <Rating
131
- value={props.value}
132
- max={props.max}
133
- readonly={props.readonly}
134
- onChange={(v) =>
135
- (element as unknown as HTMLElement).dispatchEvent(new CustomEvent('change', { detail: v }))
136
- }
137
- />
138
- )
139
- })
140
-
141
- customElement('a4-countdown', { to: '' }, (props): JSX.Element => {
142
- noShadowDOM()
143
- // eslint-disable-next-line solid/reactivity -- read the `to` attribute once to fix the target
144
- const target = props.to ? new Date(props.to) : new Date(Date.now() + 86_400_000)
145
- return <Countdown to={target} />
146
- })
147
-
148
- customElement(
149
- 'a4-clock',
150
- { variant: 'digital', seconds: true, hour12: false, timezone: '', size: 160 },
151
- (props): JSX.Element => {
152
- noShadowDOM()
153
- return (
154
- <Clock
155
- variant={props.variant as 'digital' | 'analog'}
156
- seconds={props.seconds}
157
- hour12={props.hour12}
158
- timeZone={props.timezone || undefined}
159
- size={props.size}
160
- />
161
- )
162
- },
163
- )
164
- }
165
-
166
- // Registering on import is the ergonomic default for `import '@a4ui/core/elements'`.
167
- defineA4uiElements()