nano-skeleton 0.2.0 → 0.4.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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - feat: RTL support
8
+
9
+ ## 0.3.0
10
+
11
+ ### Minor Changes
12
+
13
+ - feat: add `text` attribute to force text mode for text wrapped in elements
14
+
3
15
  ## 0.2.0
4
16
 
5
17
  ### Minor Changes
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  A tiny, framework-agnostic skeleton loader built on Web Components.
4
4
 
5
+ **[Live demo](https://smashercosmo.github.io/nano-skeleton/)**
6
+
5
7
  Instead of maintaining a separate "loading" version of every component, wrap the
6
8
  pieces of your real UI in `<nano-skeleton>` and flip a single `loading` switch.
7
9
  The layout stays exactly where it is, nothing jumps, and the content is swapped
@@ -101,7 +103,7 @@ all of its skeletons at once.
101
103
  A `<nano-skeleton>` without a provider simply renders its content as usual.
102
104
 
103
105
  `<nano-skeleton>` works in one of two modes, picked automatically from its
104
- content.
106
+ content (text mode can also be forced with the `text` attribute).
105
107
 
106
108
  ### Text mode
107
109
 
@@ -130,6 +132,34 @@ placeholder text first and replace it with real data later:
130
132
  <h2><nano-skeleton>Loading title…</nano-skeleton></h2>
131
133
  ```
132
134
 
135
+ #### Text wrapped in elements
136
+
137
+ Automatic detection only looks at text placed directly inside the skeleton.
138
+ When the text is wrapped in an inline element, like a link or `<strong>`, add
139
+ `text` to force text mode:
140
+
141
+ ```html
142
+ <p>
143
+ <nano-skeleton text>
144
+ <a href="/posts/42">Read the full post</a>
145
+ </nano-skeleton>
146
+ </p>
147
+ ```
148
+
149
+ The placeholder follows the text lines exactly like plain text does. The
150
+ wrapping elements should be inline: a block or `inline-block` child gets a
151
+ single bar for its whole box.
152
+
153
+ Text decorations, such as a link's underline, stay visible while loading, so the
154
+ placeholder still reads as a link. To hide them too, wrap the link's content in
155
+ a `<span>`:
156
+
157
+ ```html
158
+ <nano-skeleton text>
159
+ <a href="/posts/42"><span>Read the full post</span></a>
160
+ </nano-skeleton>
161
+ ```
162
+
133
163
  ### Element mode
134
164
 
135
165
  If the skeleton contains elements, it becomes `display: contents` (it doesn't
@@ -329,6 +359,7 @@ declare module "react" {
329
359
  Skeleton
330
360
  > & {
331
361
  interactive?: boolean;
362
+ text?: boolean;
332
363
  };
333
364
  [PROVIDER_NAME]: DetailedHTMLProps<
334
365
  HTMLAttributes<SkeletonProvider>,
@@ -362,13 +393,14 @@ While loading, the provider is exposed to assistive technology as
362
393
  | Attribute / property | Type | Description |
363
394
  | -------------------- | --------- | ------------------------------------------------------------------------------------------------------------- |
364
395
  | `interactive` | `boolean` | Keeps the content focusable and clickable while loading. See [Interactive skeletons](#interactive-skeletons). |
396
+ | `text` | `boolean` | Forces text mode, e.g. for text inside a link. See [Text wrapped in elements](#text-wrapped-in-elements). |
365
397
 
366
398
  It also exposes these state attributes, which you can use as styling hooks:
367
399
 
368
400
  | Attribute | Set when |
369
401
  | -------------- | ------------------------------------------------------------------------------------- |
370
402
  | `data-loading` | The nearest provider is loading. |
371
- | `data-text` | The skeleton directly contains text (text mode). |
403
+ | `data-text` | The skeleton directly contains text, or `text` is set (text mode). |
372
404
  | `inert` | The nearest provider is loading and `interactive` isn't set. Blocks focus and clicks. |
373
405
 
374
406
  ### Exports
@@ -452,6 +484,12 @@ The default colors use `light-dark()`, so they follow the page's
452
484
  }
453
485
  ```
454
486
 
487
+ ### Right-to-left
488
+
489
+ The shimmer follows the reading direction: in right-to-left content, set with
490
+ `dir="rtl"` on the skeleton or any ancestor, it moves from right to left. A
491
+ custom `--nano-skeleton-animation` with its own keyframes isn't affected.
492
+
455
493
  ### Reduced motion
456
494
 
457
495
  The animation is disabled automatically for users with
@@ -5,7 +5,10 @@ export declare class Skeleton extends HTMLElement {
5
5
  /** Keeps the content focusable and clickable while loading */
6
6
  get interactive(): boolean;
7
7
  set interactive(value: boolean);
8
- attributeChangedCallback(_name: string, oldValue: string | null, newValue: string | null): void;
8
+ /** Forces text mode, even when the text is wrapped in elements */
9
+ get text(): boolean;
10
+ set text(value: boolean);
11
+ attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
9
12
  connectedCallback(): void;
10
13
  disconnectedCallback(): void;
11
14
  }
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{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};
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`,`text`];#e=!1;#t;#n=new MutationObserver(()=>this.#r());get interactive(){return this.hasAttribute(`interactive`)}set interactive(e){this.toggleAttribute(`interactive`,!!e)}get text(){return this.hasAttribute(`text`)}set text(e){this.toggleAttribute(`text`,!!e)}attributeChangedCallback(e,t,n){t!==n&&(e===`text`?this.#r():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=this.text;for(let t of this.childNodes){if(e)break;t instanceof Text&&t.data.trim()!==``&&(e=!0)}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
@@ -1 +1 @@
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"}
1
+ {"version":3,"file":"Skeleton.js","names":["isDocumentOrShadowRoot","node","Skeleton","#update","value","oldValue","newValue","name","#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\", \"text\"];\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 /** Forces text mode, even when the text is wrapped in elements */\n get text() {\n return this.hasAttribute(\"text\");\n }\n\n set text(value: boolean) {\n this.toggleAttribute(\"text\", Boolean(value));\n }\n\n attributeChangedCallback(\n name: string,\n oldValue: string | null,\n newValue: string | null,\n ) {\n if (oldValue === newValue) return;\n if (name === \"text\") this.#update();\n else 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 = this.text;\n for (const node of this.childNodes) {\n if (isText) break;\n if (node instanceof Text && node.data.trim() !== \"\") {\n isText = true;\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,cAAe,MAAM,EAElD,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,CAGA,IAAI,MAAO,CACT,OAAO,KAAK,aAAa,MAAM,CACjC,CAEA,IAAI,KAAK,EAAgB,CACvB,KAAK,gBAAgB,OAAQ,EAAQA,CAAM,CAC7C,CAEA,yBACE,EACA,EACA,EACA,CACIC,IAAaC,IACbC,IAAS,OAAQ,KAAKJ,GAAQ,EAC7B,KAAKK,GAAQ,KAAKC,EAAQ,EACjC,CAEA,mBAAoB,CAClB,IAAMC,EAAO,KAAK,YAAY,EAE1BV,EAAuBU,CAAI,GAC7BC,EAAYD,CAAI,EAGlB,KAAKP,GAAQ,EACb,KAAKK,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,KAAK,KAClB,IAAK,IAAMjB,KAAQ,KAAK,WAAY,CAClC,GAAIiB,EAAQ,MACRjB,aAAgB,MAAQA,EAAK,KAAK,KAAK,IAAM,KAC/C,EAAS,GAEb,CACA,KAAK,gBAAgB,YAAaiB,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/constants.js CHANGED
@@ -44,6 +44,20 @@ const e=`nano-skeleton`,t=`${e}-provider`,{default:n}={default:`:scope {
44
44
  * -webkit-text-fill-color overrides any "color" property that is also set.
45
45
  */
46
46
  -webkit-text-fill-color: transparent;
47
+ /**
48
+ * Text might get visible when selected
49
+ */
50
+ user-select: none;
51
+ }
52
+
53
+ /**
54
+ * Children and grandchildren need to inherit these styles
55
+ * for consistent outlines
56
+ */
57
+ :scope[data-loading][data-text] > *,
58
+ :scope[data-loading][data-text] > * > * {
59
+ border-radius: inherit;
60
+ text-box: inherit;
47
61
  }
48
62
 
49
63
  :scope[data-loading] > :is(video, img):only-child {
@@ -72,12 +86,21 @@ const e=`nano-skeleton`,t=`${e}-provider`,{default:n}={default:`:scope {
72
86
  }
73
87
  }
74
88
 
89
+ /**
90
+ * The shimmer follows the reading direction
91
+ */
92
+ :scope[data-loading][data-text]:dir(rtl),
93
+ :scope[data-loading]:not([data-text]) > :dir(rtl) {
94
+ --nano-skeleton-shimmer-from: 0%;
95
+ --nano-skeleton-shimmer-to: 100%;
96
+ }
97
+
75
98
  @keyframes shimmer {
76
99
  from {
77
- background-position-x: 100%;
100
+ background-position-x: var(--nano-skeleton-shimmer-from, 100%);
78
101
  }
79
102
  to {
80
- background-position-x: 0;
103
+ background-position-x: var(--nano-skeleton-shimmer-to, 0%);
81
104
  }
82
105
  }
83
106
  `},r=Symbol.for(`${e}-context`);export{e as COMPONENT_NAME,n as CSS_CONTENT,t as PROVIDER_NAME,r as SKELETON_CONTEXT};
@@ -1 +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"}
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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyGb,EAIWC,EAAmB,OAAO,IAAI,GAAGH,EAAe,SAAS,E"}
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "nano-skeleton",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "The perfect skeleton component",
5
- "homepage": "https://github.com/smashercosmo/nano-skeleton#readme",
5
+ "homepage": "https://smashercosmo.github.io/nano-skeleton/",
6
6
  "bugs": {
7
7
  "url": "https://github.com/smashercosmo/nano-skeleton/issues"
8
8
  },