nano-skeleton 0.0.0-stage → 0.1.1
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/CHANGELOG.md +18 -0
- package/LICENSE +21 -0
- package/README.md +428 -2
- package/dist/ContextRequestEvent.js +2 -0
- package/dist/ContextRequestEvent.js.map +1 -0
- package/dist/Skeleton.d.ts +8 -0
- package/dist/Skeleton.js +2 -0
- package/dist/Skeleton.js.map +1 -0
- package/dist/SkeletonProvider.d.ts +11 -0
- package/dist/SkeletonProvider.js +2 -0
- package/dist/SkeletonProvider.js.map +1 -0
- package/dist/constants.d.ts +5 -0
- package/dist/constants.js +84 -0
- package/dist/constants.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/styles.js +4 -0
- package/dist/styles.js.map +1 -0
- package/package.json +37 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- chore: automate release workflow
|
|
8
|
+
|
|
9
|
+
## 0.1.0
|
|
10
|
+
|
|
11
|
+
Initial release.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- `<nano-skeleton>` element that turns its content into a placeholder of the
|
|
16
|
+
same shape while loading, with no layout shift.
|
|
17
|
+
- `<nano-skeleton-provider>` element with a `loading` attribute/property that
|
|
18
|
+
controls all skeletons inside it.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vladislav Shkodin
|
|
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
CHANGED
|
@@ -1,3 +1,429 @@
|
|
|
1
|
-
#
|
|
1
|
+
# nano-skeleton
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A tiny, framework-agnostic skeleton loader built on Web Components.
|
|
4
|
+
|
|
5
|
+
Instead of maintaining a separate "loading" version of every component, wrap the
|
|
6
|
+
pieces of your real UI in `<nano-skeleton>` and flip a single `loading` switch.
|
|
7
|
+
The layout stays exactly where it is, nothing jumps, and the content is swapped
|
|
8
|
+
for a shimmering placeholder of the same shape.
|
|
9
|
+
|
|
10
|
+
```html
|
|
11
|
+
<nano-skeleton-provider loading>
|
|
12
|
+
<article class="card">
|
|
13
|
+
<nano-skeleton>
|
|
14
|
+
<img src="/cover.png" width="320" height="240" alt="" />
|
|
15
|
+
</nano-skeleton>
|
|
16
|
+
<h2><nano-skeleton>Card title</nano-skeleton></h2>
|
|
17
|
+
<p>
|
|
18
|
+
<nano-skeleton>
|
|
19
|
+
Some description text that wraps over several lines.
|
|
20
|
+
</nano-skeleton>
|
|
21
|
+
</p>
|
|
22
|
+
</article>
|
|
23
|
+
</nano-skeleton-provider>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Features
|
|
27
|
+
|
|
28
|
+
- **Zero layout shift.** The skeleton is drawn over your real markup, so the
|
|
29
|
+
loading state has the exact same size as the loaded one.
|
|
30
|
+
- **One switch per region.** A `<nano-skeleton-provider>` controls every
|
|
31
|
+
`<nano-skeleton>` inside it, no matter how deeply nested.
|
|
32
|
+
- **Text-aware.** Inline text gets a per-line placeholder that follows line
|
|
33
|
+
wrapping, including truncated text with an ellipsis.
|
|
34
|
+
- **Media-aware.** `<img>` and `<video>` keep their box while their content is
|
|
35
|
+
hidden.
|
|
36
|
+
- **Accessible by default.** Loading content is made `inert`, so it can't be
|
|
37
|
+
focused or clicked, and the shimmer respects `prefers-reduced-motion`.
|
|
38
|
+
- **Light and dark mode** out of the box via `light-dark()`.
|
|
39
|
+
- **Works everywhere:** plain HTML, any framework, inside Shadow DOM.
|
|
40
|
+
- **No dependencies**, styles included. No CSS file to import.
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npm install nano-skeleton
|
|
46
|
+
# or
|
|
47
|
+
pnpm add nano-skeleton
|
|
48
|
+
# or
|
|
49
|
+
yarn add nano-skeleton
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Quick start
|
|
53
|
+
|
|
54
|
+
Call `init()` once to register the custom elements:
|
|
55
|
+
|
|
56
|
+
```js
|
|
57
|
+
import { init } from "nano-skeleton";
|
|
58
|
+
|
|
59
|
+
init();
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`init()` is idempotent, so calling it more than once (for example from several
|
|
63
|
+
entry points or during hot reload) is safe.
|
|
64
|
+
|
|
65
|
+
Then use the elements in your markup:
|
|
66
|
+
|
|
67
|
+
```html
|
|
68
|
+
<nano-skeleton-provider id="profile" loading>
|
|
69
|
+
<nano-skeleton>
|
|
70
|
+
<img src="/avatar.png" width="64" height="64" alt="" />
|
|
71
|
+
</nano-skeleton>
|
|
72
|
+
<strong><nano-skeleton>Jane Doe</nano-skeleton></strong>
|
|
73
|
+
</nano-skeleton-provider>
|
|
74
|
+
|
|
75
|
+
<script type="module">
|
|
76
|
+
const profile = document.getElementById("profile");
|
|
77
|
+
|
|
78
|
+
const user = await fetch("/api/me").then((r) => r.json());
|
|
79
|
+
// ...render user data...
|
|
80
|
+
|
|
81
|
+
profile.loading = false; // or profile.removeAttribute("loading")
|
|
82
|
+
</script>
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## How it works
|
|
86
|
+
|
|
87
|
+
There are two elements:
|
|
88
|
+
|
|
89
|
+
| Element | Role |
|
|
90
|
+
| -------------------------- | ------------------------------------------------------------------------ |
|
|
91
|
+
| `<nano-skeleton-provider>` | Owns the `loading` state for everything inside it. |
|
|
92
|
+
| `<nano-skeleton>` | Marks a piece of content that should become a placeholder while loading. |
|
|
93
|
+
|
|
94
|
+
When a `<nano-skeleton>` connects, it asks the nearest provider above it for the
|
|
95
|
+
loading state (using the
|
|
96
|
+
[Context Protocol](https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md))
|
|
97
|
+
and subscribes to changes. Toggling the provider's `loading` attribute updates
|
|
98
|
+
all of its skeletons at once.
|
|
99
|
+
|
|
100
|
+
A `<nano-skeleton>` without a provider simply renders its content as usual.
|
|
101
|
+
|
|
102
|
+
`<nano-skeleton>` works in one of two modes, picked automatically from its
|
|
103
|
+
content.
|
|
104
|
+
|
|
105
|
+
### Text mode
|
|
106
|
+
|
|
107
|
+
If the skeleton directly contains non-empty text, it renders inline and the text
|
|
108
|
+
itself becomes the placeholder. Each line of wrapped text gets its own rounded
|
|
109
|
+
bar, so the skeleton has the same shape as the real paragraph.
|
|
110
|
+
|
|
111
|
+
```html
|
|
112
|
+
<h2><nano-skeleton>Lorem ipsum dolor sit amet</nano-skeleton></h2>
|
|
113
|
+
|
|
114
|
+
<p>
|
|
115
|
+
<nano-skeleton>
|
|
116
|
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit,<br />
|
|
117
|
+
sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
|
|
118
|
+
</nano-skeleton>
|
|
119
|
+
</p>
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Tip: put the skeleton _inside_ the heading or paragraph, not around it, so the
|
|
123
|
+
placeholder matches the text and not the whole block.
|
|
124
|
+
|
|
125
|
+
The mode is re-evaluated whenever the content changes, so you can render
|
|
126
|
+
placeholder text first and replace it with real data later:
|
|
127
|
+
|
|
128
|
+
```html
|
|
129
|
+
<h2><nano-skeleton>Loading title…</nano-skeleton></h2>
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Element mode
|
|
133
|
+
|
|
134
|
+
If the skeleton contains elements, it becomes `display: contents` (it doesn't
|
|
135
|
+
affect layout at all), and each **direct child** is painted as a placeholder
|
|
136
|
+
block while everything inside it is hidden.
|
|
137
|
+
|
|
138
|
+
```html
|
|
139
|
+
<!-- Image: keeps its box, the picture itself is hidden -->
|
|
140
|
+
<nano-skeleton>
|
|
141
|
+
<img src="/cover.png" width="1600" height="1200" alt="" />
|
|
142
|
+
</nano-skeleton>
|
|
143
|
+
|
|
144
|
+
<!-- Video: the poster/frame and controls are hidden -->
|
|
145
|
+
<nano-skeleton>
|
|
146
|
+
<video controls width="400" src="/clip.webm"></video>
|
|
147
|
+
</nano-skeleton>
|
|
148
|
+
|
|
149
|
+
<!-- Any box: a button, a badge, a chart container... -->
|
|
150
|
+
<nano-skeleton>
|
|
151
|
+
<button type="button">Follow</button>
|
|
152
|
+
</nano-skeleton>
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Because the placeholder is just the element's own box, make sure it has a size
|
|
156
|
+
while loading, e.g. `width`/`height` attributes or `aspect-ratio` on images, or
|
|
157
|
+
a fixed size on containers.
|
|
158
|
+
|
|
159
|
+
## Examples
|
|
160
|
+
|
|
161
|
+
### A card
|
|
162
|
+
|
|
163
|
+
```html
|
|
164
|
+
<nano-skeleton-provider loading>
|
|
165
|
+
<article class="card">
|
|
166
|
+
<div class="card__media">
|
|
167
|
+
<nano-skeleton>
|
|
168
|
+
<img src="/preview.png" width="1600" height="1200" alt="" />
|
|
169
|
+
</nano-skeleton>
|
|
170
|
+
</div>
|
|
171
|
+
<div class="card__content">
|
|
172
|
+
<h2 class="card__title">
|
|
173
|
+
<nano-skeleton>Lorem ipsum dolor sit amet</nano-skeleton>
|
|
174
|
+
</h2>
|
|
175
|
+
<p class="card__description">
|
|
176
|
+
<nano-skeleton>
|
|
177
|
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
|
|
178
|
+
eiusmod tempor incididunt ut labore et dolore magna aliqua.
|
|
179
|
+
</nano-skeleton>
|
|
180
|
+
</p>
|
|
181
|
+
</div>
|
|
182
|
+
</article>
|
|
183
|
+
</nano-skeleton-provider>
|
|
184
|
+
|
|
185
|
+
<style>
|
|
186
|
+
.card__media img {
|
|
187
|
+
width: 320px;
|
|
188
|
+
height: auto;
|
|
189
|
+
aspect-ratio: 4 / 3;
|
|
190
|
+
display: block;
|
|
191
|
+
border-radius: 0.5rem;
|
|
192
|
+
}
|
|
193
|
+
</style>
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### A list with independent loading states
|
|
197
|
+
|
|
198
|
+
Each provider controls only its own subtree, so items can finish loading
|
|
199
|
+
independently:
|
|
200
|
+
|
|
201
|
+
```html
|
|
202
|
+
<ul>
|
|
203
|
+
<li>
|
|
204
|
+
<nano-skeleton-provider loading data-id="1">
|
|
205
|
+
<nano-skeleton>Item one</nano-skeleton>
|
|
206
|
+
</nano-skeleton-provider>
|
|
207
|
+
</li>
|
|
208
|
+
<li>
|
|
209
|
+
<nano-skeleton-provider loading data-id="2">
|
|
210
|
+
<nano-skeleton>Item two</nano-skeleton>
|
|
211
|
+
</nano-skeleton-provider>
|
|
212
|
+
</li>
|
|
213
|
+
</ul>
|
|
214
|
+
|
|
215
|
+
<script type="module">
|
|
216
|
+
for (const provider of document.querySelectorAll("nano-skeleton-provider")) {
|
|
217
|
+
loadItem(provider.dataset.id).then(() => (provider.loading = false));
|
|
218
|
+
}
|
|
219
|
+
</script>
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### Nested providers
|
|
223
|
+
|
|
224
|
+
The nearest provider wins. This lets a page load in stages: the outer shell
|
|
225
|
+
finishes first, while an inner section keeps its own state.
|
|
226
|
+
|
|
227
|
+
```html
|
|
228
|
+
<nano-skeleton-provider id="page">
|
|
229
|
+
<h1><nano-skeleton>Dashboard</nano-skeleton></h1>
|
|
230
|
+
|
|
231
|
+
<nano-skeleton-provider id="stats" loading>
|
|
232
|
+
<!-- Controlled by #stats, not by #page -->
|
|
233
|
+
<p><nano-skeleton>Revenue: $12,345</nano-skeleton></p>
|
|
234
|
+
</nano-skeleton-provider>
|
|
235
|
+
</nano-skeleton-provider>
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### React
|
|
239
|
+
|
|
240
|
+
React 19 passes properties to custom elements, so `loading` can be bound
|
|
241
|
+
directly:
|
|
242
|
+
|
|
243
|
+
```tsx
|
|
244
|
+
import { init } from "nano-skeleton";
|
|
245
|
+
import { useQuery } from "@tanstack/react-query";
|
|
246
|
+
|
|
247
|
+
init();
|
|
248
|
+
|
|
249
|
+
export function UserCard({ id }: { id: string }) {
|
|
250
|
+
const { data, isPending } = useQuery({
|
|
251
|
+
queryKey: ["user", id],
|
|
252
|
+
queryFn: () => fetchUser(id),
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
return (
|
|
256
|
+
<nano-skeleton-provider loading={isPending}>
|
|
257
|
+
<article>
|
|
258
|
+
<nano-skeleton>
|
|
259
|
+
<img src={data?.avatar} width={64} height={64} alt="" />
|
|
260
|
+
</nano-skeleton>
|
|
261
|
+
<h2>
|
|
262
|
+
<nano-skeleton>{data?.name ?? "Placeholder name"}</nano-skeleton>
|
|
263
|
+
</h2>
|
|
264
|
+
<p>
|
|
265
|
+
<nano-skeleton>
|
|
266
|
+
{data?.bio ??
|
|
267
|
+
"A placeholder bio that is roughly as long as a real one."}
|
|
268
|
+
</nano-skeleton>
|
|
269
|
+
</p>
|
|
270
|
+
</article>
|
|
271
|
+
</nano-skeleton-provider>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Placeholder text while loading gives the skeleton a realistic size, as it's
|
|
277
|
+
never visible to the user.
|
|
278
|
+
|
|
279
|
+
To get JSX type checking for the custom elements, add a declaration file:
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
// nano-skeleton.d.ts
|
|
283
|
+
import type {
|
|
284
|
+
Skeleton,
|
|
285
|
+
SkeletonProvider,
|
|
286
|
+
COMPONENT_NAME,
|
|
287
|
+
PROVIDER_NAME,
|
|
288
|
+
} from "nano-skeleton";
|
|
289
|
+
import type { DetailedHTMLProps, HTMLAttributes } from "react";
|
|
290
|
+
|
|
291
|
+
declare module "react" {
|
|
292
|
+
namespace JSX {
|
|
293
|
+
interface IntrinsicElements {
|
|
294
|
+
[COMPONENT_NAME]: DetailedHTMLProps<HTMLAttributes<Skeleton>, Skeleton>;
|
|
295
|
+
[PROVIDER_NAME]: DetailedHTMLProps<
|
|
296
|
+
HTMLAttributes<SkeletonProvider>,
|
|
297
|
+
SkeletonProvider
|
|
298
|
+
> & {
|
|
299
|
+
loading?: boolean;
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
## API
|
|
307
|
+
|
|
308
|
+
### `init()`
|
|
309
|
+
|
|
310
|
+
Registers `<nano-skeleton-provider>` and `<nano-skeleton>` with
|
|
311
|
+
`customElements`. Safe to call multiple times.
|
|
312
|
+
|
|
313
|
+
### `<nano-skeleton-provider>`
|
|
314
|
+
|
|
315
|
+
| Attribute / property | Type | Description |
|
|
316
|
+
| -------------------- | --------- | --------------------------------------------------------------------- |
|
|
317
|
+
| `loading` | `boolean` | When present/`true`, all descendant skeletons render as placeholders. |
|
|
318
|
+
|
|
319
|
+
### `<nano-skeleton>`
|
|
320
|
+
|
|
321
|
+
No attributes to set. It exposes these state attributes, which you can use as
|
|
322
|
+
styling hooks:
|
|
323
|
+
|
|
324
|
+
| Attribute | Set when |
|
|
325
|
+
| -------------- | --------------------------------------------------------- |
|
|
326
|
+
| `data-loading` | The nearest provider is loading. |
|
|
327
|
+
| `data-text` | The skeleton directly contains text (text mode). |
|
|
328
|
+
| `inert` | The nearest provider is loading. Blocks focus and clicks. |
|
|
329
|
+
|
|
330
|
+
### Exports
|
|
331
|
+
|
|
332
|
+
```ts
|
|
333
|
+
import {
|
|
334
|
+
init,
|
|
335
|
+
Skeleton, // <nano-skeleton> class
|
|
336
|
+
SkeletonProvider, // <nano-skeleton-provider> class
|
|
337
|
+
COMPONENT_NAME, // "nano-skeleton"
|
|
338
|
+
PROVIDER_NAME, // "nano-skeleton-provider"
|
|
339
|
+
} from "nano-skeleton";
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
## Customization
|
|
343
|
+
|
|
344
|
+
The look is controlled by CSS custom properties:
|
|
345
|
+
|
|
346
|
+
| Property | Default |
|
|
347
|
+
| ------------------------------------ | -------------------------------------- |
|
|
348
|
+
| `--nano-skeleton-background-image` | A light/dark-aware horizontal gradient |
|
|
349
|
+
| `--nano-skeleton-background-size` | `300%` |
|
|
350
|
+
| `--nano-skeleton-animation` | `shimmer 2s infinite ease-in-out` |
|
|
351
|
+
| `--nano-skeleton-text-border-radius` | `0.375rem` (text mode only) |
|
|
352
|
+
|
|
353
|
+
Each `<nano-skeleton>` inherits these from its ancestors, so set them on a
|
|
354
|
+
parent element (or on `:root` to change all skeletons at once), not on the
|
|
355
|
+
`nano-skeleton` element itself:
|
|
356
|
+
|
|
357
|
+
```css
|
|
358
|
+
/* All skeletons on the page */
|
|
359
|
+
:root {
|
|
360
|
+
--nano-skeleton-background-size: 200%;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/* Brand-colored shimmer for skeletons inside .card */
|
|
364
|
+
.card {
|
|
365
|
+
--nano-skeleton-background-image: linear-gradient(
|
|
366
|
+
90deg,
|
|
367
|
+
light-dark(#e0e7ff, #1e1b4b) 33%,
|
|
368
|
+
light-dark(#eef2ff, #312e81),
|
|
369
|
+
light-dark(#e0e7ff, #1e1b4b) 66%
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/* Pulse instead of shimmer */
|
|
374
|
+
@keyframes pulse {
|
|
375
|
+
50% {
|
|
376
|
+
opacity: 0.5;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
.pulse {
|
|
380
|
+
--nano-skeleton-animation: pulse 1.5s infinite ease-in-out;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/* Square text bars */
|
|
384
|
+
.table {
|
|
385
|
+
--nano-skeleton-text-border-radius: 0;
|
|
386
|
+
}
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
Shape (border radius, size) in element mode comes from your own element, so a
|
|
390
|
+
rounded avatar produces a rounded placeholder:
|
|
391
|
+
|
|
392
|
+
```css
|
|
393
|
+
.avatar {
|
|
394
|
+
width: 48px;
|
|
395
|
+
height: 48px;
|
|
396
|
+
border-radius: 50%;
|
|
397
|
+
}
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
### Dark mode
|
|
401
|
+
|
|
402
|
+
The default colors use `light-dark()`, so they follow the page's
|
|
403
|
+
`color-scheme`. Make sure it's set:
|
|
404
|
+
|
|
405
|
+
```css
|
|
406
|
+
:root {
|
|
407
|
+
color-scheme: light dark;
|
|
408
|
+
}
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
### Reduced motion
|
|
412
|
+
|
|
413
|
+
The animation is disabled automatically for users with
|
|
414
|
+
`prefers-reduced-motion: reduce`. They see a static placeholder instead, and a
|
|
415
|
+
custom `--nano-skeleton-animation` is skipped for them as well.
|
|
416
|
+
|
|
417
|
+
## Browser support
|
|
418
|
+
|
|
419
|
+
nano-skeleton targets modern evergreen browsers. It relies on
|
|
420
|
+
[`@scope`](https://developer.mozilla.org/docs/Web/CSS/@scope),
|
|
421
|
+
[constructable stylesheets](https://developer.mozilla.org/docs/Web/API/CSSStyleSheet/CSSStyleSheet)
|
|
422
|
+
and [`light-dark()`](https://developer.mozilla.org/docs/Web/CSS/color_value/light-dark).
|
|
423
|
+
Tighter text bars via `text-box-trim` are applied where supported and ignored
|
|
424
|
+
elsewhere.
|
|
425
|
+
|
|
426
|
+
## License
|
|
427
|
+
|
|
428
|
+
[MIT](https://github.com/smashercosmo/nano-skeleton/blob/main/LICENSE) ©
|
|
429
|
+
Vladislav Shkodin
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ContextRequestEvent.js","names":["ContextRequestEvent","context","callback","subscribe"],"sources":["../src/ContextRequestEvent.ts"],"sourcesContent":["import type { LoadingCallback } from \"#root/types.js\";\n\n/**\n * Follows the community Context Protocol (@see\n * https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md)\n */\nexport class ContextRequestEvent extends Event {\n readonly context: unknown;\n readonly callback: LoadingCallback;\n readonly subscribe: boolean;\n\n constructor(context: unknown, callback: LoadingCallback, subscribe = false) {\n /** Composed, so skeletons inside shadow roots can reach a provider outside */\n super(\"context-request\", { bubbles: true, composed: true });\n this.context = context;\n this.callback = callback;\n this.subscribe = subscribe;\n }\n}\n"],"mappings":"AAMA,IAAaA,EAAb,cAAyC,KAAM,CAC7C,QACA,SACA,UAEA,YAAY,EAAkB,EAA2B,EAAY,GAAO,CAE1E,MAAM,kBAAmB,CAAE,QAAS,GAAM,SAAU,EAAK,CAAC,EAC1D,KAAK,QAAUC,EACf,KAAK,SAAWC,EAChB,KAAK,UAAYC,CACnB,CACF,E"}
|
package/dist/Skeleton.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{SKELETON_CONTEXT as e}from"./constants.js";import{ContextRequestEvent as t}from"./ContextRequestEvent.js";import{adoptStyles as n}from"./styles.js";function r(e){return`adoptedStyleSheets`in e}var i=class extends HTMLElement{#e;#t=new MutationObserver(()=>this.#n());connectedCallback(){let i=this.getRootNode();r(i)&&n(i),this.#n(),this.#r(!1),this.dispatchEvent(new t(e,(e,t)=>{t&&(this.#e=t),this.#r(e)},!0)),this.#t.observe(this,{childList:!0,characterData:!0,subtree:!0})}disconnectedCallback(){this.#e?.(),this.#e=void 0,this.#t.disconnect()}#n(){let e=!1;for(let t of this.childNodes)if(t instanceof Text&&t.data.trim()!==``){e=!0;break}this.toggleAttribute(`data-text`,e)}#r(e){this.toggleAttribute(`inert`,e),this.toggleAttribute(`data-loading`,e)}};export{i as Skeleton};
|
|
2
|
+
//# sourceMappingURL=Skeleton.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Skeleton.js","names":["isDocumentOrShadowRoot","node","Skeleton","#update","root","adoptStyles","#render","ContextRequestEvent","SKELETON_CONTEXT","unsubscribe","#unsubscribe","loading","#observer","isText"],"sources":["../src/Skeleton.ts"],"sourcesContent":["import { SKELETON_CONTEXT } from \"#root/constants.js\";\nimport { ContextRequestEvent } from \"#root/ContextRequestEvent.js\";\nimport { adoptStyles } from \"#root/styles.js\";\nimport { type Unsubscribe } from \"#root/types.js\";\n\nfunction isDocumentOrShadowRoot(\n node: Node | Document | ShadowRoot,\n): node is Document | ShadowRoot {\n return \"adoptedStyleSheets\" in node;\n}\n\nexport class Skeleton extends HTMLElement {\n #unsubscribe?: Unsubscribe | undefined;\n #observer = new MutationObserver(() => this.#update());\n\n connectedCallback() {\n const root = this.getRootNode();\n\n if (isDocumentOrShadowRoot(root)) {\n adoptStyles(root);\n }\n\n this.#update();\n this.#render(false);\n\n this.dispatchEvent(\n new ContextRequestEvent(\n SKELETON_CONTEXT,\n (loading, unsubscribe) => {\n if (unsubscribe) this.#unsubscribe = unsubscribe;\n this.#render(loading);\n },\n true,\n ),\n );\n\n this.#observer.observe(this, {\n childList: true,\n characterData: true,\n subtree: true,\n });\n }\n\n disconnectedCallback() {\n this.#unsubscribe?.();\n this.#unsubscribe = undefined;\n this.#observer.disconnect();\n }\n\n #update() {\n let isText = false;\n for (const node of this.childNodes) {\n if (node instanceof Text && node.data.trim() !== \"\") {\n isText = true;\n break;\n }\n }\n this.toggleAttribute(\"data-text\", isText);\n }\n\n #render(loading: boolean) {\n this.toggleAttribute(\"inert\", loading);\n this.toggleAttribute(\"data-loading\", loading);\n }\n}\n"],"mappings":"A,2JAKA,SAASA,EACP,EAC+B,CAC/B,MAAO,uBAAwBC,CACjC,CAEA,IAAaC,EAAb,cAA8B,WAAY,CACxC,GACA,GAAY,IAAI,qBAAuB,KAAKC,GAAQ,CAAC,EAErD,mBAAoB,CAClB,IAAMC,EAAO,KAAK,YAAY,EAE1BJ,EAAuBI,CAAI,GAC7BC,EAAYD,CAAI,EAGlB,KAAKD,GAAQ,EACb,KAAKG,GAAQ,EAAK,EAElB,KAAK,cACH,IAAIC,EACFC,GACC,EAAS,IAAgB,CACpBC,IAAa,KAAKC,GAAeD,GACrC,KAAKH,GAAQK,CAAO,CACtB,EACA,EACF,CACF,EAEA,KAAKC,GAAU,QAAQ,KAAM,CAC3B,UAAW,GACX,cAAe,GACf,QAAS,EACX,CAAC,CACH,CAEA,sBAAuB,CACrB,KAAKF,KAAe,EACpB,KAAKA,GAAe,IAAA,GACpB,KAAKE,GAAU,WAAW,CAC5B,CAEA,IAAU,CACR,IAAIC,EAAS,GACb,IAAK,IAAMZ,KAAQ,KAAK,WACtB,GAAIA,aAAgB,MAAQA,EAAK,KAAK,KAAK,IAAM,GAAI,CACnD,EAAS,GACT,KACF,CAEF,KAAK,gBAAgB,YAAaY,CAAM,CAC1C,CAEA,GAAQ,EAAkB,CACxB,KAAK,gBAAgB,QAASF,CAAO,EACrC,KAAK,gBAAgB,eAAgBA,CAAO,CAC9C,CACF,E"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
//#region src/SkeletonProvider.d.ts
|
|
2
|
+
export declare class SkeletonProvider extends HTMLElement {
|
|
3
|
+
#private;
|
|
4
|
+
static observedAttributes: string[];
|
|
5
|
+
constructor();
|
|
6
|
+
get loading(): boolean;
|
|
7
|
+
set loading(value: boolean);
|
|
8
|
+
attributeChangedCallback(_name: string, oldValue: string | null, newValue: string | null): void;
|
|
9
|
+
}
|
|
10
|
+
//#endregion
|
|
11
|
+
//# sourceMappingURL=SkeletonProvider.d.ts.map
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{SKELETON_CONTEXT as e}from"./constants.js";var t=class extends HTMLElement{static observedAttributes=[`loading`];#e=/* @__PURE__ */ new Set;constructor(){super(),this.addEventListener(`context-request`,t=>{if(t.context!==e||typeof t.callback!=`function`)return;t.stopPropagation();let{callback:n}=t;t.subscribe?(this.#e.add(n),n(this.loading,()=>this.#e.delete(n))):n(this.loading)})}get loading(){return this.hasAttribute(`loading`)}set loading(e){this.toggleAttribute(`loading`,!!e)}attributeChangedCallback(e,t,n){if(t!==n)for(let e of this.#e)e(this.loading)}};export{t as SkeletonProvider};
|
|
2
|
+
//# sourceMappingURL=SkeletonProvider.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SkeletonProvider.js","names":["SkeletonProvider","event","SKELETON_CONTEXT","#subscribers","callback","value","oldValue","newValue"],"sources":["../src/SkeletonProvider.ts"],"sourcesContent":["import type { ContextRequestEvent } from \"#root/ContextRequestEvent.js\";\nimport type { LoadingCallback } from \"#root/types.js\";\n\nimport { SKELETON_CONTEXT } from \"#root/constants.js\";\n\nexport class SkeletonProvider extends HTMLElement {\n static observedAttributes = [\"loading\"];\n\n #subscribers = new Set<LoadingCallback>();\n\n constructor() {\n super();\n /**\n * Adding listener from the constructor so the provider is ready before any\n * descendant connects and asks for context.\n */\n this.addEventListener(\"context-request\", (event: ContextRequestEvent) => {\n if (\n event.context !== SKELETON_CONTEXT ||\n typeof event.callback !== \"function\"\n )\n return;\n\n /** The nearest provider wins */\n event.stopPropagation();\n\n const { callback } = event;\n\n if (event.subscribe) {\n this.#subscribers.add(callback);\n callback(this.loading, () => this.#subscribers.delete(callback));\n } else {\n callback(this.loading);\n }\n });\n }\n\n get loading() {\n return this.hasAttribute(\"loading\");\n }\n\n set loading(value: boolean) {\n this.toggleAttribute(\"loading\", Boolean(value));\n }\n\n attributeChangedCallback(\n _name: string,\n oldValue: string | null,\n newValue: string | null,\n ) {\n if (oldValue === newValue) return;\n for (const callback of this.#subscribers) callback(this.loading);\n }\n}\n"],"mappings":"A,kDAKA,IAAaA,EAAb,cAAsC,WAAY,CAChD,OAAO,mBAAqB,CAAC,SAAS,EAEtC,mBAAe,IAAI,IAEnB,aAAc,CACZ,MAAM,EAKN,KAAK,iBAAiB,kBAAoB,GAA+B,CACvE,GACEC,EAAM,UAAYC,GAClB,OAAOD,EAAM,UAAa,WAE1B,OAGFA,EAAM,gBAAgB,EAEtB,GAAM,CAAE,YAAaA,EAEjBA,EAAM,WACR,KAAKE,GAAa,IAAIC,CAAQ,EAC9BA,EAAS,KAAK,YAAe,KAAKD,GAAa,OAAOC,CAAQ,CAAC,GAE/DA,EAAS,KAAK,OAAO,CAEzB,CAAC,CACH,CAEA,IAAI,SAAU,CACZ,OAAO,KAAK,aAAa,SAAS,CACpC,CAEA,IAAI,QAAQ,EAAgB,CAC1B,KAAK,gBAAgB,UAAW,EAAQC,CAAM,CAChD,CAEA,yBACE,EACA,EACA,EACA,CACA,GAAIC,IAAaC,EACjB,IAAK,IAAMH,KAAY,KAAKD,GAAcC,EAAS,KAAK,OAAO,CACjE,CACF,E"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
const e=`nano-skeleton`,t=`${e}-provider`,{default:n}={default:`:scope {
|
|
2
|
+
--nano-skeleton-animation: inherit;
|
|
3
|
+
--nano-skeleton-background-image: inherit;
|
|
4
|
+
--nano-skeleton-background-size: inherit;
|
|
5
|
+
--nano-skeleton-text-border-radius: inherit;
|
|
6
|
+
|
|
7
|
+
--nano-skeleton-text-border-radius-default: 0.375rem;
|
|
8
|
+
--nano-skeleton-animation-default: shimmer 2s infinite ease-in-out;
|
|
9
|
+
--nano-skeleton-background-size-default: 300%;
|
|
10
|
+
--nano-skeleton-background-image-default: linear-gradient(
|
|
11
|
+
90deg,
|
|
12
|
+
light-dark(#e9e9e9, #2c2c2c) 33%,
|
|
13
|
+
light-dark(#fff, #393939),
|
|
14
|
+
light-dark(#e9e9e9, #2c2c2c) 66%
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
:scope:not([data-text]) {
|
|
19
|
+
display: contents;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/*
|
|
23
|
+
* Everything inside the root should disappear, but nothing jumps,
|
|
24
|
+
* as layout is intact.
|
|
25
|
+
*/
|
|
26
|
+
:scope[data-loading] > * :is(*, *:before, *:after) {
|
|
27
|
+
visibility: hidden !important;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Skeleton's content is just an inline text
|
|
32
|
+
*/
|
|
33
|
+
:scope[data-loading][data-text] {
|
|
34
|
+
display: inline;
|
|
35
|
+
border-radius: var(
|
|
36
|
+
--nano-skeleton-text-border-radius,
|
|
37
|
+
var(--nano-skeleton-text-border-radius-default)
|
|
38
|
+
);
|
|
39
|
+
box-decoration-break: clone;
|
|
40
|
+
text-box-edge: cap alphabetic;
|
|
41
|
+
text-box-trim: trim-both;
|
|
42
|
+
/**
|
|
43
|
+
* This ensures the ellipsis on truncated text is also hidden.
|
|
44
|
+
* -webkit-text-fill-color overrides any "color" property that is also set.
|
|
45
|
+
*/
|
|
46
|
+
-webkit-text-fill-color: transparent;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
:scope[data-loading] > :is(video, img):only-child {
|
|
50
|
+
content: url("data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7") !important;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
:scope[data-loading][data-text],
|
|
54
|
+
:scope[data-loading]:not([data-text]) > * {
|
|
55
|
+
background-image: var(
|
|
56
|
+
--nano-skeleton-background-image,
|
|
57
|
+
var(--nano-skeleton-background-image-default)
|
|
58
|
+
) !important;
|
|
59
|
+
background-size: var(
|
|
60
|
+
--nano-skeleton-background-size,
|
|
61
|
+
var(--nano-skeleton-background-size-default)
|
|
62
|
+
) !important;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@media not (prefers-reduced-motion: reduce) {
|
|
66
|
+
:scope[data-loading][data-text],
|
|
67
|
+
:scope[data-loading]:not([data-text]) > * {
|
|
68
|
+
animation: var(
|
|
69
|
+
--nano-skeleton-animation,
|
|
70
|
+
var(--nano-skeleton-animation-default)
|
|
71
|
+
) !important;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@keyframes shimmer {
|
|
76
|
+
from {
|
|
77
|
+
background-position-x: 100%;
|
|
78
|
+
}
|
|
79
|
+
to {
|
|
80
|
+
background-position-x: 0;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
`},r=Symbol.for(`${e}-context`);export{e as COMPONENT_NAME,n as CSS_CONTENT,t as PROVIDER_NAME,r as SKELETON_CONTEXT};
|
|
84
|
+
//# sourceMappingURL=constants.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"constants.js","names":["COMPONENT_NAME","PROVIDER_NAME","CSS_CONTENT","SKELETON_CONTEXT"],"sources":["../src/constants.ts"],"sourcesContent":["const isDev = import.meta.env.MODE !== \"production\";\n\nexport const COMPONENT_NAME = \"nano-skeleton\" as const;\n\nexport const PROVIDER_NAME = `${COMPONENT_NAME}-provider` as const;\n\nconst { default: CSS_CONTENT } = isDev\n ? await import(\"./styles.css?inline\")\n : { default: `__CSS_CONTENT__` };\n\nexport { CSS_CONTENT };\n\nexport const SKELETON_CONTEXT = Symbol.for(`${COMPONENT_NAME}-context`);\n"],"mappings":"AAEA,MAAaA,EAAiB,gBAEjBC,EAAgB,GAAGD,EAAe,WAEzC,CAAE,QAASE,GAEb,CAAE,QAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkFb,EAIWC,EAAmB,OAAO,IAAI,GAAGH,EAAe,SAAS,E"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { COMPONENT_NAME, PROVIDER_NAME } from "./constants.js";
|
|
2
|
+
import { Skeleton } from "./Skeleton.js";
|
|
3
|
+
import { SkeletonProvider } from "./SkeletonProvider.js";
|
|
4
|
+
//#region src/index.d.ts
|
|
5
|
+
export declare function init(): void;
|
|
6
|
+
//#endregion
|
|
7
|
+
export { COMPONENT_NAME, PROVIDER_NAME, Skeleton, SkeletonProvider };
|
|
8
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{COMPONENT_NAME as e,PROVIDER_NAME as t}from"./constants.js";import{Skeleton as n}from"./Skeleton.js";import{SkeletonProvider as r}from"./SkeletonProvider.js";function i(){customElements.get(`nano-skeleton-provider`)||customElements.define(t,r),customElements.get(`nano-skeleton`)||customElements.define(e,n)}export{e as COMPONENT_NAME,t as PROVIDER_NAME,n as Skeleton,r as SkeletonProvider,i as init};
|
|
2
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":["init","PROVIDER_NAME","SkeletonProvider","COMPONENT_NAME","Skeleton"],"sources":["../src/index.ts"],"sourcesContent":["import { PROVIDER_NAME, COMPONENT_NAME } from \"#root/constants.js\";\nimport { Skeleton } from \"#root/Skeleton.js\";\nimport { SkeletonProvider } from \"#root/SkeletonProvider.js\";\n\nexport function init() {\n if (!customElements.get(PROVIDER_NAME)) {\n customElements.define(PROVIDER_NAME, SkeletonProvider);\n }\n if (!customElements.get(COMPONENT_NAME)) {\n customElements.define(COMPONENT_NAME, Skeleton);\n }\n}\n\nexport { Skeleton, SkeletonProvider };\nexport { PROVIDER_NAME, COMPONENT_NAME };\n"],"mappings":"A,qKAIA,SAAgBA,GAAO,CAChB,eAAe,IAAA,wBAAiB,GACnC,eAAe,OAAOC,EAAeC,CAAgB,EAElD,eAAe,IAAA,eAAkB,GACpC,eAAe,OAAOC,EAAgBC,CAAQ,CAElD,C"}
|
package/dist/styles.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import{COMPONENT_NAME as e,CSS_CONTENT as t}from"./constants.js";const n=`@scope (${e}) {
|
|
2
|
+
${t}
|
|
3
|
+
}`;let r;function i(e){r||(r=new CSSStyleSheet,r.replaceSync(n)),e.adoptedStyleSheets.includes(r)||(e.adoptedStyleSheets=[...e.adoptedStyleSheets,r])}export{i as adoptStyles};
|
|
4
|
+
//# sourceMappingURL=styles.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"styles.js","names":["css","COMPONENT_NAME","CSS_CONTENT","sheet","adoptStyles","root"],"sources":["../src/styles.ts"],"sourcesContent":["import { CSS_CONTENT, COMPONENT_NAME } from \"#root/constants.js\";\n\n/** It gets replaced with styles.css content during build. */\nconst css = `@scope (${COMPONENT_NAME}) {\n ${CSS_CONTENT}\n}`;\n\nlet sheet: CSSStyleSheet | undefined;\n\nexport function adoptStyles(root: Document | ShadowRoot) {\n if (!sheet) {\n sheet = new CSSStyleSheet();\n sheet.replaceSync(css);\n }\n if (!root.adoptedStyleSheets.includes(sheet)) {\n root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];\n }\n}\n"],"mappings":"A,iEAGA,MAAMA,EAAM,WAAWC,EAAe;IAClCC,EAAY;GAGhB,IAAIC,EAEJ,SAAgBC,EAAY,EAA6B,CAClDD,IACH,EAAQ,IAAI,cACZA,EAAM,YAAYH,CAAG,GAElBK,EAAK,mBAAmB,SAASF,CAAK,IACzC,EAAK,mBAAqB,CAAC,GAAGE,EAAK,mBAAoBF,CAAK,EAEhE,C"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nano-skeleton",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "The perfect skeleton component",
|
|
5
|
+
"homepage": "https://github.com/smashercosmo/nano-skeleton#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/smashercosmo/nano-skeleton/issues"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"author": "Vladislav Shkodin <smashercosmo@gmail.com>",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/smashercosmo/nano-skeleton.git"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"CHANGELOG.md"
|
|
18
|
+
],
|
|
19
|
+
"type": "module",
|
|
20
|
+
"imports": {
|
|
21
|
+
"#root/*": "./src/*"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@internal/tsconfig": "0.0.0",
|
|
25
|
+
"@tsdown/css": "0.23.1",
|
|
26
|
+
"@types/node": "26.6.5",
|
|
27
|
+
"bumpp": "12.3.0",
|
|
28
|
+
"rolldown": "1.2.14",
|
|
29
|
+
"tsdown": "0.23.1"
|
|
30
|
+
},
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"exports": {
|
|
33
|
+
".": "./dist/index.js",
|
|
34
|
+
"./package.json": "./package.json"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsdown",
|
|
38
|
+
"lint:types": "tsc -b"
|
|
39
|
+
}
|
|
6
40
|
}
|