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 +21 -0
- package/README.md +242 -0
- package/package.json +48 -0
- package/src/AutoSkeleton.jsx +72 -0
- package/src/SkeletonItem.jsx +99 -0
- package/src/analyzer.js +113 -0
- package/src/index.d.ts +51 -0
- package/src/index.js +102 -0
- package/src/renderer.js +84 -0
- package/src/styles.js +76 -0
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
|
+
[](https://www.npmjs.com/package/auto-loading-skeleton)
|
|
6
|
+
[](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;
|
package/src/analyzer.js
ADDED
|
@@ -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 };
|
package/src/renderer.js
ADDED
|
@@ -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 };
|