@xoong/controls 0.0.35 → 0.0.39

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xoong/controls",
3
- "version": "0.0.35",
3
+ "version": "0.0.39",
4
4
  "description": "xoong form and page controls: tabs, popover, tooltip, menu, combobox, chips, tag input, carousel. Light DOM, accessible, zero dependencies besides @xoong/core.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -12,6 +12,7 @@
12
12
  "./combobox": "./src/combobox.js",
13
13
  "./copy": "./src/copy.js",
14
14
  "./date": "./src/date.js",
15
+ "./image": "./src/image.js",
15
16
  "./password": "./src/password.js",
16
17
  "./slider": "./src/slider.js",
17
18
  "./stepper": "./src/stepper.js",
@@ -47,7 +48,7 @@
47
48
  },
48
49
  "homepage": "https://github.com/fpt-duongtx/xoong#readme",
49
50
  "dependencies": {
50
- "@xoong/core": "0.0.35"
51
+ "@xoong/core": "0.0.39"
51
52
  },
52
53
  "publishConfig": {
53
54
  "access": "public"
package/src/controls.css CHANGED
@@ -8,6 +8,18 @@
8
8
  xo-tabs > [role="tabpanel"] { padding-top: var(--xo-space-3, .75rem); }
9
9
  }
10
10
 
11
+ @layer xoong-controls {
12
+ xo-image { display: block; position: relative; overflow: hidden; max-width: 100%; }
13
+ xo-image > img { display: block; width: 100%; height: 100%; object-fit: var(--xo-image-fit, cover); object-position: var(--xo-image-position, center); }
14
+ xo-image[data-state="loading"] { background: var(--xo-color-subtle, #eef0f3); }
15
+ xo-image[data-state="loading"] > img { opacity: 0; }
16
+ xo-image > img { transition: opacity .2s; }
17
+ @media (prefers-reduced-motion: reduce) { xo-image > img { transition: none; } }
18
+ xo-image[data-state="empty"] > img, xo-image[data-state="error"] > img { display: none; }
19
+ xo-image[data-state="empty"], xo-image[data-state="error"] { background: var(--xo-color-subtle, #eef0f3); min-height: 3rem; }
20
+ xo-image .xo-image-note { position: absolute; inset: 0; display: grid; place-items: center; padding: .5rem; text-align: center; font-size: .85rem; color: var(--xo-color-muted, #5b6470); }
21
+ }
22
+
11
23
  @layer xoong-controls {
12
24
  xo-menu { display: inline-block; }
13
25
  xo-menu [role="menu"] { min-width: 10rem; padding: var(--xo-space-1, .25rem); background: var(--xo-color-surface, #fff); color: inherit; border: 1px solid var(--xo-color-border, #d8dee4); border-radius: var(--xo-radius, 6px); box-shadow: 0 6px 18px rgb(0 0 0 / .15); }
package/src/image.js ADDED
@@ -0,0 +1,84 @@
1
+ // @ts-check
2
+ /**
3
+ * <xo-image> is an image that behaves well on a page: it loads lazily, keeps its space (no layout jump), shows a placeholder while it
4
+ * loads and something useful when it fails.
5
+ *
6
+ * <xo-image src="/img/cover.jpg" alt="The harbour at dusk" width="1200" height="800"></xo-image>
7
+ * <xo-image src="{cover.url}" alt="{cover.alt}" ratio="16/9" fit="cover" srcset="/s.jpg 480w, /l.jpg 1200w" sizes="(min-width: 60rem) 50vw, 100vw"></xo-image>
8
+ * <xo-image src="/hero.jpg" alt="" priority></xo-image>
9
+ *
10
+ * - `alt` is required: text for the image, or `alt=""` for a decorative one. `xoong check` warns when it is missing.
11
+ * - Lazy by default (`loading="lazy"`, `decoding="async"`). `priority` is for the one image above the fold: eager, high fetch priority.
12
+ * - The space is reserved from `width` and `height`, or from `ratio` (`16/9`, `1.5`). `fit` (cover by default, or contain) and `position` set how
13
+ * the picture fills it (the custom properties --xo-image-fit and --xo-image-position, set through the CSS object model: no style attribute).
14
+ * - `srcset` and `sizes` go to the <img> as they are. A `src` that changes (in an <xo-view>, with `src="{path}"`) loads again.
15
+ * - `data-state` on the element: `empty` (no src), `loading` (skeleton), `loaded`, `error`. On an error the `fallback` src is tried once;
16
+ * without one the alt text is shown in the box. `load` and `error` events are re-fired on the element (they do not bubble).
17
+ */
18
+ import { XoElement, define } from '@xoong/core';
19
+
20
+ class XoImage extends XoElement {
21
+ static props = { src: String, srcset: String, sizes: String, alt: String, width: Number, height: Number, ratio: String, fit: String, position: String, priority: Boolean, loading: String, fallback: String };
22
+
23
+ /** @type {HTMLImageElement | null} */ #img = null;
24
+ /** @type {HTMLElement | null} */ #note = null;
25
+ #fellBack = false;
26
+ #warned = false;
27
+
28
+ connected() {
29
+ const img = document.createElement('img');
30
+ img.decoding = 'async';
31
+ img.addEventListener('load', () => { this.#state('loaded'); this.dispatchEvent(new Event('load')); });
32
+ img.addEventListener('error', () => this.#failed());
33
+ this.#img = img;
34
+ this.replaceChildren(img);
35
+ this.#paint();
36
+ }
37
+ attributeChangedCallback(attr, old, value) {
38
+ super.attributeChangedCallback(attr, old, value);
39
+ if (attr === 'src' || attr === 'srcset') this.#fellBack = false;
40
+ this.#paint();
41
+ }
42
+
43
+ #state(/** @type {string} */ s) {
44
+ this.dataset.state = s;
45
+ if (s !== 'error') { this.#note?.remove(); this.#note = null; }
46
+ }
47
+
48
+ #paint() {
49
+ const img = this.#img;
50
+ if (!img) return;
51
+ if (!this.hasAttribute('alt') && !this.#warned) { this.#warned = true; console.warn('xoong: <xo-image> needs an alt attribute (alt="" for a decorative image)', this.getAttribute('src')); }
52
+ img.alt = this.alt ?? '';
53
+ img.loading = this.priority ? 'eager' : this.loading === 'eager' ? 'eager' : 'lazy';
54
+ img.fetchPriority = this.priority ? 'high' : 'auto';
55
+ for (const k of /** @type {const} */ (['width', 'height'])) { if (this[k]) img.setAttribute(k, String(this[k])); else img.removeAttribute(k); }
56
+ if (this.sizes) img.sizes = this.sizes; else img.removeAttribute('sizes');
57
+ const ratio = this.ratio ? this.ratio.replace(/\s/g, '') : this.width && this.height ? `${this.width}/${this.height}` : '';
58
+ const set = (/** @type {string} */ k, /** @type {string | null | undefined} */ v) => (v ? this.style.setProperty(k, v) : this.style.removeProperty(k));
59
+ set('aspect-ratio', ratio);
60
+ set('--xo-image-fit', this.fit);
61
+ set('--xo-image-position', this.position);
62
+ const src = this.#fellBack ? this.fallback : this.src;
63
+ const srcset = this.#fellBack ? '' : this.srcset;
64
+ if (!src && !srcset) { img.removeAttribute('src'); img.removeAttribute('srcset'); this.#state('empty'); return; }
65
+ if (srcset) img.srcset = srcset; else img.removeAttribute('srcset');
66
+ if (src && img.getAttribute('src') !== src) { this.#state('loading'); img.src = src; }
67
+ else if (!src) this.#state('loading');
68
+ if (img.complete && img.naturalWidth > 0) this.#state('loaded'); // from the cache: there may be no load event
69
+ else if (!this.dataset.state || this.dataset.state === 'empty') this.#state('loading');
70
+ }
71
+
72
+ #failed() {
73
+ if (this.fallback && !this.#fellBack) { this.#fellBack = true; this.#paint(); return; }
74
+ this.#state('error');
75
+ if (!this.#note) {
76
+ this.#note = document.createElement('span');
77
+ this.#note.className = 'xo-image-note';
78
+ this.append(this.#note);
79
+ }
80
+ this.#note.textContent = this.alt ?? '';
81
+ this.dispatchEvent(new Event('error'));
82
+ }
83
+ }
84
+ define('xo-image', XoImage);
package/src/index.js CHANGED
@@ -11,3 +11,4 @@ import './slider.js';
11
11
  import './stepper.js';
12
12
  import './password.js';
13
13
  import './copy.js';
14
+ import './image.js';