@zeyos/zx 4.3.0 → 4.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 +266 -1
- package/README.md +7 -3
- package/dist/types/components/activity-item/activity-item.d.ts +351 -0
- package/dist/types/components/activity-list/activity-list.d.ts +195 -0
- package/dist/types/components/app-sidebar/app-sidebar.d.ts +28 -0
- package/dist/types/components/button/button.d.ts +8 -0
- package/dist/types/components/calendar/calendar-model.d.ts +22 -0
- package/dist/types/components/calendar/calendar.d.ts +19 -0
- package/dist/types/components/card/card.d.ts +7 -0
- package/dist/types/components/card-view/card-view.d.ts +12 -4
- package/dist/types/components/card-view/record-card.d.ts +53 -5
- package/dist/types/components/code-editor/code-editor.d.ts +160 -0
- package/dist/types/components/code-editor/field-adapter.d.ts +5 -0
- package/dist/types/components/context-menu/context-menu.d.ts +18 -2
- package/dist/types/components/date-picker/date-picker.d.ts +8 -0
- package/dist/types/components/date-picker/time-picker.d.ts +7 -0
- package/dist/types/components/dialog/dialog.d.ts +29 -0
- package/dist/types/components/entity-ref/entity-ref.d.ts +331 -0
- package/dist/types/components/file-item/file-item.d.ts +234 -0
- package/dist/types/components/file-list/file-list.d.ts +171 -0
- package/dist/types/components/filter/filter.d.ts +91 -36
- package/dist/types/components/filter-panel/filter-panel.d.ts +449 -0
- package/dist/types/components/kanban-view/kanban-card.d.ts +138 -0
- package/dist/types/components/kanban-view/kanban-policy.d.ts +268 -0
- package/dist/types/components/kanban-view/kanban-view.d.ts +837 -26
- package/dist/types/components/launcher/launcher.d.ts +19 -12
- package/dist/types/components/list-toolbar/list-toolbar.d.ts +599 -0
- package/dist/types/components/menu-button/menu-button.d.ts +4 -37
- package/dist/types/components/message/message.d.ts +7 -0
- package/dist/types/components/modal/modal.d.ts +48 -0
- package/dist/types/components/navigation-bar/navigation-bar.d.ts +105 -21
- package/dist/types/components/panel/panel.d.ts +7 -0
- package/dist/types/components/search/search.d.ts +7 -0
- package/dist/types/components/select/select.d.ts +4 -0
- package/dist/types/components/sort-control/sort-control.d.ts +105 -0
- package/dist/types/components/stat-tile/stat-tile.d.ts +277 -0
- package/dist/types/components/tabbox/tabbox.d.ts +7 -0
- package/dist/types/components/table/table.d.ts +144 -0
- package/dist/types/components/table-view/table-view.d.ts +11 -5
- package/dist/types/components/timebox/timebox.d.ts +7 -0
- package/dist/types/components/value-list/value-list.d.ts +8 -0
- package/dist/types/components/view/record-view.d.ts +96 -5
- package/dist/types/core/component.d.ts +6 -1
- package/dist/types/core/icons.d.ts +17 -0
- package/dist/types/index.d.ts +13 -1
- package/dist/types/internal/app-rail.d.ts +10 -0
- package/dist/types/internal/menu-items.d.ts +134 -0
- package/dist/zx-zeyos.esm.js +651 -88
- package/dist/zx-zeyos.esm.min.js +8 -1
- package/dist/zx.css +6266 -3900
- package/dist/zx.esm.js +8487 -1805
- package/dist/zx.esm.min.js +10 -3
- package/dist/zx.global.js +8487 -1805
- package/dist/zx.global.min.js +10 -3
- package/dist/zx.min.css +1 -1
- package/docs/api.json +1811 -247
- package/docs/llms.md +345 -40
- package/docs/llms.txt +11 -7
- package/package.json +1 -2
- package/src/components/activity-item/activity-item.css +137 -0
- package/src/components/activity-item/activity-item.js +599 -0
- package/src/components/activity-list/activity-list.css +64 -0
- package/src/components/activity-list/activity-list.js +375 -0
- package/src/components/app-rail/app-rail.css +227 -19
- package/src/components/app-sidebar/app-sidebar.css +414 -24
- package/src/components/app-sidebar/app-sidebar.js +65 -5
- package/src/components/button/button.css +8 -0
- package/src/components/button/button.js +6 -1
- package/src/components/calendar/calendar-model.js +45 -0
- package/src/components/calendar/calendar.css +30 -10
- package/src/components/calendar/calendar.js +41 -13
- package/src/components/card/card.js +12 -1
- package/src/components/card-view/card-view.css +212 -9
- package/src/components/card-view/card-view.js +32 -16
- package/src/components/card-view/record-card.js +98 -21
- package/src/components/code-editor/code-editor.css +67 -0
- package/src/components/code-editor/code-editor.js +288 -0
- package/src/components/code-editor/field-adapter.js +34 -0
- package/src/components/context-menu/context-menu.css +80 -3
- package/src/components/context-menu/context-menu.js +22 -30
- package/src/components/date-picker/date-picker.js +22 -8
- package/src/components/date-picker/month-picker.js +3 -3
- package/src/components/date-picker/time-picker.js +20 -8
- package/src/components/dialog/dialog.js +88 -66
- package/src/components/dropdown/dropdown.js +2 -2
- package/src/components/entity-ref/entity-ref.css +182 -0
- package/src/components/entity-ref/entity-ref.js +496 -0
- package/src/components/file-item/file-item.css +88 -0
- package/src/components/file-item/file-item.js +371 -0
- package/src/components/file-list/file-list.css +51 -0
- package/src/components/file-list/file-list.js +320 -0
- package/src/components/filter/filter.css +27 -1
- package/src/components/filter/filter.js +199 -45
- package/src/components/filter-panel/filter-panel.css +104 -0
- package/src/components/filter-panel/filter-panel.js +964 -0
- package/src/components/kanban-view/kanban-card.js +239 -0
- package/src/components/kanban-view/kanban-policy.js +412 -0
- package/src/components/kanban-view/kanban-view.css +674 -36
- package/src/components/kanban-view/kanban-view.js +1762 -240
- package/src/components/launcher/launcher.js +59 -27
- package/src/components/list-toolbar/list-toolbar.css +189 -0
- package/src/components/list-toolbar/list-toolbar.js +851 -0
- package/src/components/menu-button/menu-button.css +80 -3
- package/src/components/menu-button/menu-button.js +21 -37
- package/src/components/message/message.css +14 -12
- package/src/components/message/message.js +13 -2
- package/src/components/modal/modal.css +15 -0
- package/src/components/modal/modal.js +141 -0
- package/src/components/navigation-bar/navigation-bar.css +77 -2
- package/src/components/navigation-bar/navigation-bar.js +171 -4
- package/src/components/panel/panel.js +13 -2
- package/src/components/search/search.js +17 -5
- package/src/components/select/field-adapter.js +4 -1
- package/src/components/select/select.js +3 -0
- package/src/components/sort-control/sort-control.css +17 -0
- package/src/components/sort-control/sort-control.js +140 -0
- package/src/components/stat-tile/stat-tile.css +213 -0
- package/src/components/stat-tile/stat-tile.js +590 -0
- package/src/components/tabbox/tabbox.js +12 -1
- package/src/components/table/table.css +51 -2
- package/src/components/table/table.js +298 -19
- package/src/components/table-view/table-view.css +8 -2
- package/src/components/table-view/table-view.js +21 -6
- package/src/components/tag-picker/tag-picker.css +3 -1
- package/src/components/timebox/timebox.js +18 -5
- package/src/components/value-list/value-list.js +22 -6
- package/src/components/view/record-view.css +5 -0
- package/src/components/view/record-view.js +100 -7
- package/src/core/component.js +8 -3
- package/src/core/icons.js +74 -5
- package/src/index.js +33 -3
- package/src/internal/app-rail.js +19 -1
- package/src/internal/menu-items.js +142 -0
- package/styles/print.css +192 -0
- package/styles/zx.css +11 -0
- package/DESIGN-SYSTEM.md +0 -182
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { Component } from '../../core/component.js';
|
|
3
|
+
import { h, restoreTarget, safeHref, snapshotTarget } from '../../core/dom.js';
|
|
4
|
+
import { formatCurrency, formatFileSize, formatNumber, formatPercent } from '../../core/format.js';
|
|
5
|
+
import { printf } from '../../core/i18n.js';
|
|
6
|
+
import { icon as createIcon } from '../../core/icons.js';
|
|
7
|
+
import { clamp } from '../../core/util.js';
|
|
8
|
+
import { skeleton } from '../skeleton/skeleton.js';
|
|
9
|
+
|
|
10
|
+
/** @typedef {'number'|'currency'|'percent'|'fileSize'|null} StatTileFormatName */
|
|
11
|
+
/** @typedef {StatTileFormatName|((value: number|string|null|undefined) => string)} StatTileFormat */
|
|
12
|
+
/** @typedef {'neutral'|'accent'|'success'|'warning'|'danger'|'info'} StatTileKind */
|
|
13
|
+
/** @typedef {'positive'|'negative'|'neutral'} StatTileDeltaKind */
|
|
14
|
+
/** @typedef {'up'|'down'|'flat'} StatTileDirection */
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {Object} StatTileDelta
|
|
18
|
+
* @property {StatTileDirection} direction Which way the metric moved, read from the sign alone.
|
|
19
|
+
* @property {string} sign `'+'`, `'−'` (U+2212), or `''` for an unchanged metric.
|
|
20
|
+
* @property {string} magnitude The formatted absolute change, without a sign.
|
|
21
|
+
* @property {string} text The visible change: `sign` followed by `magnitude`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {Object} StatTileNameParts
|
|
26
|
+
* @property {string} [label] The metric's name.
|
|
27
|
+
* @property {string} [value] The formatted value.
|
|
28
|
+
* @property {string} [delta] The change, already worded for speech (`'up 2'`, not `'+2'`).
|
|
29
|
+
* @property {string} [deltaLabel] What the change is measured against.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {Object} StatTileOptions
|
|
34
|
+
* @property {string} [label=''] The metric's name, set small above the value.
|
|
35
|
+
* @property {number|string|null} [value=null] The metric. A string is rendered as given.
|
|
36
|
+
* @property {StatTileFormat} [format='number'] How a numeric value is rendered: a named mode, a
|
|
37
|
+
* callback receiving the raw value, or null to print the value unformatted.
|
|
38
|
+
* @property {string|null} [currency=null] ISO 4217 code used by `format: 'currency'`.
|
|
39
|
+
* @property {string} [locale] BCP 47 tag for every formatter; defaults to the active language.
|
|
40
|
+
* @property {number|string|null} [delta=null] Change against the previous period. Rendered with
|
|
41
|
+
* its own sign and an arrow, never with colour alone.
|
|
42
|
+
* @property {StatTileDeltaKind} [deltaKind='neutral'] Whether the change is good, bad, or merely a
|
|
43
|
+
* change. This — not the sign's direction — picks the delta colour: a rising ticket count is not
|
|
44
|
+
* a success, so "up is good" is never assumed.
|
|
45
|
+
* @property {StatTileFormat} [deltaFormat] How the delta is rendered; defaults to `format`.
|
|
46
|
+
* @property {string} [deltaLabel=''] What the change is measured against, e.g. `'vs. last week'`.
|
|
47
|
+
* @property {number[]} [trend=[]] Values for a decorative sparkline. Fewer than two usable numbers
|
|
48
|
+
* render nothing.
|
|
49
|
+
* @property {string|null} [icon=null] Icon name from `icons.js`, shown beside the label.
|
|
50
|
+
* @property {StatTileKind} [kind='neutral'] Semantic intent. It tints the icon and the sparkline
|
|
51
|
+
* and never the value, which stays at full text contrast whatever intent a record carries.
|
|
52
|
+
* @property {string|null} [href=null] Destination. A tile with an href is an `<a>`.
|
|
53
|
+
* @property {boolean} [clickable=false] Renders a `<button>` without an href. Passing `onclick`
|
|
54
|
+
* sets this implicitly; set it directly to subscribe with `on('click', …)` instead.
|
|
55
|
+
* @property {boolean} [loading=false] Renders a skeleton shaped like the tile.
|
|
56
|
+
* @property {Record<string, unknown>} [msg] Message overrides, keyed as `statTile.*`.
|
|
57
|
+
* @property {(event: CustomEvent<{event: MouseEvent}>) => void} [onclick] Activation listener; its
|
|
58
|
+
* presence is what turns a tile with no href into a button. It is the tile's `click` event, as
|
|
59
|
+
* every Zx component's `on*` option is, so it receives the `CustomEvent` carrying the native
|
|
60
|
+
* event in `detail.event`.
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
/** Semantic intents, in the order they are documented. */
|
|
64
|
+
const KINDS = Object.freeze(['neutral', 'accent', 'success', 'warning', 'danger', 'info']);
|
|
65
|
+
|
|
66
|
+
/** Delta intents. The sign says which way; this says whether that is good news. */
|
|
67
|
+
const DELTA_KINDS = Object.freeze(['positive', 'negative', 'neutral']);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The built-in English text behind every `statTile.*` key. Kept as one table rather than repeated
|
|
71
|
+
* at each call site so the fallbacks cannot drift from each other, and so a host translating the
|
|
72
|
+
* component has the whole set in front of it.
|
|
73
|
+
*/
|
|
74
|
+
const MESSAGES = Object.freeze({
|
|
75
|
+
'statTile.up': 'up %1',
|
|
76
|
+
'statTile.down': 'down %1',
|
|
77
|
+
'statTile.unchanged': 'no change',
|
|
78
|
+
'statTile.loading': 'Loading'
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/** Arrow glyph per direction. A delta is never colour alone, so the shape carries it too. */
|
|
82
|
+
const DIRECTION_ICONS = Object.freeze({ up: 'chevron-up', down: 'chevron-down', flat: 'minus' });
|
|
83
|
+
|
|
84
|
+
/** The sparkline's user-unit box. It is stretched to the rendered width by the viewBox. */
|
|
85
|
+
const TREND_WIDTH = 100;
|
|
86
|
+
const TREND_HEIGHT = 30;
|
|
87
|
+
/** Half the stroke plus a little, so the extreme points are not clipped by the box. */
|
|
88
|
+
const TREND_INSET = 2;
|
|
89
|
+
|
|
90
|
+
/** @type {Readonly<StatTileOptions>} */
|
|
91
|
+
const defaults = Object.freeze({
|
|
92
|
+
label: '',
|
|
93
|
+
value: null,
|
|
94
|
+
format: 'number',
|
|
95
|
+
currency: null,
|
|
96
|
+
delta: null,
|
|
97
|
+
deltaKind: 'neutral',
|
|
98
|
+
deltaLabel: '',
|
|
99
|
+
trend: [],
|
|
100
|
+
icon: null,
|
|
101
|
+
kind: 'neutral',
|
|
102
|
+
href: null,
|
|
103
|
+
clickable: false,
|
|
104
|
+
loading: false
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Renders a metric: a label, the number it names, and — optionally — how that number moved.
|
|
111
|
+
*
|
|
112
|
+
* The value is the loudest thing on the tile; the label sits small above it and the delta and the
|
|
113
|
+
* sparkline are subordinate to both. Nothing here assumes a fixed set of metrics: the label, the
|
|
114
|
+
* intent colour, the icon and the formatting all arrive per instance, so a tile whose content
|
|
115
|
+
* comes from a server is the normal case rather than the exception.
|
|
116
|
+
*
|
|
117
|
+
* The element shape follows the tile's behaviour, and a tag cannot be rewritten in place, so the
|
|
118
|
+
* component normally builds its own root — pass `null` as the target, or use {@link statTile}. An
|
|
119
|
+
* existing element is adopted only when its tag already is the one the options call for; a mismatch
|
|
120
|
+
* throws rather than silently shipping a `<div>` with a click handler or a link that cannot
|
|
121
|
+
* navigate.
|
|
122
|
+
*
|
|
123
|
+
* @fires StatTile#click
|
|
124
|
+
* @extends {Component<StatTileOptions>}
|
|
125
|
+
*/
|
|
126
|
+
export class StatTile extends Component {
|
|
127
|
+
static cssName = 'stat-tile';
|
|
128
|
+
|
|
129
|
+
/** @type {Readonly<StatTileOptions>} */
|
|
130
|
+
static defaults = defaults;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Creates or adopts one metric tile.
|
|
134
|
+
* @param {Element|string|null} [target=null] Existing element of the required tag, or null.
|
|
135
|
+
* @param {StatTileOptions} [options={}] Tile options.
|
|
136
|
+
*/
|
|
137
|
+
constructor(target = null, options = {}) {
|
|
138
|
+
// `Component` consumes every function-valued `on*` option into a component event listener, so
|
|
139
|
+
// `options.onclick` no longer exists by the time `render()` chooses the element's tag. The
|
|
140
|
+
// shape decision is taken here, before the base constructor, and carried across as a flag.
|
|
141
|
+
super(target, {
|
|
142
|
+
...options,
|
|
143
|
+
clickable: options?.clickable === true || typeof options?.onclick === 'function'
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* @returns {HTMLElement}
|
|
149
|
+
* @throws {TypeError} When an adopted target is not the tag the options require.
|
|
150
|
+
*/
|
|
151
|
+
render() {
|
|
152
|
+
const tag = statTileTag(this.options);
|
|
153
|
+
this._createdRoot = this.el === null;
|
|
154
|
+
if (!this._createdRoot && this.el.tagName.toLowerCase() !== tag) {
|
|
155
|
+
throw new TypeError(
|
|
156
|
+
`StatTile needs a <${tag}> for these options, but the target is a `
|
|
157
|
+
+ `<${this.el.tagName.toLowerCase()}>. Pass null as the target to let it build its own.`);
|
|
158
|
+
}
|
|
159
|
+
const root = /** @type {HTMLElement} */ (this.el ?? h(tag));
|
|
160
|
+
this.el = root;
|
|
161
|
+
this._snapshot = this._createdRoot ? null : snapshotTarget(root);
|
|
162
|
+
this._closed = false;
|
|
163
|
+
this._state = { ...this.options };
|
|
164
|
+
|
|
165
|
+
if (tag === 'button') /** @type {HTMLButtonElement} */ (root).type = 'button';
|
|
166
|
+
fillStatTile(root, this._state, (key, ...args) => this._message(key, ...args));
|
|
167
|
+
if (tag !== 'div') {
|
|
168
|
+
this.listen(root, 'click', (event) => {
|
|
169
|
+
this.emit('click', { event: /** @type {MouseEvent} */ (event) });
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
return root;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Replaces any subset of the tile's content and redraws it. This is how a tile leaves
|
|
177
|
+
* `loading: true` behind once the server answers.
|
|
178
|
+
* @param {StatTileOptions} options Options to merge over the current ones.
|
|
179
|
+
* @returns {this}
|
|
180
|
+
* @throws {TypeError} When the merged options would need a different element tag.
|
|
181
|
+
*/
|
|
182
|
+
update(options = {}) {
|
|
183
|
+
const next = { ...this._state, ...options };
|
|
184
|
+
const tag = this.el.tagName.toLowerCase();
|
|
185
|
+
if (statTileTag(next) !== tag) {
|
|
186
|
+
throw new TypeError(
|
|
187
|
+
`StatTile cannot change from <${tag}> to <${statTileTag(next)}> in place: href and `
|
|
188
|
+
+ 'clickable decide the element, and an element\'s tag is fixed once it exists.');
|
|
189
|
+
}
|
|
190
|
+
this._state = next;
|
|
191
|
+
fillStatTile(/** @type {HTMLElement} */ (this.el), next, (key, ...args) => this._message(key, ...args));
|
|
192
|
+
return this;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Reports the tile's own accessible name, which is what assistive technology announces.
|
|
197
|
+
* @returns {string} Label, value, and delta, in that order.
|
|
198
|
+
*/
|
|
199
|
+
getAccessibleName() {
|
|
200
|
+
return this.el.getAttribute('aria-label') ?? '';
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Aborts listeners and restores an adopted target to the state it was found in.
|
|
205
|
+
* @returns {void}
|
|
206
|
+
*/
|
|
207
|
+
destroy() {
|
|
208
|
+
if (this._closed) return;
|
|
209
|
+
this._closed = true;
|
|
210
|
+
const root = this.el;
|
|
211
|
+
super.destroy();
|
|
212
|
+
if (!this._createdRoot && root) restoreTarget(root, this._snapshot);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Resolves a message through the host translator, falling back to the built-in English text.
|
|
217
|
+
* @param {string} key Message key.
|
|
218
|
+
* @param {...unknown} args Interpolation values.
|
|
219
|
+
* @returns {string}
|
|
220
|
+
*/
|
|
221
|
+
_message(key, ...args) {
|
|
222
|
+
const resolved = this.msg(key, ...args);
|
|
223
|
+
return resolved === key ? printf(MESSAGES[key] ?? key, args) : resolved;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Creates a tile that builds its own root: the convenience form of `new StatTile(null, options)`,
|
|
229
|
+
* the way `tooltip()` stands in for `new Tooltip(…)`.
|
|
230
|
+
*
|
|
231
|
+
* It returns the component, not an element, so there is one construction idiom and one click path.
|
|
232
|
+
* Call `.toElement()` for the node to place.
|
|
233
|
+
*
|
|
234
|
+
* @param {StatTileOptions} [opts={}] Tile options.
|
|
235
|
+
* @returns {StatTile} The component, whose root is an `<a>`, a `<button>`, or a `<div>`.
|
|
236
|
+
*/
|
|
237
|
+
export function statTile(opts = {}) {
|
|
238
|
+
return new StatTile(null, opts);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Formats the metric itself.
|
|
243
|
+
*
|
|
244
|
+
* A string value is the host's own final text and is passed through untouched; an explicit
|
|
245
|
+
* callback outranks that, because a host that supplies one has asked to decide every case.
|
|
246
|
+
*
|
|
247
|
+
* @param {number|string|null|undefined} value The metric.
|
|
248
|
+
* @param {{format?: StatTileFormat, locale?: string, currency?: string|null}} [options={}] Mode,
|
|
249
|
+
* language, and currency.
|
|
250
|
+
* @returns {string} The text to display; empty for a missing value.
|
|
251
|
+
*/
|
|
252
|
+
export function formatStatValue(value, options = {}) {
|
|
253
|
+
const { format = 'number', locale, currency = null } = options;
|
|
254
|
+
if (typeof format === 'function') return asText(format(value));
|
|
255
|
+
if (typeof value === 'string') return value;
|
|
256
|
+
if (value === null || value === undefined) return '';
|
|
257
|
+
|
|
258
|
+
switch (format) {
|
|
259
|
+
case 'currency': return formatCurrency(value, currency, { locale });
|
|
260
|
+
case 'percent': return formatPercent(value, { locale });
|
|
261
|
+
case 'fileSize': return formatFileSize(value, { locale });
|
|
262
|
+
case 'number': return formatNumber(value, { locale });
|
|
263
|
+
default: return asText(value);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Describes a change without judging it.
|
|
269
|
+
*
|
|
270
|
+
* The direction is read from the sign and nothing else; whether that direction is good news is
|
|
271
|
+
* `deltaKind`'s business, not this function's. The magnitude is formatted from the absolute value
|
|
272
|
+
* and the sign is prepended, because no locale prints a leading `+` on its own.
|
|
273
|
+
*
|
|
274
|
+
* @param {number|string|null|undefined} delta The change.
|
|
275
|
+
* @param {{format?: StatTileFormat, locale?: string, currency?: string|null}} [options={}]
|
|
276
|
+
* Formatting, defaulting to the tile's own.
|
|
277
|
+
* @returns {StatTileDelta|null} The change, or null when there is none to show.
|
|
278
|
+
*/
|
|
279
|
+
export function statDelta(delta, options = {}) {
|
|
280
|
+
const number = finite(delta);
|
|
281
|
+
if (number === null) return null;
|
|
282
|
+
|
|
283
|
+
const direction = number > 0 ? 'up' : number < 0 ? 'down' : 'flat';
|
|
284
|
+
// U+2212 MINUS SIGN rather than a hyphen: it is the same width as the digits beside it, which
|
|
285
|
+
// keeps a column of tiles aligned. The accessible name never uses it — it says "down" instead.
|
|
286
|
+
const sign = direction === 'up' ? '+' : direction === 'down' ? '−' : '';
|
|
287
|
+
const magnitude = formatStatValue(Math.abs(number), options);
|
|
288
|
+
return { direction, sign, magnitude, text: `${sign}${magnitude}` };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Chooses the element a tile must be, from what the tile does.
|
|
293
|
+
*
|
|
294
|
+
* A destination is a link, an action is a button, and a tile that does neither is a plain
|
|
295
|
+
* container. A `<div>` never gets a click handler, and an href that could execute script is not a
|
|
296
|
+
* destination at all — it falls through to whichever shape the remaining options justify.
|
|
297
|
+
*
|
|
298
|
+
* @param {{href?: string|null, clickable?: boolean, onclick?: unknown}} [options={}] The options
|
|
299
|
+
* that carry behaviour.
|
|
300
|
+
* @returns {'a'|'button'|'div'} The tag to render.
|
|
301
|
+
*/
|
|
302
|
+
export function statTileTag(options = {}) {
|
|
303
|
+
if (safeHref(options.href)) return 'a';
|
|
304
|
+
if (options.clickable === true || typeof options.onclick === 'function') return 'button';
|
|
305
|
+
return 'div';
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Composes the tile's accessible name: label, then value, then how it changed.
|
|
310
|
+
*
|
|
311
|
+
* The sparkline is decoration and contributes nothing, and the delta arrives already worded —
|
|
312
|
+
* `'up 2'` rather than `'+2'` — because a screen reader should not have to interpret a glyph.
|
|
313
|
+
*
|
|
314
|
+
* @param {StatTileNameParts} [parts={}] The pieces, already formatted and translated.
|
|
315
|
+
* @returns {string} The name, with empty pieces dropped rather than left as stray punctuation.
|
|
316
|
+
*/
|
|
317
|
+
export function statTileName(parts = {}) {
|
|
318
|
+
const change = [parts.delta, parts.deltaLabel].map(trimmed).filter(Boolean).join(' ');
|
|
319
|
+
return [trimmed(parts.label), trimmed(parts.value), change].filter(Boolean).join(', ');
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Projects a series onto the sparkline's user-unit box.
|
|
324
|
+
*
|
|
325
|
+
* A series needs two usable numbers to be a line; a flat series is drawn down the middle rather
|
|
326
|
+
* than along the floor, which would read as zero.
|
|
327
|
+
*
|
|
328
|
+
* @param {unknown[]} values The series, in order. Unusable entries are dropped.
|
|
329
|
+
* @param {{width?: number, height?: number, inset?: number}} [box={}] The target box.
|
|
330
|
+
* @returns {string|null} An SVG `points` list, or null when there is no line to draw.
|
|
331
|
+
*/
|
|
332
|
+
export function statTrendPoints(values, box = {}) {
|
|
333
|
+
const { width = TREND_WIDTH, height = TREND_HEIGHT, inset = TREND_INSET } = box;
|
|
334
|
+
const series = /** @type {number[]} */ ((Array.isArray(values) ? values : [])
|
|
335
|
+
.map(finite)
|
|
336
|
+
.filter((entry) => entry !== null));
|
|
337
|
+
if (series.length < 2) return null;
|
|
338
|
+
|
|
339
|
+
const low = Math.min(...series);
|
|
340
|
+
const high = Math.max(...series);
|
|
341
|
+
const top = inset;
|
|
342
|
+
const bottom = Math.max(inset, height - inset);
|
|
343
|
+
const span = high - low;
|
|
344
|
+
const step = width / (series.length - 1);
|
|
345
|
+
|
|
346
|
+
return series.map((entry, index) => {
|
|
347
|
+
const x = index === series.length - 1 ? width : index * step;
|
|
348
|
+
const y = span === 0 ? (top + bottom) / 2 : bottom - ((entry - low) / span) * (bottom - top);
|
|
349
|
+
return `${round(x)},${round(y)}`;
|
|
350
|
+
}).join(' ');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Builds the tile's children into an element that already has the right tag, and (re)applies every
|
|
355
|
+
* attribute the tile owns. Shared by the factory and the component so there is one tile, not two.
|
|
356
|
+
* @param {HTMLElement} root The tile's root.
|
|
357
|
+
* @param {StatTileOptions} options Fully merged options.
|
|
358
|
+
* @param {(key: string, ...args: unknown[]) => string} message Message resolver.
|
|
359
|
+
* @returns {void}
|
|
360
|
+
*/
|
|
361
|
+
function fillStatTile(root, options, message) {
|
|
362
|
+
const kind = normalize(options.kind, KINDS, 'neutral');
|
|
363
|
+
const label = asText(options.label);
|
|
364
|
+
const loading = options.loading === true;
|
|
365
|
+
|
|
366
|
+
// `add` rather than an assignment to `className`: an adopted target keeps the classes its host
|
|
367
|
+
// put on it, and a redraw through `update()` does not quietly strip them.
|
|
368
|
+
root.classList.add('zx-stat-tile');
|
|
369
|
+
root.dataset.kind = kind;
|
|
370
|
+
root.dataset.loading = String(loading);
|
|
371
|
+
if (root.tagName.toLowerCase() === 'a') {
|
|
372
|
+
// `setAttribute` rather than the reflected property, so `update()` can also take a destination
|
|
373
|
+
// away again — assigning `''` to `.href` leaves a link pointing at the current page.
|
|
374
|
+
const href = safeHref(options.href);
|
|
375
|
+
if (href) root.setAttribute('href', href);
|
|
376
|
+
else root.removeAttribute('href');
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (loading) {
|
|
380
|
+
root.setAttribute('aria-busy', 'true');
|
|
381
|
+
applyName(root, statTileName({ label, value: message('statTile.loading') }));
|
|
382
|
+
root.replaceChildren(loadingBody(options));
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
root.removeAttribute('aria-busy');
|
|
387
|
+
const value = formatStatValue(options.value, options);
|
|
388
|
+
const delta = statDelta(options.delta, {
|
|
389
|
+
format: options.deltaFormat ?? options.format,
|
|
390
|
+
locale: options.locale,
|
|
391
|
+
currency: options.currency
|
|
392
|
+
});
|
|
393
|
+
const deltaLabel = asText(options.deltaLabel);
|
|
394
|
+
|
|
395
|
+
applyName(root, statTileName({
|
|
396
|
+
label,
|
|
397
|
+
value,
|
|
398
|
+
delta: delta === null ? '' : spokenDelta(delta, message),
|
|
399
|
+
deltaLabel
|
|
400
|
+
}));
|
|
401
|
+
|
|
402
|
+
const header = h('div', { class: 'zx-stat-tile__header' },
|
|
403
|
+
h('span', { class: 'zx-stat-tile__label' }, label));
|
|
404
|
+
const glyph = tileIcon(options.icon);
|
|
405
|
+
if (glyph) header.append(h('span', { class: 'zx-stat-tile__icon' }, glyph));
|
|
406
|
+
|
|
407
|
+
// `Element`, not `HTMLElement`: the sparkline is an SVG element and belongs in the same list.
|
|
408
|
+
/** @type {Element[]} */
|
|
409
|
+
const children = [header, h('div', { class: 'zx-stat-tile__value' }, value)];
|
|
410
|
+
if (delta !== null) children.push(deltaBody(delta, deltaLabel, options.deltaKind));
|
|
411
|
+
const trend = trendBody(options.trend);
|
|
412
|
+
if (trend) children.push(trend);
|
|
413
|
+
root.replaceChildren(...children);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Builds the delta row: sign, arrow, and what the change is measured against.
|
|
418
|
+
* @param {StatTileDelta} delta The change.
|
|
419
|
+
* @param {string} deltaLabel Comparison text.
|
|
420
|
+
* @param {StatTileDeltaKind|undefined} deltaKind Whether the change is good news.
|
|
421
|
+
* @returns {HTMLElement}
|
|
422
|
+
*/
|
|
423
|
+
function deltaBody(delta, deltaLabel, deltaKind) {
|
|
424
|
+
// The whole row is hidden from assistive technology: the root's own name already carries the
|
|
425
|
+
// change, worded, and reading "+2" after "up 2" would say the same thing twice.
|
|
426
|
+
const row = h('div', {
|
|
427
|
+
class: 'zx-stat-tile__delta',
|
|
428
|
+
ariaHidden: 'true',
|
|
429
|
+
dataset: {
|
|
430
|
+
kind: normalize(deltaKind, DELTA_KINDS, 'neutral'),
|
|
431
|
+
direction: delta.direction
|
|
432
|
+
}
|
|
433
|
+
}, createIcon(DIRECTION_ICONS[delta.direction], { size: 12 }),
|
|
434
|
+
h('span', { class: 'zx-stat-tile__delta-value' }, delta.text));
|
|
435
|
+
|
|
436
|
+
if (deltaLabel) row.append(h('span', { class: 'zx-stat-tile__delta-label' }, deltaLabel));
|
|
437
|
+
return row;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Builds the sparkline, or nothing when the series cannot make a line.
|
|
442
|
+
*
|
|
443
|
+
* Inline SVG rather than `icon()`, because this is data and not a glyph — and `aria-hidden`,
|
|
444
|
+
* because a line nobody can read is decoration and has to say so.
|
|
445
|
+
* @param {unknown} values The series.
|
|
446
|
+
* @returns {SVGSVGElement|null}
|
|
447
|
+
*/
|
|
448
|
+
function trendBody(values) {
|
|
449
|
+
const points = statTrendPoints(/** @type {unknown[]} */ (values));
|
|
450
|
+
if (points === null) return null;
|
|
451
|
+
|
|
452
|
+
const svg = document.createElementNS(SVG_NS, 'svg');
|
|
453
|
+
svg.setAttribute('class', 'zx-stat-tile__trend');
|
|
454
|
+
svg.setAttribute('viewBox', `0 0 ${TREND_WIDTH} ${TREND_HEIGHT}`);
|
|
455
|
+
// The line stretches to whatever width the tile has; `non-scaling-stroke` below keeps that
|
|
456
|
+
// stretch from thinning the stroke with it.
|
|
457
|
+
svg.setAttribute('preserveAspectRatio', 'none');
|
|
458
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
459
|
+
svg.setAttribute('focusable', 'false');
|
|
460
|
+
|
|
461
|
+
const line = document.createElementNS(SVG_NS, 'polyline');
|
|
462
|
+
line.setAttribute('points', points);
|
|
463
|
+
line.setAttribute('fill', 'none');
|
|
464
|
+
line.setAttribute('stroke', 'currentColor');
|
|
465
|
+
line.setAttribute('stroke-width', '1.5');
|
|
466
|
+
line.setAttribute('stroke-linecap', 'round');
|
|
467
|
+
line.setAttribute('stroke-linejoin', 'round');
|
|
468
|
+
line.setAttribute('vector-effect', 'non-scaling-stroke');
|
|
469
|
+
svg.append(line);
|
|
470
|
+
return svg;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Builds the placeholder: the tile's own shape in grey, not a spinner, so nothing moves when the
|
|
475
|
+
* numbers arrive.
|
|
476
|
+
* @param {StatTileOptions} options Fully merged options.
|
|
477
|
+
* @returns {HTMLElement}
|
|
478
|
+
*/
|
|
479
|
+
function loadingBody(options) {
|
|
480
|
+
const blocks = [
|
|
481
|
+
h('div', { class: 'zx-stat-tile__header' }, skeleton({ width: '55%', height: '0.75rem' })),
|
|
482
|
+
skeleton({ width: '70%', height: '1.75rem', radius: 'md' })
|
|
483
|
+
];
|
|
484
|
+
if (options.delta !== null && options.delta !== undefined) {
|
|
485
|
+
blocks.push(skeleton({ width: '40%', height: '0.75rem' }));
|
|
486
|
+
}
|
|
487
|
+
if (statTrendPoints(/** @type {unknown[]} */ (options.trend)) !== null) {
|
|
488
|
+
blocks.push(skeleton({ width: '100%', height: `${TREND_HEIGHT}px`, radius: 'md' }));
|
|
489
|
+
}
|
|
490
|
+
return h('div', { class: 'zx-stat-tile__loading' }, ...blocks);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Renders the tile's icon.
|
|
495
|
+
*
|
|
496
|
+
* The icon travels with the metric, which means it comes from a server: a name the built-in set
|
|
497
|
+
* does not carry is bad data on one tile, not a reason for the dashboard around it to stop
|
|
498
|
+
* rendering.
|
|
499
|
+
*
|
|
500
|
+
* This used to wrap `icon()` in a `try`/`catch`, because `icon()` threw `RangeError` on a name it
|
|
501
|
+
* could not resolve. It no longer does — it renders an empty placeholder of the right size and
|
|
502
|
+
* reports the name once — so the guard is gone with it. The difference on screen is that the tile
|
|
503
|
+
* now keeps the icon's 16px box instead of closing the gap, which is the better of the two:
|
|
504
|
+
* a row of tiles stays aligned when one of them has a name nobody configured.
|
|
505
|
+
* @param {unknown} name Icon name.
|
|
506
|
+
* @returns {Element|null}
|
|
507
|
+
*/
|
|
508
|
+
function tileIcon(name) {
|
|
509
|
+
return name ? createIcon(String(name), { size: 16 }) : null;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Words a change for speech, so the name says "up 2" where the tile shows "+2" and an arrow.
|
|
514
|
+
* @param {StatTileDelta} delta The change.
|
|
515
|
+
* @param {(key: string, ...args: unknown[]) => string} message Message resolver.
|
|
516
|
+
* @returns {string}
|
|
517
|
+
*/
|
|
518
|
+
function spokenDelta(delta, message) {
|
|
519
|
+
if (delta.direction === 'flat') return message('statTile.unchanged');
|
|
520
|
+
return message(delta.direction === 'up' ? 'statTile.up' : 'statTile.down', delta.magnitude);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Names the tile.
|
|
525
|
+
*
|
|
526
|
+
* A link and a button take their name from `aria-label` alone. A plain tile is not a widget, so it
|
|
527
|
+
* needs a role before a name means anything: `group` gives it one without hiding the text inside
|
|
528
|
+
* it, which `img` would.
|
|
529
|
+
* @param {HTMLElement} root The tile's root.
|
|
530
|
+
* @param {string} name The composed name.
|
|
531
|
+
* @returns {void}
|
|
532
|
+
*/
|
|
533
|
+
function applyName(root, name) {
|
|
534
|
+
const plain = root.tagName.toLowerCase() === 'div';
|
|
535
|
+
if (name) {
|
|
536
|
+
root.setAttribute('aria-label', name);
|
|
537
|
+
if (plain) root.setAttribute('role', 'group');
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
// An empty tile has nothing to be named, and a named-nothing role is worse than no role.
|
|
541
|
+
root.removeAttribute('aria-label');
|
|
542
|
+
if (plain) root.removeAttribute('role');
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** @param {unknown} value @param {readonly string[]} allowed @param {string} fallback @returns {any} */
|
|
546
|
+
function normalize(value, allowed, fallback) {
|
|
547
|
+
return allowed.includes(/** @type {string} */ (value)) ? value : fallback;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Reads a usable number, or nothing.
|
|
552
|
+
*
|
|
553
|
+
* `Number()` alone would turn a gap in a series — `null`, `''`, a stray `false` — into a hard zero,
|
|
554
|
+
* which a sparkline then draws as a crash to the floor and a delta reports as "no change". A gap is
|
|
555
|
+
* absent data, not the number nought, so it is dropped instead.
|
|
556
|
+
* @param {unknown} value Candidate number.
|
|
557
|
+
* @returns {number|null}
|
|
558
|
+
*/
|
|
559
|
+
function finite(value) {
|
|
560
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
|
561
|
+
// Only numbers and numeric strings are numbers. `Number()` says otherwise about `null`, `''`,
|
|
562
|
+
// `false` and `[]` — all of which it reads as 0 — so they never reach it.
|
|
563
|
+
if (typeof value !== 'string') return null;
|
|
564
|
+
const text = value.trim();
|
|
565
|
+
if (text === '') return null;
|
|
566
|
+
const number = Number(text);
|
|
567
|
+
return Number.isFinite(number) ? number : null;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** @param {unknown} value @returns {string} */
|
|
571
|
+
function asText(value) {
|
|
572
|
+
return value === null || value === undefined ? '' : String(value);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** @param {unknown} value @returns {string} */
|
|
576
|
+
function trimmed(value) {
|
|
577
|
+
return asText(value).trim();
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** @param {number} value @returns {number} Two decimals, which is finer than one device pixel here. */
|
|
581
|
+
function round(value) {
|
|
582
|
+
return Math.round(clamp(value, -1e6, 1e6) * 100) / 100;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Fired when a linked or clickable tile is activated. A tile with neither an href nor a click
|
|
587
|
+
* handler is a `<div>` and never emits it.
|
|
588
|
+
* @event StatTile#click
|
|
589
|
+
* @type {CustomEvent<{event: MouseEvent}>}
|
|
590
|
+
*/
|
|
@@ -179,7 +179,7 @@ export class Tabbox extends Component {
|
|
|
179
179
|
children.push(h('span', {
|
|
180
180
|
class: 'zx-tabbox__close zx-icon-btn',
|
|
181
181
|
ariaHidden: 'true',
|
|
182
|
-
title: 'Close'
|
|
182
|
+
title: this._message('tabbox.close', 'Close')
|
|
183
183
|
}, icon('x', { size: 14 })));
|
|
184
184
|
}
|
|
185
185
|
const tab = /** @type {HTMLButtonElement} */ (h('button', {
|
|
@@ -467,6 +467,17 @@ export class Tabbox extends Component {
|
|
|
467
467
|
_recordForTab(tab) {
|
|
468
468
|
return this._tabs.find((record) => record.tab === tab) ?? null;
|
|
469
469
|
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Resolves a message through the host translator, falling back to the built-in English text.
|
|
473
|
+
* @param {string} key Message key.
|
|
474
|
+
* @param {string} fallback English default.
|
|
475
|
+
* @returns {string}
|
|
476
|
+
*/
|
|
477
|
+
_message(key, fallback) {
|
|
478
|
+
const message = this.msg(key);
|
|
479
|
+
return message === key ? fallback : message;
|
|
480
|
+
}
|
|
470
481
|
}
|
|
471
482
|
|
|
472
483
|
/**
|
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
.zx-table__column-controls {
|
|
6
6
|
position: relative;
|
|
7
7
|
inline-size: max-content;
|
|
8
|
-
|
|
8
|
+
/* `auto` on the inline start, so the controls sit at the trailing edge of the table in either
|
|
9
|
+
direction. The four-value `margin` shorthand this replaces resolved to the physical left. */
|
|
10
|
+
margin-block: 0 var(--zx-space-2);
|
|
11
|
+
margin-inline: auto 0;
|
|
9
12
|
}
|
|
10
13
|
|
|
11
14
|
.zx-table__reorder-status {
|
|
@@ -261,9 +264,12 @@
|
|
|
261
264
|
text-align: end;
|
|
262
265
|
}
|
|
263
266
|
|
|
267
|
+
/* `left` is the legacy spelling of `start`, exactly as `right` above is the legacy spelling of
|
|
268
|
+
`end`: the alignment follows the writing direction, so a column pinned to `left` sits on the
|
|
269
|
+
right of an RTL table. A caller that means the physical edge sets `text-align` itself. */
|
|
264
270
|
.zx-table th[data-align="left"],
|
|
265
271
|
.zx-table td[data-align="left"] {
|
|
266
|
-
text-align:
|
|
272
|
+
text-align: start;
|
|
267
273
|
}
|
|
268
274
|
|
|
269
275
|
.zx-table__sort-button {
|
|
@@ -379,6 +385,49 @@
|
|
|
379
385
|
min-inline-size: 0;
|
|
380
386
|
}
|
|
381
387
|
|
|
388
|
+
/* --- Group and section rows (Table rowKinds) ------------------------------------------------ */
|
|
389
|
+
|
|
390
|
+
/*
|
|
391
|
+
* A `span` kind renders one `<th scope="colgroup">` across the whole row, because the cell labels
|
|
392
|
+
* the columns of the records beneath it rather than holding a value of its own. Using `<th>` for
|
|
393
|
+
* that reason means inheriting the header's look and its sticky positioning, neither of which
|
|
394
|
+
* belongs on a body cell — the sticky one would pin every section to the top of the scroller and
|
|
395
|
+
* pile them up there — so both are taken back off here.
|
|
396
|
+
*
|
|
397
|
+
* What is NOT here is a look. No tint, no weight, no rule above it: a billing section is not a
|
|
398
|
+
* kanban swimlane, a default would be the first thing every consumer overrides, and `rowKinds[kind]
|
|
399
|
+
* .class` is where the decision belongs.
|
|
400
|
+
*/
|
|
401
|
+
.zx-table .zx-table__body th.zx-table__section-cell {
|
|
402
|
+
position: static;
|
|
403
|
+
z-index: auto;
|
|
404
|
+
background: none;
|
|
405
|
+
color: inherit;
|
|
406
|
+
font-size: inherit;
|
|
407
|
+
font-weight: inherit;
|
|
408
|
+
text-align: start;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
.zx-table__body tr:last-child > .zx-table__section-cell {
|
|
412
|
+
border-block-end: 0;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/*
|
|
416
|
+
* Stacked, a spanning row is its heading and nothing else. The labelled-line treatment is what the
|
|
417
|
+
* rest of the card is made of, and applying it here produces a heading followed by a column of
|
|
418
|
+
* empty labelled blanks — the exact artefact hosts are writing stylesheet workarounds for.
|
|
419
|
+
*/
|
|
420
|
+
.zx-table[data-stacked] .zx-table__body > tr[data-span] > * {
|
|
421
|
+
display: block;
|
|
422
|
+
inline-size: auto;
|
|
423
|
+
padding-inline: 0;
|
|
424
|
+
text-align: start;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
.zx-table[data-stacked] .zx-table__body > tr[data-span] > *::before {
|
|
428
|
+
content: none;
|
|
429
|
+
}
|
|
430
|
+
|
|
382
431
|
/* --- Inline editing (Table editMode) -------------------------------------------------------- */
|
|
383
432
|
|
|
384
433
|
/* Reserve a control-sized content box in every body cell as soon as the table is editable, so
|