@zeyos/zx 2.3.0 → 4.0.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 +138 -0
- package/DESIGN-SYSTEM.md +96 -0
- package/README.md +29 -39
- package/dist/types/components/account-menu/account-menu.d.ts +144 -0
- package/dist/types/components/app-sidebar/app-sidebar.d.ts +243 -0
- package/dist/types/components/avatar/avatar.d.ts +100 -0
- package/dist/types/components/card/card.d.ts +128 -0
- package/dist/types/components/chart/chart.d.ts +322 -0
- package/dist/types/components/date-picker/month-picker.d.ts +6 -0
- package/dist/types/components/dialog/dialog.d.ts +9 -0
- package/dist/types/components/dock/dock.d.ts +495 -0
- package/dist/types/components/dropdown/dropdown.d.ts +2 -2
- package/dist/types/components/field/field.d.ts +14 -0
- package/dist/types/components/field-upload/field-upload.d.ts +37 -4
- package/dist/types/components/form/form.d.ts +25 -3
- package/dist/types/components/grid/grid.d.ts +172 -0
- package/dist/types/components/launcher/launcher.d.ts +359 -0
- package/dist/types/components/layout/layout.d.ts +4 -4
- package/dist/types/components/menu-button/menu-button.d.ts +10 -2
- package/dist/types/components/modal/modal.d.ts +25 -0
- package/dist/types/components/multi-value-editor/multi-value-editor.d.ts +7 -1
- package/dist/types/components/questionnaire/items.d.ts +327 -0
- package/dist/types/components/questionnaire/questionnaire.d.ts +449 -0
- package/dist/types/components/select/select.d.ts +51 -0
- package/dist/types/components/sheet/sheet-stack.d.ts +167 -0
- package/dist/types/components/sheet/sheet.d.ts +294 -0
- package/dist/types/components/split-view/split-view.d.ts +2 -21
- package/dist/types/components/table/table.d.ts +223 -15
- package/dist/types/components/tooltip/tooltip.d.ts +2 -2
- package/dist/types/components/value-list/value-list.d.ts +24 -3
- package/dist/types/core/dom.d.ts +122 -0
- package/dist/types/core/drag-axis.d.ts +111 -0
- package/dist/types/core/position.d.ts +4 -2
- package/dist/types/index.d.ts +13 -2
- package/dist/types/internal/app-rail.d.ts +265 -0
- package/dist/types/zeyos/icons.d.ts +5 -0
- package/dist/types/zeyos/index.d.ts +2 -1
- package/dist/types/zeyos/launcher.d.ts +147 -0
- package/dist/types/zeyos/select.d.ts +39 -0
- package/dist/zx-zeyos.esm.js +1870 -133
- package/dist/zx-zeyos.esm.min.js +1 -1
- package/dist/zx.css +2006 -267
- package/dist/zx.esm.js +8987 -2757
- package/dist/zx.esm.min.js +3 -3
- package/dist/zx.global.js +8987 -2757
- package/dist/zx.global.min.js +3 -3
- package/dist/zx.min.css +1 -1
- package/docs/api.json +4396 -2344
- package/docs/llms.md +463 -86
- package/docs/llms.txt +32 -19
- package/package.json +10 -17
- package/src/components/account-menu/account-menu.css +70 -0
- package/src/components/account-menu/account-menu.js +208 -0
- package/src/components/app-rail/app-rail.css +107 -0
- package/src/components/app-sidebar/app-sidebar.css +80 -0
- package/src/components/app-sidebar/app-sidebar.js +588 -0
- package/src/components/avatar/avatar.css +61 -0
- package/src/components/avatar/avatar.js +222 -0
- package/src/components/button/button.css +0 -1
- package/src/components/card/card.css +149 -0
- package/src/components/card/card.js +285 -0
- package/src/components/chart/chart.css +61 -0
- package/src/components/chart/chart.js +523 -0
- package/src/components/date-picker/month-picker.js +9 -0
- package/src/components/dialog/dialog.js +11 -4
- package/src/components/dock/dock.css +228 -0
- package/src/components/dock/dock.js +977 -0
- package/src/components/dropdown/dropdown.js +1 -1
- package/src/components/field/field.js +2 -0
- package/src/components/field-upload/field-upload.js +9 -4
- package/src/components/form/form.js +6 -3
- package/src/components/grid/grid.css +6 -0
- package/src/components/grid/grid.js +220 -0
- package/src/components/launcher/launcher.css +210 -0
- package/src/components/launcher/launcher.js +765 -0
- package/src/components/layout/layout.js +2 -2
- package/src/components/menu-button/menu-button.js +27 -2
- package/src/components/modal/modal.css +5 -1
- package/src/components/modal/modal.js +37 -2
- package/src/components/multi-value-editor/multi-value-editor.js +2 -1
- package/src/components/questionnaire/items.js +370 -0
- package/src/components/questionnaire/questionnaire.css +387 -0
- package/src/components/questionnaire/questionnaire.js +1033 -0
- package/src/components/select/field-adapter.js +9 -4
- package/src/components/select/select.css +144 -11
- package/src/components/select/select.js +259 -27
- package/src/components/sheet/sheet-stack.js +304 -0
- package/src/components/sheet/sheet.css +351 -0
- package/src/components/sheet/sheet.js +551 -0
- package/src/components/split-view/split-view.js +76 -161
- package/src/components/table/table.css +43 -0
- package/src/components/table/table.js +531 -52
- package/src/components/tooltip/tooltip.js +5 -2
- package/src/components/value-list/value-list.js +6 -3
- package/src/core/dom.js +322 -0
- package/src/core/drag-axis.js +203 -0
- package/src/core/position.js +68 -22
- package/src/index.js +20 -2
- package/src/internal/app-rail.js +718 -0
- package/src/zeyos/icons.js +8 -3
- package/src/zeyos/index.js +7 -1
- package/src/zeyos/launcher.js +338 -0
- package/src/zeyos/modules.js +1 -0
- package/src/zeyos/select.js +115 -1
- package/styles/tokens/dark.css +2 -3
- package/styles/tokens/global.css +6 -6
- package/styles/tokens/modules.css +1 -0
- package/styles/tokens/semantic.css +5 -3
- package/styles/tokens/themes.css +1 -1
- package/styles/zx.css +11 -1
- package/MIGRATION.md +0 -598
- package/dist/types/compat/base.d.ts +0 -156
- package/dist/types/compat/globals.d.ts +0 -20
- package/dist/types/compat/index.d.ts +0 -128
- package/dist/types/compat/map/basic.d.ts +0 -175
- package/dist/types/compat/map/data.d.ts +0 -102
- package/dist/types/compat/map/date-time.d.ts +0 -80
- package/dist/types/compat/map/factory.d.ts +0 -9
- package/dist/types/compat/map/form.d.ts +0 -98
- package/dist/types/compat/map/helpers.d.ts +0 -12
- package/dist/types/compat/map/http.d.ts +0 -36
- package/dist/types/compat/map/layout.d.ts +0 -106
- package/dist/types/compat/map/options.d.ts +0 -80
- package/dist/types/compat/map/select.d.ts +0 -95
- package/dist/types/compat/map/stubs.d.ts +0 -32
- package/dist/types/compat/map/utilities.d.ts +0 -37
- package/dist/types/compat-entry.d.ts +0 -5
- package/dist/types/components/permission/permission.d.ts +0 -96
- package/dist/zx-compat.esm.js +0 -17484
- package/dist/zx-compat.esm.min.js +0 -1
- package/dist/zx-compat.global.js +0 -23635
- package/dist/zx-compat.global.min.js +0 -3
- package/src/compat/base.js +0 -291
- package/src/compat/globals.js +0 -165
- package/src/compat/index.js +0 -84
- package/src/compat/map/basic.js +0 -464
- package/src/compat/map/data.js +0 -232
- package/src/compat/map/date-time.js +0 -177
- package/src/compat/map/factory.js +0 -45
- package/src/compat/map/form.js +0 -330
- package/src/compat/map/helpers.js +0 -46
- package/src/compat/map/http.js +0 -117
- package/src/compat/map/layout.js +0 -256
- package/src/compat/map/options.js +0 -267
- package/src/compat/map/select.js +0 -198
- package/src/compat/map/stubs.js +0 -26
- package/src/compat/map/utilities.js +0 -113
- package/src/compat-entry.js +0 -11
- package/src/components/permission/permission.css +0 -47
- package/src/components/permission/permission.js +0 -201
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { Component } from '../../core/component.js';
|
|
2
|
+
import { h } from '../../core/dom.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {Object} AvatarOptions
|
|
6
|
+
* @property {string|null} [src=null] Image URL.
|
|
7
|
+
* @property {string} [name=''] Name used to derive fallback initials.
|
|
8
|
+
* @property {string|null} [initials=null] Explicit fallback initials.
|
|
9
|
+
* @property {string|null} [label=null] Accessible label; null makes the avatar decorative.
|
|
10
|
+
* @property {string|number} [size='md'] Preset (`sm`, `md`, `lg`) or pixel size.
|
|
11
|
+
* @property {'circle'|'rounded'|'square'} [shape='circle'] Avatar shape.
|
|
12
|
+
* @property {'online'|'away'|'busy'|'offline'|null} [status=null] Optional presence state.
|
|
13
|
+
* @property {string|null} [statusLabel=null] Text alternative for the presence state.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Stable-size user image with deterministic initials fallback and optional presence state.
|
|
18
|
+
* @extends {Component<AvatarOptions>}
|
|
19
|
+
*/
|
|
20
|
+
export class Avatar extends Component {
|
|
21
|
+
static cssName = 'avatar';
|
|
22
|
+
|
|
23
|
+
/** @type {Readonly<AvatarOptions>} */
|
|
24
|
+
static defaults = {
|
|
25
|
+
src: null,
|
|
26
|
+
name: '',
|
|
27
|
+
initials: null,
|
|
28
|
+
label: null,
|
|
29
|
+
size: 'md',
|
|
30
|
+
shape: 'circle',
|
|
31
|
+
status: null,
|
|
32
|
+
statusLabel: null
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** @returns {HTMLElement} */
|
|
36
|
+
render() {
|
|
37
|
+
this._createdRoot = this.el === null;
|
|
38
|
+
const root = /** @type {HTMLElement} */ (this.el ?? h('span'));
|
|
39
|
+
this.el = root;
|
|
40
|
+
this._original = this._createdRoot ? null : {
|
|
41
|
+
attributes: Array.from(root.attributes, (attribute) => [attribute.name, attribute.value]),
|
|
42
|
+
children: Array.from(root.childNodes)
|
|
43
|
+
};
|
|
44
|
+
this._state = {
|
|
45
|
+
src: this.options.src,
|
|
46
|
+
name: String(this.options.name ?? ''),
|
|
47
|
+
initials: this.options.initials,
|
|
48
|
+
label: this.options.label,
|
|
49
|
+
size: this.options.size,
|
|
50
|
+
shape: this.options.shape,
|
|
51
|
+
status: this.options.status,
|
|
52
|
+
statusLabel: this.options.statusLabel
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const image = /** @type {HTMLImageElement} */ (h('img', {
|
|
56
|
+
ref: 'image',
|
|
57
|
+
class: 'zx-avatar__image',
|
|
58
|
+
alt: '',
|
|
59
|
+
draggable: false
|
|
60
|
+
}));
|
|
61
|
+
const fallback = h('span', {
|
|
62
|
+
ref: 'fallback',
|
|
63
|
+
class: 'zx-avatar__fallback',
|
|
64
|
+
ariaHidden: 'true'
|
|
65
|
+
});
|
|
66
|
+
const status = h('span', {
|
|
67
|
+
ref: 'status',
|
|
68
|
+
class: 'zx-avatar__status',
|
|
69
|
+
ariaHidden: 'true',
|
|
70
|
+
hidden: true
|
|
71
|
+
});
|
|
72
|
+
root.replaceChildren(image, fallback, status);
|
|
73
|
+
this.listen(image, 'load', () => {
|
|
74
|
+
if (this._isCurrentImageSource()) this._showImage(true);
|
|
75
|
+
});
|
|
76
|
+
this.listen(image, 'error', () => {
|
|
77
|
+
if (this._isCurrentImageSource()) this._showImage(false);
|
|
78
|
+
});
|
|
79
|
+
this._sync();
|
|
80
|
+
return root;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Replaces avatar identity and presentation values.
|
|
85
|
+
* @param {Partial<AvatarOptions>} values New values.
|
|
86
|
+
* @returns {this}
|
|
87
|
+
*/
|
|
88
|
+
set(values = {}) {
|
|
89
|
+
if (!values || typeof values !== 'object') return this;
|
|
90
|
+
for (const key of ['src', 'name', 'initials', 'label', 'size', 'shape', 'status', 'statusLabel']) {
|
|
91
|
+
if (Object.hasOwn(values, key)) this._state[key] = values[key];
|
|
92
|
+
}
|
|
93
|
+
this._sync();
|
|
94
|
+
return this;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Sets the presence state and its text alternative. @param {AvatarOptions['status']} status @param {string|null} [label=null] @returns {this} */
|
|
98
|
+
setStatus(status, label = null) {
|
|
99
|
+
this._state.status = status;
|
|
100
|
+
this._state.statusLabel = label;
|
|
101
|
+
this._syncLabel();
|
|
102
|
+
this._syncStatus();
|
|
103
|
+
return this;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Returns whether the image is currently visible. @returns {boolean} */
|
|
107
|
+
hasImage() {
|
|
108
|
+
return !this.refs.image.hidden;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Restores an enhanced target or removes an owned root. @returns {void} */
|
|
112
|
+
destroy() {
|
|
113
|
+
const original = this._original;
|
|
114
|
+
super.destroy();
|
|
115
|
+
if (!this._createdRoot && original) {
|
|
116
|
+
for (const attribute of Array.from(this.el.attributes)) this.el.removeAttribute(attribute.name);
|
|
117
|
+
for (const [name, value] of original.attributes) this.el.setAttribute(name, value);
|
|
118
|
+
this.el.replaceChildren(...original.children);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** @returns {void} */
|
|
123
|
+
_sync() {
|
|
124
|
+
const { size, shape, label } = this._state;
|
|
125
|
+
this.el.dataset.size = typeof size === 'string' ? size : 'custom';
|
|
126
|
+
this.el.dataset.shape = ['circle', 'rounded', 'square'].includes(shape) ? shape : 'circle';
|
|
127
|
+
if (typeof size === 'number' && Number.isFinite(size)) {
|
|
128
|
+
this.el.style.setProperty('--zx-avatar-size', `${Math.max(1, size)}px`);
|
|
129
|
+
} else {
|
|
130
|
+
this.el.style.removeProperty('--zx-avatar-size');
|
|
131
|
+
}
|
|
132
|
+
this._syncLabel();
|
|
133
|
+
this.refs.fallback.textContent = String(this._state.initials || avatarInitials(this._state.name));
|
|
134
|
+
const src = this._state.src == null ? '' : String(this._state.src).trim();
|
|
135
|
+
if (src) {
|
|
136
|
+
this.refs.image.setAttribute('src', src);
|
|
137
|
+
// Keep the fallback visible until this exact source has loaded successfully.
|
|
138
|
+
this._showImage(/** @type {HTMLImageElement} */ (this.refs.image).complete
|
|
139
|
+
&& /** @type {HTMLImageElement} */ (this.refs.image).naturalWidth > 0);
|
|
140
|
+
} else {
|
|
141
|
+
this.refs.image.removeAttribute('src');
|
|
142
|
+
this._showImage(false);
|
|
143
|
+
}
|
|
144
|
+
this._syncStatus();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Synchronizes the standalone accessible name, including valid presence text. @returns {void} */
|
|
148
|
+
_syncLabel() {
|
|
149
|
+
const { label, status, statusLabel } = this._state;
|
|
150
|
+
if (label) {
|
|
151
|
+
const presence = ['online', 'away', 'busy', 'offline'].includes(status) ? statusLabel : null;
|
|
152
|
+
this.el.setAttribute('role', 'img');
|
|
153
|
+
this.el.setAttribute('aria-label', [label, presence].filter(Boolean).join(', '));
|
|
154
|
+
this.el.removeAttribute('aria-hidden');
|
|
155
|
+
} else {
|
|
156
|
+
this.el.removeAttribute('role');
|
|
157
|
+
this.el.removeAttribute('aria-label');
|
|
158
|
+
this.el.setAttribute('aria-hidden', 'true');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** @param {boolean} visible @returns {void} */
|
|
163
|
+
_showImage(visible) {
|
|
164
|
+
this.refs.image.hidden = !visible;
|
|
165
|
+
this.refs.fallback.hidden = visible;
|
|
166
|
+
this.el.dataset.fallback = String(!visible);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Ignores a late load/error from a source that has already been replaced. @returns {boolean} */
|
|
170
|
+
_isCurrentImageSource() {
|
|
171
|
+
const image = /** @type {HTMLImageElement} */ (this.refs.image);
|
|
172
|
+
const requested = image.getAttribute('src');
|
|
173
|
+
if (!requested) return false;
|
|
174
|
+
try {
|
|
175
|
+
return image.currentSrc === new URL(requested, document.baseURI).href;
|
|
176
|
+
} catch {
|
|
177
|
+
return image.currentSrc === requested;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** @returns {void} */
|
|
182
|
+
_syncStatus() {
|
|
183
|
+
const allowed = ['online', 'away', 'busy', 'offline'];
|
|
184
|
+
const status = allowed.includes(this._state.status) ? this._state.status : null;
|
|
185
|
+
this.refs.status.hidden = status === null;
|
|
186
|
+
if (status) this.refs.status.dataset.status = status;
|
|
187
|
+
else delete this.refs.status.dataset.status;
|
|
188
|
+
const label = this._state.statusLabel;
|
|
189
|
+
if (label) {
|
|
190
|
+
this.refs.status.setAttribute('title', String(label));
|
|
191
|
+
} else {
|
|
192
|
+
this.refs.status.removeAttribute('title');
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Derives at most two uppercase initials from the first and last words of a name.
|
|
199
|
+
* @param {unknown} name Name-like value.
|
|
200
|
+
* @returns {string}
|
|
201
|
+
*/
|
|
202
|
+
export function avatarInitials(name) {
|
|
203
|
+
const words = String(name ?? '').trim().split(/\s+/u).filter(Boolean);
|
|
204
|
+
if (words.length === 0) return '?';
|
|
205
|
+
const first = uppercaseInitial(words[0]);
|
|
206
|
+
const last = words.length > 1 ? uppercaseInitial(words[words.length - 1]) : '';
|
|
207
|
+
return `${first}${last}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Returns one uppercased grapheme even when Unicode case expansion produces multiple letters. @param {string} word @returns {string} */
|
|
211
|
+
function uppercaseInitial(word) {
|
|
212
|
+
return firstGrapheme(firstGrapheme(word).toUpperCase());
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** @param {string} value @returns {string} */
|
|
216
|
+
function firstGrapheme(value) {
|
|
217
|
+
if (typeof Intl.Segmenter === 'function') {
|
|
218
|
+
const iterator = new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(value)[Symbol.iterator]();
|
|
219
|
+
return iterator.next().value?.segment ?? '';
|
|
220
|
+
}
|
|
221
|
+
return Array.from(value)[0] ?? '';
|
|
222
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
.zx-card {
|
|
2
|
+
--zx-card-media-size: 8rem;
|
|
3
|
+
position: relative;
|
|
4
|
+
inline-size: 100%;
|
|
5
|
+
container-type: inline-size;
|
|
6
|
+
overflow: hidden;
|
|
7
|
+
border-radius: var(--zx-radius-lg);
|
|
8
|
+
color: var(--zx-color-text);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.zx-card[data-variant="outlined"] {
|
|
12
|
+
border: 1px solid var(--zx-color-border);
|
|
13
|
+
background: var(--zx-color-bg-surface);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.zx-card[data-variant="raised"] {
|
|
17
|
+
border: 1px solid transparent;
|
|
18
|
+
background: var(--zx-color-bg-surface);
|
|
19
|
+
box-shadow: var(--zx-overlay-shadow);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.zx-card[data-variant="filled"] {
|
|
23
|
+
border: 1px solid transparent;
|
|
24
|
+
background: var(--zx-color-bg-muted);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.zx-card[data-linked="true"]:hover {
|
|
28
|
+
background: var(--zx-color-bg-hover);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.zx-card:has(.zx-card__primary:focus-visible) {
|
|
32
|
+
outline: 0;
|
|
33
|
+
box-shadow: var(--zx-focus-ring);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.zx-card__layout,
|
|
37
|
+
.zx-card__inner {
|
|
38
|
+
min-inline-size: 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.zx-card__inner {
|
|
42
|
+
display: grid;
|
|
43
|
+
gap: var(--zx-space-3);
|
|
44
|
+
padding: var(--zx-space-5);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.zx-card[data-orientation="horizontal"] .zx-card__layout {
|
|
48
|
+
display: grid;
|
|
49
|
+
grid-template-columns: var(--zx-card-media-size) minmax(0, 1fr);
|
|
50
|
+
align-items: stretch;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.zx-card__media {
|
|
54
|
+
min-inline-size: 0;
|
|
55
|
+
overflow: hidden;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.zx-card[data-orientation="horizontal"] .zx-card__media {
|
|
59
|
+
min-block-size: var(--zx-card-media-size);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.zx-card__media > :is(img, video, svg) {
|
|
63
|
+
display: block;
|
|
64
|
+
inline-size: 100%;
|
|
65
|
+
block-size: 100%;
|
|
66
|
+
object-fit: cover;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.zx-card__header {
|
|
70
|
+
display: flex;
|
|
71
|
+
align-items: start;
|
|
72
|
+
justify-content: space-between;
|
|
73
|
+
gap: var(--zx-space-3);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.zx-card__header[hidden],
|
|
77
|
+
.zx-card__media[hidden],
|
|
78
|
+
.zx-card__content[hidden],
|
|
79
|
+
.zx-card__footer[hidden],
|
|
80
|
+
.zx-card__actions[hidden] {
|
|
81
|
+
display: none;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.zx-card__heading {
|
|
85
|
+
min-inline-size: 0;
|
|
86
|
+
margin: 0;
|
|
87
|
+
font-size: var(--zx-text-lg);
|
|
88
|
+
font-weight: 700;
|
|
89
|
+
line-height: 1.3;
|
|
90
|
+
letter-spacing: -0.012em;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.zx-card__primary,
|
|
94
|
+
.zx-card__title {
|
|
95
|
+
color: inherit;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.zx-card__primary {
|
|
99
|
+
text-decoration: none;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.zx-card[data-linked="true"] .zx-card__primary::after {
|
|
103
|
+
content: "";
|
|
104
|
+
position: absolute;
|
|
105
|
+
inset: 0;
|
|
106
|
+
z-index: 1;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.zx-card__primary:focus-visible {
|
|
110
|
+
outline: 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.zx-card__actions,
|
|
114
|
+
.zx-card__content :is(a, button, input, select, textarea, summary, [tabindex]:not([tabindex="-1"])),
|
|
115
|
+
.zx-card__footer :is(a, button, input, select, textarea, summary, [tabindex]:not([tabindex="-1"])) {
|
|
116
|
+
position: relative;
|
|
117
|
+
z-index: 2;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.zx-card__actions {
|
|
121
|
+
display: flex;
|
|
122
|
+
flex: none;
|
|
123
|
+
flex-wrap: wrap;
|
|
124
|
+
align-items: center;
|
|
125
|
+
justify-content: end;
|
|
126
|
+
gap: var(--zx-space-2);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.zx-card__content {
|
|
130
|
+
color: var(--zx-color-text-muted);
|
|
131
|
+
line-height: 1.6;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.zx-card__footer {
|
|
135
|
+
padding-block-start: var(--zx-space-3);
|
|
136
|
+
border-block-start: 1px solid var(--zx-color-border);
|
|
137
|
+
color: var(--zx-color-text-muted);
|
|
138
|
+
font-size: var(--zx-text-sm);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
@container (max-width: 28rem) {
|
|
142
|
+
.zx-card[data-orientation="horizontal"] .zx-card__layout {
|
|
143
|
+
grid-template-columns: minmax(0, 1fr);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.zx-card[data-orientation="horizontal"] .zx-card__media {
|
|
147
|
+
min-block-size: 0;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { Component } from '../../core/component.js';
|
|
2
|
+
import { h, restoreTarget, safeHref, snapshotTarget } from '../../core/dom.js';
|
|
3
|
+
import { isElement, uid } from '../../core/util.js';
|
|
4
|
+
import { button } from '../button/button.js';
|
|
5
|
+
|
|
6
|
+
/** @typedef {string|Node|Component|null} CardContent */
|
|
7
|
+
/** @typedef {import('../button/button.js').ButtonOptions} ButtonOptions */
|
|
8
|
+
/** @typedef {Element|ButtonOptions} CardAction */
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} CardLinkDescriptor
|
|
11
|
+
* @property {string} href Native link destination; executable/data schemes are rejected.
|
|
12
|
+
* @property {string} [target] Native browsing context.
|
|
13
|
+
* @property {string} [rel] Native link relationship.
|
|
14
|
+
* @property {(event: MouseEvent) => void} [onclick] Native click listener.
|
|
15
|
+
*/
|
|
16
|
+
/** @typedef {string|CardLinkDescriptor|null} CardLink */
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {Object} CardOptions
|
|
20
|
+
* @property {string} [title=''] Card title.
|
|
21
|
+
* @property {CardContent} [media=null] Optional image, avatar, icon, or other media.
|
|
22
|
+
* @property {CardContent} [content=null] Main card content; enhanced children are adopted by default.
|
|
23
|
+
* @property {CardAction[]} [actions=[]] Secondary native controls beside the title.
|
|
24
|
+
* @property {CardContent} [footer=null] Optional metadata or trailing content.
|
|
25
|
+
* @property {CardLink} [link=null] Optional native primary link rendered around the title.
|
|
26
|
+
* @property {'outlined'|'raised'|'filled'} [variant='outlined'] Surface treatment.
|
|
27
|
+
* @property {'vertical'|'horizontal'} [orientation='vertical'] Media/content arrangement.
|
|
28
|
+
* @property {1|2|3|4|5|6} [headingLevel=3] Semantic title heading level.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Semantic content or record surface with optional media, native primary link, actions, and footer.
|
|
33
|
+
* The root is never interactive: linked cards keep a real title anchor, so secondary controls are
|
|
34
|
+
* siblings rather than invalid nested actions.
|
|
35
|
+
* @extends {Component<CardOptions>}
|
|
36
|
+
*/
|
|
37
|
+
export class Card extends Component {
|
|
38
|
+
static cssName = 'card';
|
|
39
|
+
|
|
40
|
+
/** @type {Readonly<CardOptions>} */
|
|
41
|
+
static defaults = {
|
|
42
|
+
title: '',
|
|
43
|
+
media: null,
|
|
44
|
+
content: null,
|
|
45
|
+
actions: [],
|
|
46
|
+
footer: null,
|
|
47
|
+
link: null,
|
|
48
|
+
variant: 'outlined',
|
|
49
|
+
orientation: 'vertical',
|
|
50
|
+
headingLevel: 3
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Creates or enhances one card.
|
|
55
|
+
* @param {Element|string|null} [target=null] Existing card container, selector, or null.
|
|
56
|
+
* @param {CardOptions} [options={}] Card options.
|
|
57
|
+
*/
|
|
58
|
+
constructor(target = null, options = {}) {
|
|
59
|
+
super(target, options);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @returns {HTMLElement} */
|
|
63
|
+
render() {
|
|
64
|
+
const created = !this.el;
|
|
65
|
+
const root = /** @type {HTMLElement} */ (this.el ?? h('article'));
|
|
66
|
+
this.el = root;
|
|
67
|
+
this._createdRoot = created;
|
|
68
|
+
this._original = created ? null : snapshotTarget(root);
|
|
69
|
+
this._cleaned = false;
|
|
70
|
+
this._title = '';
|
|
71
|
+
this._link = null;
|
|
72
|
+
this._actionAbort = null;
|
|
73
|
+
this._linkAbort = null;
|
|
74
|
+
|
|
75
|
+
const originalContent = created ? [] : Array.from(root.childNodes);
|
|
76
|
+
try {
|
|
77
|
+
const headingLevel = normalizeHeadingLevel(this.options.headingLevel);
|
|
78
|
+
const headingId = uid('zx-card-heading');
|
|
79
|
+
const heading = h(`h${headingLevel}`, {
|
|
80
|
+
ref: 'heading',
|
|
81
|
+
class: 'zx-card__heading',
|
|
82
|
+
id: headingId
|
|
83
|
+
});
|
|
84
|
+
const media = h('div', { ref: 'media', class: 'zx-card__media', hidden: true });
|
|
85
|
+
const actions = h('div', {
|
|
86
|
+
ref: 'actions',
|
|
87
|
+
class: 'zx-card__actions',
|
|
88
|
+
role: 'group',
|
|
89
|
+
ariaLabel: 'Card actions',
|
|
90
|
+
hidden: true
|
|
91
|
+
});
|
|
92
|
+
const content = h('div', { ref: 'content', class: 'zx-card__content', hidden: true });
|
|
93
|
+
const footer = h('footer', { ref: 'footer', class: 'zx-card__footer', hidden: true });
|
|
94
|
+
const header = h('header', { ref: 'header', class: 'zx-card__header' }, heading, actions);
|
|
95
|
+
|
|
96
|
+
root.replaceChildren(h('div', { class: 'zx-card__layout' }, media,
|
|
97
|
+
h('div', { class: 'zx-card__inner' }, header, content, footer)));
|
|
98
|
+
root.dataset.variant = normalizeVariant(this.options.variant);
|
|
99
|
+
root.dataset.orientation = normalizeOrientation(this.options.orientation);
|
|
100
|
+
|
|
101
|
+
this.setTitle(this.options.title);
|
|
102
|
+
this.setMedia(this.options.media);
|
|
103
|
+
if (this.options.content !== null) this.setContent(this.options.content);
|
|
104
|
+
else if (originalContent.length) {
|
|
105
|
+
content.replaceChildren(...originalContent);
|
|
106
|
+
content.hidden = false;
|
|
107
|
+
}
|
|
108
|
+
this.setActions(this.options.actions);
|
|
109
|
+
this.setFooter(this.options.footer);
|
|
110
|
+
this.setLink(this.options.link);
|
|
111
|
+
return root;
|
|
112
|
+
} catch (error) {
|
|
113
|
+
this._actionAbort?.abort();
|
|
114
|
+
this._linkAbort?.abort();
|
|
115
|
+
if (!created) restoreTarget(root, this._original);
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Replaces the card title. @param {string} title Next title. @returns {this} */
|
|
121
|
+
setTitle(title) {
|
|
122
|
+
const next = String(title ?? '');
|
|
123
|
+
if (this._link && !next.trim()) throw new TypeError('A linked Card requires a non-empty title');
|
|
124
|
+
this._title = next;
|
|
125
|
+
this._renderHeading();
|
|
126
|
+
return this;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Replaces or removes the media region. @param {CardContent} content Media. @returns {this} */
|
|
130
|
+
setMedia(content) {
|
|
131
|
+
replaceCardContent(this.refs.media, content, 'Card media');
|
|
132
|
+
this.refs.media.hidden = content === null;
|
|
133
|
+
return this;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Replaces or clears the main content. @param {CardContent} content Content. @returns {this} */
|
|
137
|
+
setContent(content) {
|
|
138
|
+
replaceCardContent(this.refs.content, content, 'Card content');
|
|
139
|
+
this.refs.content.hidden = content === null;
|
|
140
|
+
return this;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Replaces the secondary action controls. @param {CardAction[]} actions Actions. @returns {this} */
|
|
144
|
+
setActions(actions) {
|
|
145
|
+
if (!Array.isArray(actions)) throw new TypeError('Card actions must be an array');
|
|
146
|
+
const nextAbort = new AbortController();
|
|
147
|
+
let controls;
|
|
148
|
+
try {
|
|
149
|
+
controls = actions.map((action) => {
|
|
150
|
+
if (isElement(action)) return action;
|
|
151
|
+
if (!action || typeof action !== 'object' || Array.isArray(action)) {
|
|
152
|
+
throw new TypeError('Card actions must be Elements or button descriptors');
|
|
153
|
+
}
|
|
154
|
+
const descriptor = { ...action };
|
|
155
|
+
const onclick = descriptor.onclick;
|
|
156
|
+
delete descriptor.onclick;
|
|
157
|
+
const control = button(descriptor);
|
|
158
|
+
if (onclick !== undefined && typeof onclick !== 'function') {
|
|
159
|
+
throw new TypeError('Card action onclick must be a function');
|
|
160
|
+
}
|
|
161
|
+
if (onclick) control.addEventListener('click', onclick, { signal: nextAbort.signal });
|
|
162
|
+
return control;
|
|
163
|
+
});
|
|
164
|
+
} catch (error) {
|
|
165
|
+
nextAbort.abort();
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
this._actionAbort?.abort();
|
|
169
|
+
this._actionAbort = nextAbort;
|
|
170
|
+
this.refs.actions.replaceChildren(...controls);
|
|
171
|
+
this.refs.actions.hidden = controls.length === 0;
|
|
172
|
+
this._syncHeader();
|
|
173
|
+
return this;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Replaces or removes the footer. @param {CardContent} content Footer. @returns {this} */
|
|
177
|
+
setFooter(content) {
|
|
178
|
+
replaceCardContent(this.refs.footer, content, 'Card footer');
|
|
179
|
+
this.refs.footer.hidden = content === null;
|
|
180
|
+
return this;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Sets or removes the title's native primary link. @param {CardLink} link Link. @returns {this} */
|
|
184
|
+
setLink(link) {
|
|
185
|
+
const next = normalizeCardLink(link);
|
|
186
|
+
if (next && !this._title.trim()) throw new TypeError('A linked Card requires a non-empty title');
|
|
187
|
+
this._link = next;
|
|
188
|
+
this.el.dataset.linked = next ? 'true' : 'false';
|
|
189
|
+
this._renderHeading();
|
|
190
|
+
return this;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Restores an enhanced target exactly, or removes an owned card. @returns {void} */
|
|
194
|
+
destroy() {
|
|
195
|
+
if (this._cleaned) return;
|
|
196
|
+
this._cleaned = true;
|
|
197
|
+
this._actionAbort?.abort();
|
|
198
|
+
this._linkAbort?.abort();
|
|
199
|
+
super.destroy();
|
|
200
|
+
if (!this._createdRoot) restoreTarget(this.el, this._original);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** @returns {void} */
|
|
204
|
+
_renderHeading() {
|
|
205
|
+
const title = this._title;
|
|
206
|
+
const nextAbort = new AbortController();
|
|
207
|
+
const node = this._link ? h('a', {
|
|
208
|
+
class: 'zx-card__primary',
|
|
209
|
+
href: this._link.href,
|
|
210
|
+
target: this._link.target,
|
|
211
|
+
rel: this._link.rel
|
|
212
|
+
}, title) : h('span', { class: 'zx-card__title' }, title);
|
|
213
|
+
if (this._link?.onclick) node.addEventListener('click', this._link.onclick, { signal: nextAbort.signal });
|
|
214
|
+
this._linkAbort?.abort();
|
|
215
|
+
this._linkAbort = nextAbort;
|
|
216
|
+
this.refs.heading.replaceChildren(node);
|
|
217
|
+
this.refs.heading.hidden = title === '';
|
|
218
|
+
if (title) this.el.setAttribute('aria-labelledby', this.refs.heading.id);
|
|
219
|
+
else this.el.removeAttribute('aria-labelledby');
|
|
220
|
+
this._syncHeader();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** @returns {void} */
|
|
224
|
+
_syncHeader() {
|
|
225
|
+
if (!this.refs.header) return;
|
|
226
|
+
this.refs.header.hidden = this._title === '' && this.refs.actions.childElementCount === 0;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** @param {Element} host @param {CardContent} content @param {string} label */
|
|
231
|
+
function replaceCardContent(host, content, label) {
|
|
232
|
+
if (content === null) {
|
|
233
|
+
host.replaceChildren();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (typeof content === 'string') {
|
|
237
|
+
host.textContent = content;
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const node = content instanceof Component ? content.toElement() : content;
|
|
241
|
+
if (node instanceof Node) {
|
|
242
|
+
host.replaceChildren(node);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
throw new TypeError(`${label} must be a string, Node, Component, or null`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** @param {CardLink} value @returns {CardLinkDescriptor|null} */
|
|
249
|
+
function normalizeCardLink(value) {
|
|
250
|
+
if (value === null) return null;
|
|
251
|
+
const descriptor = typeof value === 'string' ? { href: value } : value;
|
|
252
|
+
if (!descriptor || typeof descriptor !== 'object' || Array.isArray(descriptor)) {
|
|
253
|
+
throw new TypeError('Card link must be a URL string, descriptor, or null');
|
|
254
|
+
}
|
|
255
|
+
const href = safeHref(descriptor.href);
|
|
256
|
+
if (href === null) {
|
|
257
|
+
throw new TypeError('Card link requires a safe non-empty href');
|
|
258
|
+
}
|
|
259
|
+
if (descriptor.onclick !== undefined && typeof descriptor.onclick !== 'function') {
|
|
260
|
+
throw new TypeError('Card link onclick must be a function');
|
|
261
|
+
}
|
|
262
|
+
const target = descriptor.target == null ? undefined : String(descriptor.target);
|
|
263
|
+
let rel = descriptor.rel == null ? '' : String(descriptor.rel).trim();
|
|
264
|
+
if (target === '_blank' && !/(^|\s)noopener(\s|$)/i.test(rel)) {
|
|
265
|
+
rel = `${rel} noopener`.trim();
|
|
266
|
+
}
|
|
267
|
+
return { href, target, rel: rel || undefined, onclick: descriptor.onclick };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** @param {unknown} value @returns {'outlined'|'raised'|'filled'} */
|
|
271
|
+
function normalizeVariant(value) {
|
|
272
|
+
return ['raised', 'filled'].includes(String(value))
|
|
273
|
+
? /** @type {'raised'|'filled'} */ (value) : 'outlined';
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** @param {unknown} value @returns {'vertical'|'horizontal'} */
|
|
277
|
+
function normalizeOrientation(value) {
|
|
278
|
+
return value === 'horizontal' ? 'horizontal' : 'vertical';
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** @param {unknown} value @returns {1|2|3|4|5|6} */
|
|
282
|
+
function normalizeHeadingLevel(value) {
|
|
283
|
+
const level = Math.trunc(Number(value));
|
|
284
|
+
return /** @type {1|2|3|4|5|6} */ (level >= 1 && level <= 6 ? level : 3);
|
|
285
|
+
}
|