nano-skeleton 0.0.0-stage → 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to
7
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
+
9
+ ## [0.1.0] - 2026-10-19
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.
19
+
20
+ [0.1.0]: https://github.com/smashercosmo/nano-skeleton/releases/tag/v0.1.0
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
- # Temporary Holding Version
1
+ # nano-skeleton
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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,2 @@
1
+ var e=class extends Event{context;callback;subscribe;constructor(e,t,n=!1){super(`context-request`,{bubbles:!0,composed:!0}),this.context=e,this.callback=t,this.subscribe=n}};export{e as ContextRequestEvent};
2
+ //# sourceMappingURL=ContextRequestEvent.js.map
@@ -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"}
@@ -0,0 +1,8 @@
1
+ //#region src/Skeleton.d.ts
2
+ export declare class Skeleton extends HTMLElement {
3
+ #private;
4
+ connectedCallback(): void;
5
+ disconnectedCallback(): void;
6
+ }
7
+ //#endregion
8
+ //# sourceMappingURL=Skeleton.d.ts.map
@@ -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,5 @@
1
+ //#region src/constants.d.ts
2
+ export declare const COMPONENT_NAME: "nano-skeleton";
3
+ export declare const PROVIDER_NAME: "nano-skeleton-provider";
4
+ //#endregion
5
+ //# sourceMappingURL=constants.d.ts.map
@@ -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"}
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
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
+ "@types/node": "26.6.5",
26
+ "@tsdown/css": "0.23.1",
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
  }