auto-loading-skeleton 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Your Name
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,242 @@
1
+ # auto-loading-skeleton ðŸĶī
2
+
3
+ > Automatically generate loading skeleton UIs from your existing React components — no manual skeleton screens needed.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/auto-loading-skeleton.svg)](https://www.npmjs.com/package/auto-loading-skeleton)
6
+ [![license](https://img.shields.io/npm/l/auto-loading-skeleton.svg)](LICENSE)
7
+
8
+ ---
9
+
10
+ ## The Problem
11
+
12
+ Every React developer has written this pattern manually:
13
+
14
+ ```jsx
15
+ // ❌ The old way — maintain TWO versions of every component
16
+ {loading ? <SkeletonCard /> : <ProductCard product={data} />}
17
+ ```
18
+
19
+ This means duplicating structure, breaking skeletons every time the real component changes, and wasting hours on boilerplate.
20
+
21
+ ---
22
+
23
+ ## The Solution
24
+
25
+ ```jsx
26
+ // ✅ The new way — one wrapper, zero effort
27
+ import { AutoSkeleton } from 'auto-loading-skeleton';
28
+
29
+ <AutoSkeleton loading={loading}>
30
+ <ProductCard product={data} />
31
+ </AutoSkeleton>
32
+ ```
33
+
34
+ `AutoSkeleton` analyzes your component tree and automatically renders matching skeleton placeholders — preserving layout, spacing, and proportions.
35
+
36
+ ---
37
+
38
+ ## Installation
39
+
40
+ ```bash
41
+ npm install auto-loading-skeleton
42
+ # or
43
+ yarn add auto-loading-skeleton
44
+ ```
45
+
46
+ **Peer dependencies:** React >= 16.8
47
+
48
+ ---
49
+
50
+ ## Quick Start
51
+
52
+ ```jsx
53
+ import React, { useState, useEffect } from 'react';
54
+ import { AutoSkeleton } from 'auto-loading-skeleton';
55
+
56
+ function App() {
57
+ const [loading, setLoading] = useState(true);
58
+ const [product, setProduct] = useState(null);
59
+
60
+ useEffect(() => {
61
+ fetchProduct().then(data => {
62
+ setProduct(data);
63
+ setLoading(false);
64
+ });
65
+ }, []);
66
+
67
+ return (
68
+ <AutoSkeleton loading={loading}>
69
+ <ProductCard product={product} />
70
+ </AutoSkeleton>
71
+ );
72
+ }
73
+ ```
74
+
75
+ ---
76
+
77
+ ## API Reference
78
+
79
+ ### `<AutoSkeleton>`
80
+
81
+ | Prop | Type | Default | Description |
82
+ |-------------|-----------|--------------|------------------------------------------------------|
83
+ | `loading` | `boolean` | **required** | Show skeleton when true, real content when false |
84
+ | `animation` | `string` | `'shimmer'` | `'shimmer'` / `'pulse'` / `'wave'` / `'none'` |
85
+ | `theme` | `object` | `{}` | CSS custom property overrides (see Theming) |
86
+ | `count` | `number` | `1` | Repeat the skeleton N times (great for lists) |
87
+ | `className` | `string` | `''` | Extra CSS class on the wrapper |
88
+ | `style` | `object` | `{}` | Extra inline styles on the wrapper |
89
+
90
+ ```jsx
91
+ <AutoSkeleton loading={isLoading} animation="wave" count={3}
92
+ theme={{ baseColor: '#f0f0f0', duration: '1.2s' }}>
93
+ <ArticleCard article={article} />
94
+ </AutoSkeleton>
95
+ ```
96
+
97
+ ---
98
+
99
+ ### `<SkeletonBlock>`
100
+
101
+ A single configurable skeleton rectangle, circle, or pill.
102
+
103
+ ```jsx
104
+ import { SkeletonBlock } from 'auto-loading-skeleton';
105
+
106
+ <SkeletonBlock width="200px" height="20px" />
107
+ <SkeletonBlock width="48px" height="48px" shape="circle" />
108
+ <SkeletonBlock width="120px" height="36px" shape="pill" animation="pulse" />
109
+ ```
110
+
111
+ ---
112
+
113
+ ### `<SkeletonText>`
114
+
115
+ One or more text-line placeholders.
116
+
117
+ ```jsx
118
+ import { SkeletonText } from 'auto-loading-skeleton';
119
+ <SkeletonText lines={4} lastLineWidth="50%" />
120
+ ```
121
+
122
+ ---
123
+
124
+ ### `<SkeletonAvatar>`
125
+
126
+ A circular avatar placeholder.
127
+
128
+ ```jsx
129
+ import { SkeletonAvatar } from 'auto-loading-skeleton';
130
+ <SkeletonAvatar size="56px" animation="pulse" />
131
+ ```
132
+
133
+ ---
134
+
135
+ ### `useSkeleton` hook
136
+
137
+ ```jsx
138
+ const { loading, setLoading, skeletonProps } = useSkeleton(true);
139
+
140
+ <AutoSkeleton {...skeletonProps} animation="wave">
141
+ <ProfileCard />
142
+ </AutoSkeleton>
143
+ ```
144
+
145
+ ---
146
+
147
+ ### `withSkeleton` HOC
148
+
149
+ ```jsx
150
+ const SkeletonProductCard = withSkeleton(ProductCard, { animation: 'shimmer' });
151
+
152
+ // Just pass a `loading` prop alongside your component's own props
153
+ <SkeletonProductCard loading={isLoading} product={data} />
154
+ ```
155
+
156
+ ---
157
+
158
+ ## Animations
159
+
160
+ | Value | Description |
161
+ |-----------|-------------------------------------|
162
+ | `shimmer` | Left-to-right light sweep (default) |
163
+ | `pulse` | Gentle fade in / fade out |
164
+ | `wave` | Soft ripple wave effect |
165
+ | `none` | Static blocks, no animation |
166
+
167
+ ---
168
+
169
+ ## Theming
170
+
171
+ ```jsx
172
+ <AutoSkeleton loading={loading} theme={{
173
+ baseColor: '#dde3ea',
174
+ shimmerColor: 'rgba(255,255,255,0.6)',
175
+ duration: '1.2s',
176
+ borderRadius: '6px',
177
+ }}>
178
+ <MyComponent />
179
+ </AutoSkeleton>
180
+ ```
181
+
182
+ Or globally via CSS custom properties:
183
+
184
+ ```css
185
+ :root {
186
+ --ask-base-color: #dde3ea;
187
+ --ask-shimmer-color: rgba(255, 255, 255, 0.6);
188
+ --ask-duration: 1.2s;
189
+ --ask-border-radius: 6px;
190
+ }
191
+ ```
192
+
193
+ Dark mode is handled automatically via `@media (prefers-color-scheme: dark)`.
194
+
195
+ ---
196
+
197
+ ## How It Works
198
+
199
+ 1. **Analyze** — Traverses the React element tree when `loading` is `true`
200
+ 2. **Classify** — Tags each node as `text`, `image`, `avatar`, `button`, `input`, `icon`, or `container`
201
+ 3. **Render** — Converts each node into a proportional skeleton block
202
+ 4. **Animate** — Injects CSS animations once into `<head>`
203
+
204
+ ### Element Detection
205
+
206
+ | Element / Pattern | Detected As |
207
+ |---------------------------------------|---------------|
208
+ | `<img>` | IMAGE |
209
+ | `<img className="avatar">` | AVATAR |
210
+ | `<button>`, `role="button"` | BUTTON |
211
+ | `<input>`, `<textarea>`, `<select>` | INPUT |
212
+ | `<svg>`, `.icon-*` | ICON |
213
+ | `<h1>`–`<h6>` with text | TEXT (heading)|
214
+ | `<p>`, `<span>` with text | TEXT |
215
+ | Any element with children | CONTAINER |
216
+
217
+ ---
218
+
219
+ ## List Skeletons
220
+
221
+ ```jsx
222
+ <AutoSkeleton loading={loading} count={5}>
223
+ <ProductCard product={sampleProduct} />
224
+ </AutoSkeleton>
225
+ ```
226
+
227
+ ---
228
+
229
+ ## Low-Level API
230
+
231
+ ```js
232
+ import { analyzeElement, renderNode, injectStyles } from 'auto-loading-skeleton';
233
+
234
+ const tree = analyzeElement(<MyComponent />);
235
+ const skeletonEl = renderNode(tree, { animation: 'shimmer' });
236
+ ```
237
+
238
+ ---
239
+
240
+ ## License
241
+
242
+ MIT ÂĐ Your Name
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "auto-loading-skeleton",
3
+ "version": "1.0.0",
4
+ "description": "Automatically generate loading skeleton UIs from your existing React components \u2014 no manual skeleton screens needed.",
5
+ "main": "src/index.js",
6
+ "types": "src/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./src/index.js",
10
+ "types": "./src/index.d.ts"
11
+ }
12
+ },
13
+ "files": [
14
+ "src",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "scripts": {
19
+ "test": "echo \"Run tests with your preferred test runner\""
20
+ },
21
+ "keywords": [
22
+ "react",
23
+ "skeleton",
24
+ "loading",
25
+ "placeholder",
26
+ "ui",
27
+ "shimmer",
28
+ "auto-skeleton",
29
+ "loading-skeleton",
30
+ "skeleton-screen",
31
+ "pulse",
32
+ "wave"
33
+ ],
34
+ "author": "Your Name <you@example.com>",
35
+ "license": "MIT",
36
+ "peerDependencies": {
37
+ "react": ">=16.8.0",
38
+ "react-dom": ">=16.8.0"
39
+ },
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/yourusername/auto-loading-skeleton.git"
43
+ },
44
+ "bugs": {
45
+ "url": "https://github.com/yourusername/auto-loading-skeleton/issues"
46
+ },
47
+ "homepage": "https://github.com/yourusername/auto-loading-skeleton#readme"
48
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * AutoSkeleton.jsx
3
+ * Main wrapper component.
4
+ *
5
+ * Usage:
6
+ * <AutoSkeleton loading={isLoading}>
7
+ * <ProductCard />
8
+ * </AutoSkeleton>
9
+ */
10
+
11
+ import React, { useEffect, useMemo } from 'react';
12
+ import { analyzeTree } from './analyzer.js';
13
+ import { renderSkeletonNodes } from './renderer.js';
14
+ import { injectStyles } from './styles.js';
15
+
16
+ /**
17
+ * @typedef {Object} AutoSkeletonProps
18
+ * @property {boolean} loading - Show skeleton when true
19
+ * @property {React.ReactNode} children - The real UI component(s)
20
+ * @property {'shimmer'|'pulse'|'wave'|'none'} [animation='shimmer']
21
+ * @property {number} [repeat=1] - Repeat skeleton N times (e.g. list items)
22
+ * @property {object} [theme] - { baseColor, highlightColor }
23
+ * @property {React.CSSProperties} [style] - Wrapper style
24
+ * @property {string} [className] - Wrapper className
25
+ * @property {string} [ariaLabel] - aria-label for the skeleton wrapper
26
+ */
27
+
28
+ export function AutoSkeleton({
29
+ loading = false,
30
+ children,
31
+ animation = 'shimmer',
32
+ repeat = 1,
33
+ theme = {},
34
+ style,
35
+ className,
36
+ ariaLabel = 'Loadingâ€Ķ',
37
+ }) {
38
+ // Inject global CSS once
39
+ useEffect(() => { injectStyles(); }, []);
40
+
41
+ // Analyse the child tree once (memoised — re-runs only when children change)
42
+ const skeletonNodes = useMemo(() => {
43
+ if (!loading) return null;
44
+ const childArray = React.Children.toArray(children);
45
+ return childArray.flatMap(child => analyzeTree(child));
46
+ }, [loading, children]);
47
+
48
+ if (!loading) return children;
49
+
50
+ const options = { animation, theme };
51
+ const units = Array.from({ length: repeat }, (_, i) =>
52
+ React.createElement(
53
+ 'div',
54
+ { key: i, style: i > 0 ? { marginTop: 16 } : undefined },
55
+ renderSkeletonNodes(skeletonNodes, options)
56
+ )
57
+ );
58
+
59
+ return React.createElement(
60
+ 'div',
61
+ {
62
+ role: 'status',
63
+ 'aria-busy': true,
64
+ 'aria-label': ariaLabel,
65
+ style,
66
+ className,
67
+ },
68
+ ...units
69
+ );
70
+ }
71
+
72
+ export default AutoSkeleton;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * SkeletonItem.jsx
3
+ * Low-level primitive for manual skeleton building if needed.
4
+ *
5
+ * Usage:
6
+ * <SkeletonItem type="text" lines={2} />
7
+ * <SkeletonItem type="avatar" size={48} />
8
+ * <SkeletonItem type="image" width="100%" height={200} />
9
+ */
10
+
11
+ import React, { useEffect } from 'react';
12
+ import { buildClassName, injectStyles } from './styles.js';
13
+
14
+ export function SkeletonItem({
15
+ type = 'text',
16
+ lines = 1,
17
+ width,
18
+ height,
19
+ size,
20
+ animation = 'shimmer',
21
+ theme = {},
22
+ style: extraStyle = {},
23
+ }) {
24
+ useEffect(() => { injectStyles(); }, []);
25
+
26
+ const cssVars = {
27
+ ...(theme.baseColor && { '--skeleton-base-color': theme.baseColor }),
28
+ ...(theme.highlightColor && { '--skeleton-highlight-color': theme.highlightColor }),
29
+ ...extraStyle,
30
+ };
31
+
32
+ switch (type) {
33
+ case 'text': {
34
+ const cls = buildClassName(animation, 'auto-skeleton-text-line');
35
+ if (lines === 1) {
36
+ return React.createElement('div', {
37
+ className: cls,
38
+ style: { width: width || '80%', ...cssVars },
39
+ 'aria-hidden': true,
40
+ });
41
+ }
42
+ const lineEls = Array.from({ length: lines }, (_, i) =>
43
+ React.createElement('div', {
44
+ key: i,
45
+ className: cls,
46
+ style: { width: i === lines - 1 ? '65%' : '100%', ...cssVars },
47
+ 'aria-hidden': true,
48
+ })
49
+ );
50
+ return React.createElement('div', {
51
+ className: 'auto-skeleton-text-block',
52
+ style: cssVars,
53
+ 'aria-hidden': true,
54
+ }, ...lineEls);
55
+ }
56
+
57
+ case 'avatar': {
58
+ const px = size ? `${size}px` : (width || '40px');
59
+ return React.createElement('div', {
60
+ className: buildClassName(animation, 'auto-skeleton-avatar-block'),
61
+ style: { width: px, height: size ? `${size}px` : (height || px), ...cssVars },
62
+ 'aria-hidden': true,
63
+ });
64
+ }
65
+
66
+ case 'image': {
67
+ return React.createElement('div', {
68
+ className: buildClassName(animation, 'auto-skeleton-image-block'),
69
+ style: {
70
+ width: width || '100%',
71
+ ...(height ? { paddingTop: 0, height } : {}),
72
+ ...cssVars,
73
+ },
74
+ 'aria-hidden': true,
75
+ });
76
+ }
77
+
78
+ case 'button': {
79
+ return React.createElement('div', {
80
+ className: buildClassName(animation, 'auto-skeleton-button-block'),
81
+ style: { width: width || 100, ...cssVars },
82
+ 'aria-hidden': true,
83
+ });
84
+ }
85
+
86
+ case 'input': {
87
+ return React.createElement('div', {
88
+ className: 'auto-skeleton-input-block',
89
+ style: cssVars,
90
+ 'aria-hidden': true,
91
+ });
92
+ }
93
+
94
+ default:
95
+ return null;
96
+ }
97
+ }
98
+
99
+ export default SkeletonItem;
@@ -0,0 +1,113 @@
1
+ const ELEMENT_TYPES = {
2
+ TEXT: 'text',
3
+ IMAGE: 'image',
4
+ AVATAR: 'avatar',
5
+ BUTTON: 'button',
6
+ INPUT: 'input',
7
+ CONTAINER: 'container',
8
+ ICON: 'icon',
9
+ };
10
+
11
+ function inferType(element) {
12
+ if (!element || typeof element !== 'object') return ELEMENT_TYPES.TEXT;
13
+ var type = element.type;
14
+ var props = element.props || {};
15
+ var tag = typeof type === 'string' ? type.toLowerCase() : '';
16
+ var cls = (props.className || '').toLowerCase();
17
+ var alt = (props.alt || '').toLowerCase();
18
+ var role = (props.role || '').toLowerCase();
19
+
20
+ if (tag === 'img') {
21
+ var isAvatar = cls.includes('avatar') || cls.includes('profile') || alt.includes('avatar');
22
+ return isAvatar ? ELEMENT_TYPES.AVATAR : ELEMENT_TYPES.IMAGE;
23
+ }
24
+ if (tag === 'button' || role === 'button') return ELEMENT_TYPES.BUTTON;
25
+ if (tag === 'input' || tag === 'textarea' || tag === 'select') return ELEMENT_TYPES.INPUT;
26
+ if (tag === 'svg' || cls.includes('icon')) return ELEMENT_TYPES.ICON;
27
+ if (cls.includes('avatar') || cls.includes('profile-pic')) return ELEMENT_TYPES.AVATAR;
28
+ return ELEMENT_TYPES.CONTAINER;
29
+ }
30
+
31
+ function isTextLeaf(element) {
32
+ if (!element || typeof element !== 'object') return false;
33
+ var children = element.props && element.props.children;
34
+ if (typeof children === 'string') return true;
35
+ if (typeof children === 'number') return true;
36
+ if (Array.isArray(children)) {
37
+ return children.every(function(c) { return typeof c === 'string' || typeof c === 'number'; });
38
+ }
39
+ return false;
40
+ }
41
+
42
+ function extractStyleHints(element) {
43
+ var props = (element && element.props) || {};
44
+ var style = props.style || {};
45
+ return {
46
+ width: style.width,
47
+ height: style.height,
48
+ borderRadius: style.borderRadius,
49
+ display: style.display,
50
+ flexDirection: style.flexDirection,
51
+ alignItems: style.alignItems,
52
+ gap: style.gap,
53
+ padding: style.padding,
54
+ margin: style.margin,
55
+ };
56
+ }
57
+
58
+ function analyzeElement(element, depth) {
59
+ depth = depth || 0;
60
+ if (element === null || element === undefined) return null;
61
+ if (typeof element === 'string' || typeof element === 'number') {
62
+ return { nodeType: ELEMENT_TYPES.TEXT, content: String(element), depth: depth };
63
+ }
64
+ if (typeof element === 'boolean') return null;
65
+
66
+ if (Array.isArray(element)) {
67
+ return element.map(function(el) { return analyzeElement(el, depth); }).filter(Boolean);
68
+ }
69
+
70
+ var type = element.type;
71
+ var props = element.props || {};
72
+ var styleHints = extractStyleHints(element);
73
+
74
+ // Structural elements take priority over text-leaf detection
75
+ var earlyType = inferType(element);
76
+ if (earlyType === ELEMENT_TYPES.BUTTON || earlyType === ELEMENT_TYPES.INPUT) {
77
+ var lbl = earlyType === ELEMENT_TYPES.BUTTON && typeof props.children === 'string' ? props.children : '';
78
+ return { nodeType: earlyType, label: lbl, styleHints: styleHints, depth: depth };
79
+ }
80
+
81
+ if (isTextLeaf(element)) {
82
+ var tag = typeof type === 'string' ? type.toLowerCase() : '';
83
+ var isHeading = /^h[1-6]$/.test(tag);
84
+ return { nodeType: ELEMENT_TYPES.TEXT, isHeading: isHeading, tag: tag, content: String(props.children), styleHints: styleHints, depth: depth };
85
+ }
86
+
87
+ var nodeType = inferType(element);
88
+
89
+ if (nodeType === ELEMENT_TYPES.IMAGE || nodeType === ELEMENT_TYPES.AVATAR || nodeType === ELEMENT_TYPES.ICON) {
90
+ return { nodeType: nodeType, styleHints: styleHints, depth: depth };
91
+ }
92
+ if (nodeType === ELEMENT_TYPES.BUTTON) {
93
+ return { nodeType: ELEMENT_TYPES.BUTTON, label: typeof props.children === 'string' ? props.children : '', styleHints: styleHints, depth: depth };
94
+ }
95
+ if (nodeType === ELEMENT_TYPES.INPUT) {
96
+ return { nodeType: ELEMENT_TYPES.INPUT, styleHints: styleHints, depth: depth };
97
+ }
98
+
99
+ var rawChildren = props.children;
100
+ var children = [];
101
+ if (rawChildren) {
102
+ var arr = Array.isArray(rawChildren) ? rawChildren : [rawChildren];
103
+ arr.forEach(function(child) {
104
+ var result = analyzeElement(child, depth + 1);
105
+ if (Array.isArray(result)) { result.forEach(function(r) { if (r) children.push(r); }); }
106
+ else if (result) children.push(result);
107
+ });
108
+ }
109
+
110
+ return { nodeType: ELEMENT_TYPES.CONTAINER, tag: typeof type === 'string' ? type : 'div', styleHints: styleHints, children: children, depth: depth };
111
+ }
112
+
113
+ module.exports = { analyzeElement: analyzeElement, ELEMENT_TYPES: ELEMENT_TYPES };
package/src/index.d.ts ADDED
@@ -0,0 +1,51 @@
1
+ import { ReactNode, CSSProperties, FC } from 'react';
2
+
3
+ export type SkeletonAnimation = 'shimmer' | 'pulse' | 'wave' | 'none';
4
+
5
+ export interface SkeletonTheme {
6
+ /** Base skeleton color. Default: #e2e8f0 */
7
+ baseColor?: string;
8
+ /** Highlight color for shimmer/wave. Default: #f8fafc */
9
+ highlightColor?: string;
10
+ }
11
+
12
+ export interface AutoSkeletonProps {
13
+ /** Show skeleton when true, render real children when false */
14
+ loading: boolean;
15
+ children: ReactNode;
16
+ /** Animation style. Default: 'shimmer' */
17
+ animation?: SkeletonAnimation;
18
+ /** Repeat the skeleton pattern N times. Useful for lists. Default: 1 */
19
+ repeat?: number;
20
+ /** Theme overrides */
21
+ theme?: SkeletonTheme;
22
+ style?: CSSProperties;
23
+ className?: string;
24
+ /** Accessible label for the loading region. Default: 'Loadingâ€Ķ' */
25
+ ariaLabel?: string;
26
+ }
27
+
28
+ export type SkeletonItemType = 'text' | 'image' | 'avatar' | 'button' | 'input';
29
+
30
+ export interface SkeletonItemProps {
31
+ type?: SkeletonItemType;
32
+ lines?: number;
33
+ width?: number | string;
34
+ height?: number | string;
35
+ /** For avatar type: shorthand for equal width and height */
36
+ size?: number;
37
+ animation?: SkeletonAnimation;
38
+ theme?: SkeletonTheme;
39
+ style?: CSSProperties;
40
+ }
41
+
42
+ /** Main wrapper component — auto-generates a skeleton from its children */
43
+ export const AutoSkeleton: FC<AutoSkeletonProps>;
44
+
45
+ /** Primitive building block for manually-crafted skeletons */
46
+ export const SkeletonItem: FC<SkeletonItemProps>;
47
+
48
+ /** Inject global CSS (called automatically; export for SSR use) */
49
+ export function injectStyles(): void;
50
+
51
+ export default AutoSkeleton;
package/src/index.js ADDED
@@ -0,0 +1,102 @@
1
+ const React = require('react');
2
+ const { analyzeElement } = require('./analyzer');
3
+ const { renderNode } = require('./renderer');
4
+ const { injectStyles, blockClass, buildThemeVars } = require('./styles');
5
+
6
+ function AutoSkeleton(props) {
7
+ var loading = props.loading;
8
+ var animation = props.animation || 'shimmer';
9
+ var theme = props.theme || {};
10
+ var count = props.count || 1;
11
+ var children = props.children;
12
+ var className = props.className || '';
13
+ var style = props.style || {};
14
+
15
+ React.useEffect(function() { injectStyles(); }, []);
16
+ if (typeof document !== 'undefined') injectStyles();
17
+
18
+ if (!loading) return children;
19
+
20
+ var cssVarStyle = {};
21
+ var varMap = { baseColor: '--ask-base-color', shimmerColor: '--ask-shimmer-color', duration: '--ask-duration', borderRadius: '--ask-border-radius' };
22
+ Object.entries(theme).forEach(function(pair) { if (varMap[pair[0]]) cssVarStyle[varMap[pair[0]]] = pair[1]; });
23
+
24
+ var descriptor = analyzeElement(children);
25
+ var skeletonEl = renderNode(descriptor, { animation: animation });
26
+
27
+ var items = [];
28
+ for (var i = 0; i < Math.max(1, count); i++) {
29
+ items.push(React.createElement('div', { key: i, style: i < count - 1 ? { marginBottom: '16px' } : {} }, skeletonEl));
30
+ }
31
+
32
+ return React.createElement('div', {
33
+ className: 'ask-wrapper' + (className ? ' ' + className : ''),
34
+ style: Object.assign({}, style, cssVarStyle),
35
+ 'aria-busy': 'true',
36
+ 'aria-label': 'Loading\u2026',
37
+ }, items);
38
+ }
39
+
40
+ function SkeletonBlock(props) {
41
+ var width = props.width || '100%';
42
+ var height = props.height || '16px';
43
+ var shape = props.shape;
44
+ var animation = props.animation || 'shimmer';
45
+ var style = props.style || {};
46
+ var className = props.className || '';
47
+ React.useEffect(function() { injectStyles(); }, []);
48
+ if (typeof document !== 'undefined') injectStyles();
49
+ return React.createElement('span', {
50
+ className: blockClass(animation, shape) + (className ? ' ' + className : ''),
51
+ style: Object.assign({ width: width, height: height, display: 'block' }, style),
52
+ 'aria-hidden': 'true',
53
+ });
54
+ }
55
+
56
+ function SkeletonText(props) {
57
+ var lines = props.lines || 3;
58
+ var animation = props.animation || 'shimmer';
59
+ var lastLineWidth = props.lastLineWidth || '60%';
60
+ var lineHeight = props.lineHeight || '1em';
61
+ var gap = props.gap || '8px';
62
+ var style = props.style || {};
63
+ React.useEffect(function() { injectStyles(); }, []);
64
+ if (typeof document !== 'undefined') injectStyles();
65
+ var lineEls = [];
66
+ for (var i = 0; i < lines; i++) {
67
+ lineEls.push(React.createElement('span', { key: i, className: blockClass(animation), style: { display: 'block', width: i === lines - 1 ? lastLineWidth : '100%', height: lineHeight, marginBottom: i < lines - 1 ? gap : 0 }, 'aria-hidden': 'true' }));
68
+ }
69
+ return React.createElement('div', { style: style, 'aria-hidden': 'true' }, lineEls);
70
+ }
71
+
72
+ function SkeletonAvatar(props) {
73
+ var size = props.size || '40px';
74
+ return React.createElement(SkeletonBlock, { width: size, height: size, shape: 'circle', animation: props.animation || 'shimmer', style: props.style || {} });
75
+ }
76
+
77
+ function useSkeleton(initialLoading, options) {
78
+ initialLoading = initialLoading !== undefined ? initialLoading : true;
79
+ options = options || {};
80
+ var state = React.useState(initialLoading);
81
+ var loading = state[0];
82
+ var setLoading = state[1];
83
+ return { loading: loading, setLoading: setLoading, skeletonProps: Object.assign({ loading: loading }, options) };
84
+ }
85
+
86
+ function withSkeleton(WrappedComponent, defaultOptions) {
87
+ defaultOptions = defaultOptions || {};
88
+ var displayName = WrappedComponent.displayName || WrappedComponent.name || 'Component';
89
+ function WithSkeletonWrapper(props) {
90
+ var loading = props.loading !== undefined ? props.loading : false;
91
+ var skeletonAnimation = props.skeletonAnimation;
92
+ var skeletonTheme = props.skeletonTheme;
93
+ var skeletonCount = props.skeletonCount;
94
+ var rest = Object.assign({}, props);
95
+ delete rest.loading; delete rest.skeletonAnimation; delete rest.skeletonTheme; delete rest.skeletonCount;
96
+ return React.createElement(AutoSkeleton, { loading: loading, animation: skeletonAnimation || defaultOptions.animation || 'shimmer', theme: skeletonTheme || defaultOptions.theme || {}, count: skeletonCount || defaultOptions.count || 1 }, React.createElement(WrappedComponent, rest));
97
+ }
98
+ WithSkeletonWrapper.displayName = 'WithSkeleton(' + displayName + ')';
99
+ return WithSkeletonWrapper;
100
+ }
101
+
102
+ module.exports = { AutoSkeleton, SkeletonBlock, SkeletonText, SkeletonAvatar, useSkeleton, withSkeleton, analyzeElement, renderNode, injectStyles };
@@ -0,0 +1,84 @@
1
+ const React = require('react');
2
+ const { ELEMENT_TYPES } = require('./analyzer');
3
+ const { blockClass } = require('./styles');
4
+
5
+ const DEFAULTS = {
6
+ text: { width: '80%', height: '1em' },
7
+ heading: { width: '60%', height: '1.4em' },
8
+ image: { width: '100%', height: '200px' },
9
+ avatar: { width: '40px', height: '40px' },
10
+ button: { width: '100px',height: '36px' },
11
+ input: { width: '100%', height: '38px' },
12
+ icon: { width: '24px', height: '24px' },
13
+ };
14
+
15
+ let _key = 0;
16
+ function nk() { return 'ask-' + (++_key); }
17
+
18
+ function bStyle(def, hints, extra) {
19
+ hints = hints || {};
20
+ extra = extra || {};
21
+ return Object.assign({ width: hints.width || def.width, height: hints.height || def.height, display: 'block', margin: hints.margin || '0' }, extra);
22
+ }
23
+
24
+ function renderNode(node, options) {
25
+ if (!node) return null;
26
+ options = options || {};
27
+ var animation = options.animation || 'shimmer';
28
+ var nodeType = node.nodeType;
29
+ var hints = node.styleHints || {};
30
+
31
+ if (nodeType === ELEMENT_TYPES.TEXT) {
32
+ var isHeading = node.isHeading;
33
+ var def = isHeading ? DEFAULTS.heading : DEFAULTS.text;
34
+ var len = (node.content || '').length;
35
+ var lines = Math.min(Math.max(1, Math.round(len / 40)), 4);
36
+ if (lines <= 1) {
37
+ return React.createElement('span', { key: nk(), className: blockClass(animation), style: bStyle(def, hints, { width: hints.width || (len > 60 ? '95%' : def.width) }), 'aria-hidden': 'true' });
38
+ }
39
+ var lineEls = [];
40
+ for (var i = 0; i < lines; i++) {
41
+ lineEls.push(React.createElement('span', { key: nk(), className: blockClass(animation), style: { display: 'block', width: i === lines-1 ? '65%' : '100%', height: def.height, marginBottom: '6px' }, 'aria-hidden': 'true' }));
42
+ }
43
+ return React.createElement('div', { key: nk() }, lineEls);
44
+ }
45
+
46
+ if (nodeType === ELEMENT_TYPES.IMAGE) {
47
+ return React.createElement('span', { key: nk(), className: blockClass(animation, 'rounded'), style: bStyle(DEFAULTS.image, hints), 'aria-hidden': 'true' });
48
+ }
49
+
50
+ if (nodeType === ELEMENT_TYPES.AVATAR) {
51
+ return React.createElement('span', { key: nk(), className: blockClass(animation, 'circle'), style: bStyle(DEFAULTS.avatar, hints), 'aria-hidden': 'true' });
52
+ }
53
+
54
+ if (nodeType === ELEMENT_TYPES.ICON) {
55
+ return React.createElement('span', { key: nk(), className: blockClass(animation, 'circle'), style: bStyle(DEFAULTS.icon, hints), 'aria-hidden': 'true' });
56
+ }
57
+
58
+ if (nodeType === ELEMENT_TYPES.BUTTON) {
59
+ return React.createElement('span', { key: nk(), className: blockClass(animation, 'pill'), style: bStyle(DEFAULTS.button, hints), 'aria-hidden': 'true' });
60
+ }
61
+
62
+ if (nodeType === ELEMENT_TYPES.INPUT) {
63
+ return React.createElement('span', { key: nk(), className: blockClass(animation, 'rounded'), style: bStyle(DEFAULTS.input, hints), 'aria-hidden': 'true' });
64
+ }
65
+
66
+ if (nodeType === ELEMENT_TYPES.CONTAINER) {
67
+ var children = (node.children || []).map(function(c) { return renderNode(c, options); }).filter(Boolean);
68
+ var tag = node.tag || 'div';
69
+ var cStyle = {};
70
+ if (hints.display) cStyle.display = hints.display;
71
+ if (hints.flexDirection) cStyle.flexDirection = hints.flexDirection;
72
+ if (hints.gap) cStyle.gap = hints.gap;
73
+ if (hints.padding) cStyle.padding = hints.padding;
74
+ if (hints.margin) cStyle.margin = hints.margin;
75
+ if (children.length === 0) {
76
+ return React.createElement('span', { key: nk(), className: blockClass(animation), style: bStyle({ width: '100%', height: '20px' }, hints), 'aria-hidden': 'true' });
77
+ }
78
+ return React.createElement(tag, { key: nk(), style: cStyle, className: 'ask-wrapper' }, children);
79
+ }
80
+
81
+ return null;
82
+ }
83
+
84
+ module.exports = { renderNode };
package/src/styles.js ADDED
@@ -0,0 +1,76 @@
1
+ const STYLE_ID = 'auto-skeleton-styles';
2
+
3
+ const CSS = `
4
+ .ask-block {
5
+ display: inline-block;
6
+ position: relative;
7
+ overflow: hidden;
8
+ background-color: var(--ask-base-color, #e2e8f0);
9
+ border-radius: var(--ask-border-radius, 4px);
10
+ vertical-align: middle;
11
+ }
12
+ .ask-shimmer::after {
13
+ content: '';
14
+ position: absolute;
15
+ inset: 0;
16
+ transform: translateX(-100%);
17
+ background: linear-gradient(90deg, transparent 0%, var(--ask-shimmer-color, rgba(255,255,255,0.55)) 50%, transparent 100%);
18
+ animation: ask-shimmer var(--ask-duration, 1.4s) infinite;
19
+ }
20
+ @keyframes ask-shimmer { 100% { transform: translateX(100%); } }
21
+ .ask-pulse { animation: ask-pulse var(--ask-duration, 1.8s) ease-in-out infinite; }
22
+ @keyframes ask-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.45; } }
23
+ .ask-wave::after {
24
+ content: '';
25
+ position: absolute;
26
+ inset: 0;
27
+ transform: translateX(-100%);
28
+ background: linear-gradient(90deg, transparent 0%, var(--ask-shimmer-color, rgba(255,255,255,0.8)) 50%, transparent 100%);
29
+ animation: ask-wave var(--ask-duration, 1.6s) ease-in-out infinite;
30
+ overflow: hidden;
31
+ }
32
+ @keyframes ask-wave { 0% { transform: translateX(-100%); } 100% { transform: translateX(150%); } }
33
+ @media (prefers-color-scheme: dark) {
34
+ .ask-block { background-color: var(--ask-base-color-dark, #2d3748); }
35
+ }
36
+ .ask-circle { border-radius: 50%; }
37
+ .ask-rounded { border-radius: 8px; }
38
+ .ask-pill { border-radius: 9999px; }
39
+ .ask-wrapper { pointer-events: none; user-select: none; }
40
+ `;
41
+
42
+ let injected = false;
43
+
44
+ function injectStyles() {
45
+ if (injected || typeof document === 'undefined') return;
46
+ if (document.getElementById(STYLE_ID)) { injected = true; return; }
47
+ const style = document.createElement('style');
48
+ style.id = STYLE_ID;
49
+ style.textContent = CSS;
50
+ document.head.appendChild(style);
51
+ injected = true;
52
+ }
53
+
54
+ function blockClass(animation, shape) {
55
+ animation = animation || 'shimmer';
56
+ const parts = ['ask-block'];
57
+ if (animation !== 'none') parts.push('ask-' + animation);
58
+ if (shape) parts.push('ask-' + shape);
59
+ return parts.join(' ');
60
+ }
61
+
62
+ function buildThemeVars(theme) {
63
+ theme = theme || {};
64
+ const map = {
65
+ baseColor: '--ask-base-color',
66
+ shimmerColor: '--ask-shimmer-color',
67
+ duration: '--ask-duration',
68
+ borderRadius: '--ask-border-radius',
69
+ };
70
+ return Object.entries(theme)
71
+ .filter(function(pair) { return map[pair[0]]; })
72
+ .map(function(pair) { return map[pair[0]] + ':' + pair[1]; })
73
+ .join(';');
74
+ }
75
+
76
+ module.exports = { injectStyles, blockClass, buildThemeVars, CSS };