nano-skeleton 0.1.1 → 0.2.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 +8 -0
- package/README.md +53 -9
- package/dist/Skeleton.d.ts +5 -0
- package/dist/Skeleton.js +1 -1
- package/dist/Skeleton.js.map +1 -1
- package/dist/SkeletonProvider.js +1 -1
- package/dist/SkeletonProvider.js.map +1 -1
- package/dist/constants.js +5 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
package/README.md
CHANGED
|
@@ -34,7 +34,8 @@ for a shimmering placeholder of the same shape.
|
|
|
34
34
|
- **Media-aware.** `<img>` and `<video>` keep their box while their content is
|
|
35
35
|
hidden.
|
|
36
36
|
- **Accessible by default.** Loading content is made `inert`, so it can't be
|
|
37
|
-
focused or clicked,
|
|
37
|
+
focused or clicked, the provider is marked `aria-busy`, and the shimmer
|
|
38
|
+
respects `prefers-reduced-motion`. Opt out per skeleton with `interactive`.
|
|
38
39
|
- **Light and dark mode** out of the box via `light-dark()`.
|
|
39
40
|
- **Works everywhere:** plain HTML, any framework, inside Shadow DOM.
|
|
40
41
|
- **No dependencies**, styles included. No CSS file to import.
|
|
@@ -156,6 +157,38 @@ Because the placeholder is just the element's own box, make sure it has a size
|
|
|
156
157
|
while loading, e.g. `width`/`height` attributes or `aspect-ratio` on images, or
|
|
157
158
|
a fixed size on containers.
|
|
158
159
|
|
|
160
|
+
The content is hidden with `opacity: 0`, so if an element inside has a
|
|
161
|
+
`transition` on `opacity` (or `transition: all`), it fades in and out when
|
|
162
|
+
`loading` changes instead of switching instantly.
|
|
163
|
+
|
|
164
|
+
### Interactive skeletons
|
|
165
|
+
|
|
166
|
+
By default, a loading skeleton is `inert`: its content can't be focused, clicked
|
|
167
|
+
or reached by assistive technology. That's usually what you want, but sometimes
|
|
168
|
+
the content is usable before the data arrives, e.g. a link whose `href` is
|
|
169
|
+
already known, or a button that doesn't depend on the data.
|
|
170
|
+
|
|
171
|
+
It matters most when content goes back into the loading state (a background
|
|
172
|
+
refetch, pagination): an `inert` element that had focus loses it, and keyboard
|
|
173
|
+
users are sent back to the top of the page.
|
|
174
|
+
|
|
175
|
+
Add `interactive` to keep the content focusable and clickable while loading:
|
|
176
|
+
|
|
177
|
+
```html
|
|
178
|
+
<nano-skeleton-provider loading>
|
|
179
|
+
<nano-skeleton interactive>
|
|
180
|
+
<a href="/posts/42" class="card__link">
|
|
181
|
+
<span>Placeholder post title</span>
|
|
182
|
+
</a>
|
|
183
|
+
</nano-skeleton>
|
|
184
|
+
</nano-skeleton-provider>
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
The placeholder looks the same, but the content stays in the tab order and
|
|
188
|
+
keeps its accessible name. Make sure that name makes sense while loading, as screen reader users
|
|
189
|
+
will hear it. The provider sets `aria-busy="true"` while loading to tell them
|
|
190
|
+
the region is still updating.
|
|
191
|
+
|
|
159
192
|
## Examples
|
|
160
193
|
|
|
161
194
|
### A card
|
|
@@ -291,7 +324,12 @@ import type { DetailedHTMLProps, HTMLAttributes } from "react";
|
|
|
291
324
|
declare module "react" {
|
|
292
325
|
namespace JSX {
|
|
293
326
|
interface IntrinsicElements {
|
|
294
|
-
[COMPONENT_NAME]: DetailedHTMLProps<
|
|
327
|
+
[COMPONENT_NAME]: DetailedHTMLProps<
|
|
328
|
+
HTMLAttributes<Skeleton>,
|
|
329
|
+
Skeleton
|
|
330
|
+
> & {
|
|
331
|
+
interactive?: boolean;
|
|
332
|
+
};
|
|
295
333
|
[PROVIDER_NAME]: DetailedHTMLProps<
|
|
296
334
|
HTMLAttributes<SkeletonProvider>,
|
|
297
335
|
SkeletonProvider
|
|
@@ -316,16 +354,22 @@ Registers `<nano-skeleton-provider>` and `<nano-skeleton>` with
|
|
|
316
354
|
| -------------------- | --------- | --------------------------------------------------------------------- |
|
|
317
355
|
| `loading` | `boolean` | When present/`true`, all descendant skeletons render as placeholders. |
|
|
318
356
|
|
|
357
|
+
While loading, the provider is exposed to assistive technology as
|
|
358
|
+
`aria-busy="true"`. An `aria-busy` attribute you set yourself takes precedence.
|
|
359
|
+
|
|
319
360
|
### `<nano-skeleton>`
|
|
320
361
|
|
|
321
|
-
|
|
322
|
-
|
|
362
|
+
| Attribute / property | Type | Description |
|
|
363
|
+
| -------------------- | --------- | ------------------------------------------------------------------------------------------------------------- |
|
|
364
|
+
| `interactive` | `boolean` | Keeps the content focusable and clickable while loading. See [Interactive skeletons](#interactive-skeletons). |
|
|
365
|
+
|
|
366
|
+
It also exposes these state attributes, which you can use as styling hooks:
|
|
323
367
|
|
|
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. |
|
|
368
|
+
| Attribute | Set when |
|
|
369
|
+
| -------------- | ------------------------------------------------------------------------------------- |
|
|
370
|
+
| `data-loading` | The nearest provider is loading. |
|
|
371
|
+
| `data-text` | The skeleton directly contains text (text mode). |
|
|
372
|
+
| `inert` | The nearest provider is loading and `interactive` isn't set. Blocks focus and clicks. |
|
|
329
373
|
|
|
330
374
|
### Exports
|
|
331
375
|
|
package/dist/Skeleton.d.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
//#region src/Skeleton.d.ts
|
|
2
2
|
export declare class Skeleton extends HTMLElement {
|
|
3
3
|
#private;
|
|
4
|
+
static observedAttributes: string[];
|
|
5
|
+
/** Keeps the content focusable and clickable while loading */
|
|
6
|
+
get interactive(): boolean;
|
|
7
|
+
set interactive(value: boolean);
|
|
8
|
+
attributeChangedCallback(_name: string, oldValue: string | null, newValue: string | null): void;
|
|
4
9
|
connectedCallback(): void;
|
|
5
10
|
disconnectedCallback(): void;
|
|
6
11
|
}
|
package/dist/Skeleton.js
CHANGED
|
@@ -1,2 +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{
|
|
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{static observedAttributes=[`interactive`];#e=!1;#t;#n=new MutationObserver(()=>this.#r());get interactive(){return this.hasAttribute(`interactive`)}set interactive(e){this.toggleAttribute(`interactive`,!!e)}attributeChangedCallback(e,t,n){t!==n&&this.#i(this.#e)}connectedCallback(){let i=this.getRootNode();r(i)&&n(i),this.#r(),this.#i(!1),this.dispatchEvent(new t(e,(e,t)=>{t&&(this.#t=t),this.#i(e)},!0)),this.#n.observe(this,{childList:!0,characterData:!0,subtree:!0})}disconnectedCallback(){this.#t?.(),this.#t=void 0,this.#n.disconnect()}#r(){let e=!1;for(let t of this.childNodes)if(t instanceof Text&&t.data.trim()!==``){e=!0;break}this.toggleAttribute(`data-text`,e)}#i(e){this.#e=e,this.toggleAttribute(`inert`,e&&!this.interactive),this.toggleAttribute(`data-loading`,e)}};export{i as Skeleton};
|
|
2
2
|
//# sourceMappingURL=Skeleton.js.map
|
package/dist/Skeleton.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Skeleton.js","names":["isDocumentOrShadowRoot","node","Skeleton","#update","
|
|
1
|
+
{"version":3,"file":"Skeleton.js","names":["isDocumentOrShadowRoot","node","Skeleton","#update","value","oldValue","newValue","#render","#loading","root","adoptStyles","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 static observedAttributes = [\"interactive\"];\n\n #loading = false;\n #unsubscribe?: Unsubscribe | undefined;\n #observer = new MutationObserver(() => this.#update());\n\n /** Keeps the content focusable and clickable while loading */\n get interactive() {\n return this.hasAttribute(\"interactive\");\n }\n\n set interactive(value: boolean) {\n this.toggleAttribute(\"interactive\", Boolean(value));\n }\n\n attributeChangedCallback(\n _name: string,\n oldValue: string | null,\n newValue: string | null,\n ) {\n if (oldValue === newValue) return;\n this.#render(this.#loading);\n }\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.#loading = loading;\n this.toggleAttribute(\"inert\", loading && !this.interactive);\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,OAAO,mBAAqB,CAAC,aAAa,EAE1C,GAAW,GACX,GACA,GAAY,IAAI,qBAAuB,KAAKC,GAAQ,CAAC,EAGrD,IAAI,aAAc,CAChB,OAAO,KAAK,aAAa,aAAa,CACxC,CAEA,IAAI,YAAY,EAAgB,CAC9B,KAAK,gBAAgB,cAAe,EAAQC,CAAM,CACpD,CAEA,yBACE,EACA,EACA,EACA,CACIC,IAAaC,GACjB,KAAKC,GAAQ,KAAKC,EAAQ,CAC5B,CAEA,mBAAoB,CAClB,IAAMC,EAAO,KAAK,YAAY,EAE1BT,EAAuBS,CAAI,GAC7BC,EAAYD,CAAI,EAGlB,KAAKN,GAAQ,EACb,KAAKI,GAAQ,EAAK,EAElB,KAAK,cACH,IAAII,EACFC,GACC,EAAS,IAAgB,CACpBC,IAAa,KAAKC,GAAeD,GACrC,KAAKN,GAAQQ,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,IAAMhB,KAAQ,KAAK,WACtB,GAAIA,aAAgB,MAAQA,EAAK,KAAK,KAAK,IAAM,GAAI,CACnD,EAAS,GACT,KACF,CAEF,KAAK,gBAAgB,YAAagB,CAAM,CAC1C,CAEA,GAAQ,EAAkB,CACxB,KAAKT,GAAWO,EAChB,KAAK,gBAAgB,QAASA,GAAW,CAAC,KAAK,WAAW,EAC1D,KAAK,gBAAgB,eAAgBA,CAAO,CAC9C,CACF,E"}
|
package/dist/SkeletonProvider.js
CHANGED
|
@@ -1,2 +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};
|
|
1
|
+
import{SKELETON_CONTEXT as e}from"./constants.js";var t=class extends HTMLElement{static observedAttributes=[`loading`];#e=/* @__PURE__ */ new Set;#t=this.attachInternals();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){this.#t.ariaBusy=this.loading?`true`:null;for(let e of this.#e)e(this.loading)}}};export{t as SkeletonProvider};
|
|
2
2
|
//# sourceMappingURL=SkeletonProvider.js.map
|
|
@@ -1 +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,
|
|
1
|
+
{"version":3,"file":"SkeletonProvider.js","names":["SkeletonProvider","event","SKELETON_CONTEXT","#subscribers","callback","value","oldValue","newValue","#internals"],"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 #internals = this.attachInternals();\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 /**\n * Via internals, so an aria-busy attribute set by the user still takes\n * precedence.\n */\n this.#internals.ariaBusy = this.loading ? \"true\" : null;\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,IACnB,GAAa,KAAK,gBAAgB,EAElC,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,EAKjB,MAAKC,GAAW,SAAW,KAAK,QAAU,OAAS,KACnD,IAAK,IAAMJ,KAAY,KAAKD,GAAcC,EAAS,KAAK,OAAO,CADZ,CAErD,CACF,E"}
|
package/dist/constants.js
CHANGED
|
@@ -21,10 +21,11 @@ const e=`nano-skeleton`,t=`${e}-provider`,{default:n}={default:`:scope {
|
|
|
21
21
|
|
|
22
22
|
/*
|
|
23
23
|
* Everything inside the root should disappear, but nothing jumps,
|
|
24
|
-
* as layout is intact.
|
|
24
|
+
* as layout is intact. Hidden visually only: focus and accessibility are
|
|
25
|
+
* handled by inert, so interactive skeletons keep both.
|
|
25
26
|
*/
|
|
26
|
-
:scope[data-loading] > *
|
|
27
|
-
|
|
27
|
+
:scope[data-loading] > * > * {
|
|
28
|
+
opacity: 0 !important;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
/**
|
|
@@ -37,8 +38,7 @@ const e=`nano-skeleton`,t=`${e}-provider`,{default:n}={default:`:scope {
|
|
|
37
38
|
var(--nano-skeleton-text-border-radius-default)
|
|
38
39
|
);
|
|
39
40
|
box-decoration-break: clone;
|
|
40
|
-
text-box
|
|
41
|
-
text-box-trim: trim-both;
|
|
41
|
+
text-box: cap alphabetic;
|
|
42
42
|
/**
|
|
43
43
|
* This ensures the ellipsis on truncated text is also hidden.
|
|
44
44
|
* -webkit-text-fill-color overrides any "color" property that is also set.
|