@xoong/controls 0.0.41 → 0.0.42

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,34 +1,135 @@
1
1
  {
2
2
  "name": "@xoong/controls",
3
- "version": "0.0.41",
3
+ "version": "0.0.42",
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",
7
7
  "exports": {
8
8
  ".": {
9
+ "types": "./src/index.d.ts",
9
10
  "default": "./src/index.js"
10
11
  },
11
- "./accordion": "./src/accordion.js",
12
- "./carousel": "./src/carousel.js",
13
- "./chart": "./src/chart.js",
14
- "./command": "./src/command.js",
15
- "./consent": "./src/consent.js",
16
- "./file": "./src/file.js",
17
- "./popover": "./src/popover.js",
18
- "./combobox": "./src/combobox.js",
19
- "./copy": "./src/copy.js",
20
- "./date": "./src/date.js",
21
- "./image": "./src/image.js",
22
- "./password": "./src/password.js",
23
- "./slider": "./src/slider.js",
24
- "./stepper": "./src/stepper.js",
12
+ "./accordion": {
13
+ "types": "./src/index.d.ts",
14
+ "default": "./src/accordion.js"
15
+ },
16
+ "./avatar": {
17
+ "types": "./src/index.d.ts",
18
+ "default": "./src/avatar.js"
19
+ },
20
+ "./carousel": {
21
+ "types": "./src/index.d.ts",
22
+ "default": "./src/carousel.js"
23
+ },
24
+ "./chart": {
25
+ "types": "./src/index.d.ts",
26
+ "default": "./src/chart.js"
27
+ },
28
+ "./combobox": {
29
+ "types": "./src/index.d.ts",
30
+ "default": "./src/combobox.js"
31
+ },
32
+ "./command": {
33
+ "types": "./src/index.d.ts",
34
+ "default": "./src/command.js"
35
+ },
36
+ "./consent": {
37
+ "types": "./src/index.d.ts",
38
+ "default": "./src/consent.js"
39
+ },
25
40
  "./controls.css": "./src/controls.css",
26
- "./menu": "./src/menu.js",
27
- "./switch": "./src/switch.js",
28
- "./tabs": "./src/tabs.js",
29
- "./theme-toggle": "./src/theme-toggle.js",
30
- "./time": "./src/time.js",
31
- "./tooltip": "./src/tooltip.js"
41
+ "./copy": {
42
+ "types": "./src/index.d.ts",
43
+ "default": "./src/copy.js"
44
+ },
45
+ "./date": {
46
+ "types": "./src/index.d.ts",
47
+ "default": "./src/date.js"
48
+ },
49
+ "./file": {
50
+ "types": "./src/index.d.ts",
51
+ "default": "./src/file.js"
52
+ },
53
+ "./image": {
54
+ "types": "./src/index.d.ts",
55
+ "default": "./src/image.js"
56
+ },
57
+ "./inline-edit": {
58
+ "types": "./src/index.d.ts",
59
+ "default": "./src/inline-edit.js"
60
+ },
61
+ "./menu": {
62
+ "types": "./src/index.d.ts",
63
+ "default": "./src/menu.js"
64
+ },
65
+ "./otp": {
66
+ "types": "./src/index.d.ts",
67
+ "default": "./src/otp.js"
68
+ },
69
+ "./password": {
70
+ "types": "./src/index.d.ts",
71
+ "default": "./src/password.js"
72
+ },
73
+ "./popover": {
74
+ "types": "./src/index.d.ts",
75
+ "default": "./src/popover.js"
76
+ },
77
+ "./progress": {
78
+ "types": "./src/index.d.ts",
79
+ "default": "./src/progress.js"
80
+ },
81
+ "./range-slider": {
82
+ "types": "./src/index.d.ts",
83
+ "default": "./src/range-slider.js"
84
+ },
85
+ "./segmented": {
86
+ "types": "./src/index.d.ts",
87
+ "default": "./src/segmented.js"
88
+ },
89
+ "./slider": {
90
+ "types": "./src/index.d.ts",
91
+ "default": "./src/slider.js"
92
+ },
93
+ "./split-button": {
94
+ "types": "./src/index.d.ts",
95
+ "default": "./src/split-button.js"
96
+ },
97
+ "./stat": {
98
+ "types": "./src/index.d.ts",
99
+ "default": "./src/stat.js"
100
+ },
101
+ "./stepper": {
102
+ "types": "./src/index.d.ts",
103
+ "default": "./src/stepper.js"
104
+ },
105
+ "./steps": {
106
+ "types": "./src/index.d.ts",
107
+ "default": "./src/steps.js"
108
+ },
109
+ "./switch": {
110
+ "types": "./src/index.d.ts",
111
+ "default": "./src/switch.js"
112
+ },
113
+ "./tabs": {
114
+ "types": "./src/index.d.ts",
115
+ "default": "./src/tabs.js"
116
+ },
117
+ "./textarea": {
118
+ "types": "./src/index.d.ts",
119
+ "default": "./src/textarea.js"
120
+ },
121
+ "./theme-toggle": {
122
+ "types": "./src/index.d.ts",
123
+ "default": "./src/theme-toggle.js"
124
+ },
125
+ "./time": {
126
+ "types": "./src/index.d.ts",
127
+ "default": "./src/time.js"
128
+ },
129
+ "./tooltip": {
130
+ "types": "./src/index.d.ts",
131
+ "default": "./src/tooltip.js"
132
+ }
32
133
  },
33
134
  "sideEffects": true,
34
135
  "files": [
@@ -53,9 +154,9 @@
53
154
  "bugs": {
54
155
  "url": "https://github.com/fpt-duongtx/xoong/issues"
55
156
  },
56
- "homepage": "https://github.com/fpt-duongtx/xoong#readme",
157
+ "homepage": "https://fpt-duongtx.github.io/xoong/",
57
158
  "dependencies": {
58
- "@xoong/core": "0.0.41"
159
+ "@xoong/core": "0.0.42"
59
160
  },
60
161
  "publishConfig": {
61
162
  "access": "public"
package/src/avatar.js ADDED
@@ -0,0 +1,142 @@
1
+ // @ts-check
2
+ /**
3
+ * <xo-avatar> is a person (or a team) as a round badge: their picture when there is one, their initials when there is not.
4
+ * <xo-avatars> stacks several of them.
5
+ *
6
+ * <xo-avatar name="Nguyễn Văn An" src="/img/an.jpg" size="lg" status="ok"></xo-avatar>
7
+ * <xo-avatars max="3"><xo-avatar name="An Dao"></xo-avatar> <xo-avatar name="Linh Tran"></xo-avatar> ... </xo-avatars>
8
+ *
9
+ * - The element itself is the `.xo-avatar` badge (it gets the classes `xo-avatar`, `xo-avatar--sm|lg` from `size`, and `xo-avatar--ok|warn|muted` from
10
+ * `status`). Colour is never the only signal: the status is added to the accessible name ("An Dao, online").
11
+ * - Initials: the first letter of the first and of the last word ("Nguyễn Văn An" is "NA"); a single word gives its first two letters. Without a
12
+ * name the user icon is drawn.
13
+ * - `src` is tried first: the image replaces the initials once it loads and the initials stay when it fails (`data-state` is `loaded` or `error`).
14
+ * - The accessible name is the full name: `role=img` with `aria-label`; inside <xo-avatars> it is a `role=listitem` with the same label.
15
+ * - <xo-avatars max="3"> is a `role=list` of its avatars. Only `max` are shown; a last badge "+N" (labelled "N more") stands for the rest.
16
+ * Without `max` all are shown.
17
+ */
18
+ import { XoElement, define, text } from '@xoong/core';
19
+
20
+ /** Initials for a name: first letter of the first and the last word, or the first two letters of a single word. */
21
+ export function initialsOf(/** @type {string} */ name) {
22
+ const words = String(name ?? '').trim().split(/\s+/).map((w) => Array.from(w.replace(/^[^\p{L}\p{N}]+/u, ''))).filter((w) => w.length);
23
+ if (!words.length) return '';
24
+ const letters = words.length === 1 ? words[0].slice(0, 2) : [words[0][0], words[words.length - 1][0]];
25
+ return letters.join('').toLocaleUpperCase();
26
+ }
27
+
28
+ const STATUS = ['ok', 'warn', 'muted'];
29
+ const SIZE = ['sm', 'lg'];
30
+
31
+ class XoAvatar extends XoElement {
32
+ static props = { name: String, src: String, size: String, status: String };
33
+
34
+ /** @type {HTMLImageElement | null} */ #img = null;
35
+ /** @type {HTMLElement | null} */ #initials = null;
36
+
37
+ connected() {
38
+ const initials = document.createElement('span');
39
+ initials.setAttribute('aria-hidden', 'true');
40
+ this.#initials = initials;
41
+ this.replaceChildren(initials);
42
+ this.#img = null;
43
+ this.#loadImage();
44
+ this.#paint();
45
+ }
46
+
47
+ attributeChangedCallback(attr, old, value) {
48
+ super.attributeChangedCallback(attr, old, value);
49
+ if (attr === 'src' && this.#initials) this.#loadImage();
50
+ this.#paint();
51
+ }
52
+
53
+ /** The stack (or the page) calls this when the avatar is moved into or out of an <xo-avatars>. */
54
+ refresh() { this.#paint(); }
55
+
56
+ #loadImage() {
57
+ this.#img?.remove();
58
+ this.#img = null;
59
+ delete this.dataset.state;
60
+ this.#initials?.removeAttribute('hidden');
61
+ if (!this.src) return;
62
+ const img = document.createElement('img');
63
+ img.alt = '';
64
+ img.setAttribute('aria-hidden', 'true');
65
+ img.decoding = 'async';
66
+ // The picture fills the badge: set through the CSS object model (no style attribute, strict CSP) so it works with or without a stylesheet rule.
67
+ for (const [k, v] of [['width', '100%'], ['height', '100%'], ['objectFit', 'cover'], ['borderRadius', '50%'], ['display', 'none']]) /** @type {any} */ (img.style)[k] = v;
68
+ img.addEventListener('load', () => { if (this.#img !== img) return; img.style.display = 'block'; this.#initials?.setAttribute('hidden', ''); this.dataset.state = 'loaded'; });
69
+ img.addEventListener('error', () => { if (this.#img !== img) return; img.remove(); this.#img = null; this.#initials?.removeAttribute('hidden'); this.dataset.state = 'error'; });
70
+ this.#img = img;
71
+ this.append(img);
72
+ img.src = this.src;
73
+ }
74
+
75
+ #paint() {
76
+ const el = this.#initials;
77
+ if (!el) return;
78
+ const name = (this.name ?? '').trim();
79
+ const initials = initialsOf(name);
80
+ if (initials) el.textContent = initials;
81
+ else el.innerHTML = '<span class="ic" data-ic="user" aria-hidden="true"></span>';
82
+ const cls = this.classList;
83
+ cls.add('xo-avatar');
84
+ for (const s of SIZE) cls.toggle(`xo-avatar--${s}`, this.size === s);
85
+ for (const s of STATUS) cls.toggle(`xo-avatar--${s}`, this.status === s);
86
+ const status = this.status === 'ok' ? text('xoong.avatar.ok', 'online') : this.status === 'warn' ? text('xoong.avatar.warn', 'needs attention') : this.status === 'muted' ? text('xoong.avatar.muted', 'inactive') : '';
87
+ const label = name ? (status ? `${name}, ${status}` : name) : text('xoong.avatar.unknown', 'Unknown user');
88
+ this.setAttribute('aria-label', label);
89
+ this.setAttribute('role', this.closest('xo-avatars') ? 'listitem' : 'img');
90
+ }
91
+ }
92
+ define('xo-avatar', XoAvatar);
93
+
94
+ class XoAvatars extends XoElement {
95
+ static props = { max: Number, label: String };
96
+
97
+ /** @type {HTMLElement | null} */ #more = null;
98
+ /** @type {MutationObserver | null} */ #watch = null;
99
+
100
+ connected() {
101
+ this.classList.add('xo-avatars');
102
+ this.setAttribute('role', 'list');
103
+ void this.ready().then(() => {
104
+ this.#layout();
105
+ this.#watch = new MutationObserver(() => this.#layout());
106
+ this.#watch.observe(this, { childList: true });
107
+ });
108
+ }
109
+ disconnected() { this.#watch?.disconnect(); this.#watch = null; }
110
+
111
+ attributeChangedCallback(attr, old, value) {
112
+ super.attributeChangedCallback(attr, old, value);
113
+ if (this.isConnected && this.#watch) this.#layout();
114
+ }
115
+
116
+ #layout() {
117
+ this.#watch?.disconnect();
118
+ const items = /** @type {HTMLElement[]} */ ([...this.children].filter((c) => c !== this.#more));
119
+ const max = this.max > 0 ? Math.floor(this.max) : items.length;
120
+ const rest = Math.max(0, items.length - max);
121
+ items.forEach((el, i) => {
122
+ if (!el.hasAttribute('role') || el.getAttribute('role') === 'img') el.setAttribute('role', 'listitem');
123
+ if (/** @type {any} */ (el).refresh) /** @type {any} */ (el).refresh();
124
+ const hide = i >= max;
125
+ el.toggleAttribute('hidden', hide);
126
+ if (hide) el.style.display = 'none'; else el.style.removeProperty('display');
127
+ });
128
+ if (rest > 0) {
129
+ if (!this.#more) {
130
+ const more = document.createElement('span');
131
+ more.className = 'xo-avatar xo-avatar--muted';
132
+ more.setAttribute('role', 'listitem');
133
+ this.#more = more;
134
+ }
135
+ this.#more.textContent = `+${rest}`;
136
+ this.#more.setAttribute('aria-label', text('xoong.avatar.more', '{count} more', { count: rest }));
137
+ if (this.lastElementChild !== this.#more) this.append(this.#more);
138
+ } else if (this.#more) { this.#more.remove(); this.#more = null; }
139
+ this.#watch?.observe(this, { childList: true });
140
+ }
141
+ }
142
+ define('xo-avatars', XoAvatars);
package/src/controls.css CHANGED
@@ -8,6 +8,13 @@
8
8
  xo-tabs > [role="tabpanel"] { padding-top: var(--xo-space-3, .75rem); }
9
9
  }
10
10
 
11
+ @layer xoong-controls {
12
+ /* the hosts of the field and feedback elements: a custom element is inline until told otherwise */
13
+ :is(xo-date, xo-slider, xo-range-slider, xo-password, xo-copy, xo-file, xo-textarea, xo-otp, xo-progress, xo-steps, xo-stat, xo-chart, xo-accordion, xo-consent) { display: block; }
14
+ :is(xo-switch, xo-stepper, xo-time, xo-segmented, xo-split-button, xo-spinner, xo-avatar, xo-avatars, xo-inline-edit, xo-theme-toggle, xo-popover-anchor) { display: inline-block; }
15
+ xo-popover[hidden] { display: none; }
16
+ }
17
+
11
18
  @layer xoong-controls {
12
19
  xo-image { display: block; position: relative; overflow: hidden; max-width: 100%; }
13
20
  xo-image > img { display: block; width: 100%; height: 100%; object-fit: var(--xo-image-fit, cover); object-position: var(--xo-image-position, center); }
package/src/date.js CHANGED
@@ -59,13 +59,13 @@ class XoDate extends XoControl {
59
59
  input.type = 'text';
60
60
  input.autocomplete = 'off';
61
61
  input.setAttribute('aria-label', this.label || this.name || text('xoong.controls.date', 'Date'));
62
- input.setAttribute('aria-haspopup', 'dialog');
63
- input.setAttribute('aria-expanded', 'false');
64
62
  input.placeholder = this.placeholder || (this.range ? text('xoong.controls.dateRangeHint', 'Start - end') : this.#sample());
65
63
  const opener = document.createElement('button');
66
64
  opener.type = 'button';
67
65
  opener.className = 'xo-date-open';
68
66
  opener.setAttribute('aria-label', text('xoong.controls.chooseDate', 'Choose date'));
67
+ opener.setAttribute('aria-haspopup', 'dialog'); // on the button: a text input may not carry aria-expanded (axe aria-allowed-attr)
68
+ opener.setAttribute('aria-expanded', 'false');
69
69
  opener.innerHTML = '<span class="ic" data-ic="calendar" aria-hidden="true"></span>';
70
70
  opener.addEventListener('click', () => (this.#open ? this.#close(true) : this.#openCalendar()));
71
71
  wrap.append(input, opener);
@@ -141,7 +141,7 @@ class XoDate extends XoControl {
141
141
  this.#open = true;
142
142
  this.#draw();
143
143
  this.#stop = showLayer(/** @type {HTMLElement} */ (this.#panel), /** @type {HTMLElement} */ (this.#input).parentElement ?? this);
144
- this.#input?.setAttribute('aria-expanded', 'true');
144
+ this.#opener?.setAttribute('aria-expanded', 'true');
145
145
  document.addEventListener('pointerdown', this.#onDocument);
146
146
  this.#focusDay();
147
147
  }
@@ -150,7 +150,7 @@ class XoDate extends XoControl {
150
150
  this.#open = false;
151
151
  this.#stop();
152
152
  hideLayer(/** @type {HTMLElement} */ (this.#panel));
153
- this.#input?.setAttribute('aria-expanded', 'false');
153
+ this.#opener?.setAttribute('aria-expanded', 'false');
154
154
  document.removeEventListener('pointerdown', this.#onDocument);
155
155
  if (refocus) this.#input?.focus();
156
156
  }