@adia-ai/web-components 0.6.17 → 0.6.19
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 +140 -0
- package/USAGE.md +6 -0
- package/components/button/button.a2ui.json +8 -1
- package/components/button/button.yaml +13 -1
- package/components/button/class.js +36 -0
- package/components/chart/chart.a2ui.json +5 -0
- package/components/chart/chart.d.ts +2 -0
- package/components/chart/chart.yaml +13 -0
- package/components/chart/class.js +13 -0
- package/components/drawer/class.js +60 -2
- package/components/drawer/drawer.a2ui.json +11 -1
- package/components/drawer/drawer.d.ts +2 -0
- package/components/drawer/drawer.yaml +26 -1
- package/components/segmented/class.js +23 -0
- package/components/segmented/segmented.a2ui.json +11 -4
- package/components/segmented/segmented.yaml +52 -6
- package/components/stat/stat.a2ui.json +7 -1
- package/components/stat/stat.d.ts +2 -0
- package/components/stat/stat.js +34 -5
- package/components/stat/stat.test.js +108 -0
- package/components/stat/stat.yaml +9 -0
- package/components/table/class.js +43 -8
- package/components/table/table.a2ui.json +2 -1
- package/components/table/table.css +20 -4
- package/components/table/table.d.ts +1 -1
- package/components/table/table.test.js +174 -0
- package/components/table/table.yaml +6 -2
- package/components/text/class.js +14 -4
- package/components/text/text.a2ui.json +46 -0
- package/components/text/text.css +41 -0
- package/components/text/text.d.ts +6 -0
- package/components/text/text.test.js +90 -0
- package/components/text/text.yaml +36 -0
- package/components/textarea/textarea.a2ui.json +25 -0
- package/components/textarea/textarea.yaml +23 -0
- package/components/toggle-scheme/class.js +6 -0
- package/components/toggle-scheme/toggle-scheme.yaml +7 -1
- package/core/element.js +13 -0
- package/css-module.d.ts +6 -0
- package/package.json +18 -5
|
@@ -4,7 +4,7 @@ tag: segmented-ui
|
|
|
4
4
|
component: Segmented
|
|
5
5
|
category: navigation
|
|
6
6
|
version: 1
|
|
7
|
-
description:
|
|
7
|
+
description: Single-select toggle group with an animated sliding indicator. Children must be segment-ui elements.
|
|
8
8
|
props:
|
|
9
9
|
value:
|
|
10
10
|
description: Value of the currently selected segment.
|
|
@@ -13,7 +13,11 @@ props:
|
|
|
13
13
|
events:
|
|
14
14
|
change:
|
|
15
15
|
description: Fired when the selected segment changes. detail contains { value }.
|
|
16
|
-
slots:
|
|
16
|
+
slots:
|
|
17
|
+
default:
|
|
18
|
+
description: Child segment-ui elements that form the toggle group. Children MUST be segment-ui — bare segment tags render text but are silently ignored for the sliding indicator and role/aria-checked state.
|
|
19
|
+
indicator:
|
|
20
|
+
description: Auto-created sliding indicator element prepended on first render.
|
|
17
21
|
states:
|
|
18
22
|
- name: idle
|
|
19
23
|
description: Default, ready for interaction.
|
|
@@ -24,10 +28,52 @@ a2ui:
|
|
|
24
28
|
anti_patterns: []
|
|
25
29
|
examples:
|
|
26
30
|
- name: basic-segmented
|
|
27
|
-
description:
|
|
28
|
-
a2ui:
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
description: Segmented control with three segment-ui children for view switching.
|
|
32
|
+
a2ui: >-
|
|
33
|
+
[
|
|
34
|
+
{
|
|
35
|
+
"id": "root",
|
|
36
|
+
"component": "Card",
|
|
37
|
+
"children": [
|
|
38
|
+
"sec"
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"id": "sec",
|
|
43
|
+
"component": "Section",
|
|
44
|
+
"children": [
|
|
45
|
+
"comp"
|
|
46
|
+
]
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"id": "comp",
|
|
50
|
+
"component": "Segmented",
|
|
51
|
+
"value": "daily",
|
|
52
|
+
"children": [
|
|
53
|
+
"s1",
|
|
54
|
+
"s2",
|
|
55
|
+
"s3"
|
|
56
|
+
]
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"id": "s1",
|
|
60
|
+
"component": "Segment",
|
|
61
|
+
"value": "daily",
|
|
62
|
+
"text": "Daily"
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"id": "s2",
|
|
66
|
+
"component": "Segment",
|
|
67
|
+
"value": "weekly",
|
|
68
|
+
"text": "Weekly"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"id": "s3",
|
|
72
|
+
"component": "Segment",
|
|
73
|
+
"value": "monthly",
|
|
74
|
+
"text": "Monthly"
|
|
75
|
+
}
|
|
76
|
+
]
|
|
31
77
|
keywords:
|
|
32
78
|
- segmented
|
|
33
79
|
- options
|
|
@@ -31,6 +31,11 @@
|
|
|
31
31
|
"type": "string",
|
|
32
32
|
"default": ""
|
|
33
33
|
},
|
|
34
|
+
"loading": {
|
|
35
|
+
"description": "Renders skeleton-ui shimmer placeholders in place of the value and change slots while data is fetching. Sets aria-busy=\"true\" on the host. Label and icon are preserved (they're static metadata, not fetched data). Toggle back to false when data arrives.",
|
|
36
|
+
"type": "boolean",
|
|
37
|
+
"default": false
|
|
38
|
+
},
|
|
34
39
|
"trend": {
|
|
35
40
|
"description": "Trend direction or narrative subtitle. Canonical values color the change badge (up=success, down=danger, neutral/flat=muted); any other string renders as caption-style text under the primary value.",
|
|
36
41
|
"type": "string",
|
|
@@ -50,7 +55,8 @@
|
|
|
50
55
|
"anti_patterns": [],
|
|
51
56
|
"category": "display",
|
|
52
57
|
"composes": [
|
|
53
|
-
"icon-ui"
|
|
58
|
+
"icon-ui",
|
|
59
|
+
"skeleton-ui"
|
|
54
60
|
],
|
|
55
61
|
"events": {},
|
|
56
62
|
"examples": [
|
|
@@ -19,6 +19,8 @@ export class UIStat extends UIElement {
|
|
|
19
19
|
icon: string;
|
|
20
20
|
/** Eyebrow label describing the metric */
|
|
21
21
|
label: string;
|
|
22
|
+
/** Renders skeleton-ui shimmer placeholders in place of the value and change slots while data is fetching. Sets aria-busy="true" on the host. Label and icon are preserved (they're static metadata, not fetched data). Toggle back to false when data arrives. */
|
|
23
|
+
loading: boolean;
|
|
22
24
|
/** Trend direction or narrative subtitle. Canonical values color the change badge (up=success, down=danger, neutral/flat=muted); any other string renders as caption-style text under the primary value. */
|
|
23
25
|
trend: string;
|
|
24
26
|
/** The primary metric value to display */
|
package/components/stat/stat.js
CHANGED
|
@@ -12,11 +12,12 @@ import { UIElement } from '../../core/element.js';
|
|
|
12
12
|
|
|
13
13
|
class UIStat extends UIElement {
|
|
14
14
|
static properties = {
|
|
15
|
-
value:
|
|
16
|
-
label:
|
|
17
|
-
change:
|
|
18
|
-
trend:
|
|
19
|
-
icon:
|
|
15
|
+
value: { type: String, default: '', reflect: true },
|
|
16
|
+
label: { type: String, default: '', reflect: true },
|
|
17
|
+
change: { type: String, default: '', reflect: true },
|
|
18
|
+
trend: { type: String, default: '', reflect: true },
|
|
19
|
+
icon: { type: String, default: '', reflect: true },
|
|
20
|
+
loading: { type: Boolean, default: false, reflect: true },
|
|
20
21
|
};
|
|
21
22
|
|
|
22
23
|
static template = () => null;
|
|
@@ -59,6 +60,34 @@ class UIStat extends UIElement {
|
|
|
59
60
|
render() {
|
|
60
61
|
if (!this.#valueEl) return;
|
|
61
62
|
|
|
63
|
+
// ── Loading state ──
|
|
64
|
+
// When [loading], render skeleton-ui into the value + change slots and set
|
|
65
|
+
// aria-busy on the host. Label is preserved (it's static metadata, not
|
|
66
|
+
// fetched data). Icon is preserved too. When loading flips to false on
|
|
67
|
+
// first non-empty value write, slots restore to text content automatically.
|
|
68
|
+
if (this.loading) {
|
|
69
|
+
this.setAttribute('aria-busy', 'true');
|
|
70
|
+
// Use innerHTML so skeleton-ui auto-registers via the barrel; consumers
|
|
71
|
+
// who tree-shake skeleton-ui out will see plain shimmer-less placeholders.
|
|
72
|
+
// Width 60% / 40% / 2em / 1em chosen to roughly mirror the rendered
|
|
73
|
+
// value+change visual mass without being so wide as to look like text.
|
|
74
|
+
this.#valueEl.textContent = '';
|
|
75
|
+
this.#valueEl.innerHTML = '<skeleton-ui width="60%" height="2em" radius="sm"></skeleton-ui>';
|
|
76
|
+
this.#changeEl.textContent = '';
|
|
77
|
+
this.#changeEl.innerHTML = '<skeleton-ui width="40%" height="1em" radius="sm"></skeleton-ui>';
|
|
78
|
+
this.#changeEl.hidden = false;
|
|
79
|
+
// Icon stays as-is (metadata, not data).
|
|
80
|
+
if (this.icon) {
|
|
81
|
+
this.#iconEl.setAttribute('name', this.icon);
|
|
82
|
+
this.#iconEl.hidden = false;
|
|
83
|
+
} else {
|
|
84
|
+
this.#iconEl.hidden = true;
|
|
85
|
+
}
|
|
86
|
+
this.#labelEl.textContent = this.label;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
this.removeAttribute('aria-busy');
|
|
62
91
|
this.#valueEl.textContent = this.value;
|
|
63
92
|
this.#labelEl.textContent = this.label;
|
|
64
93
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stat-ui — focused unit tests for the v0.6.18 `loading` boolean prop
|
|
3
|
+
* (FB-12 P2 resolution).
|
|
4
|
+
*
|
|
5
|
+
* Pre-v0.6.18: stat-ui rendered empty/zero values during data fetch with no
|
|
6
|
+
* visual indication. Consumers were forced to hand-roll skeleton-card
|
|
7
|
+
* workarounds with their own `@keyframes` CSS — duplicating skeleton-ui.
|
|
8
|
+
*
|
|
9
|
+
* v0.6.18 adds `loading: Boolean`. When set:
|
|
10
|
+
* - value + change slots render <skeleton-ui> shimmer placeholders
|
|
11
|
+
* - aria-busy="true" on the host
|
|
12
|
+
* - label + icon (static metadata) are preserved
|
|
13
|
+
* - toggling off restores text content + clears aria-busy
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
17
|
+
import '../../core/element.js';
|
|
18
|
+
import './stat.js';
|
|
19
|
+
// skeleton-ui is referenced by stat-ui at runtime when [loading]; load it so
|
|
20
|
+
// the element gets defined (otherwise the inner <skeleton-ui> stays an
|
|
21
|
+
// HTMLUnknownElement, which is still observable in the DOM but the assertions
|
|
22
|
+
// below want to confirm registration via tagName.)
|
|
23
|
+
import '../skeleton/skeleton.js';
|
|
24
|
+
|
|
25
|
+
const tick = () => new Promise((r) => queueMicrotask(r));
|
|
26
|
+
|
|
27
|
+
function mount(html) {
|
|
28
|
+
const wrap = document.createElement('div');
|
|
29
|
+
wrap.innerHTML = html;
|
|
30
|
+
document.body.appendChild(wrap);
|
|
31
|
+
return wrap.firstElementChild;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('stat-ui — v0.6.18 loading prop (FB-12 P2)', () => {
|
|
35
|
+
beforeEach(() => { document.body.innerHTML = ''; });
|
|
36
|
+
|
|
37
|
+
it('defaults loading to false; no aria-busy on host', async () => {
|
|
38
|
+
const el = mount('<stat-ui label="Total" value="1,234"></stat-ui>');
|
|
39
|
+
await tick();
|
|
40
|
+
expect(el.loading).toBe(false);
|
|
41
|
+
expect(el.hasAttribute('loading')).toBe(false);
|
|
42
|
+
expect(el.getAttribute('aria-busy')).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('reflects [loading] attribute to the property', () => {
|
|
46
|
+
const el = mount('<stat-ui label="Total" loading></stat-ui>');
|
|
47
|
+
expect(el.loading).toBe(true);
|
|
48
|
+
expect(el.hasAttribute('loading')).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('sets aria-busy="true" on host when [loading]', async () => {
|
|
52
|
+
const el = mount('<stat-ui label="Total" loading></stat-ui>');
|
|
53
|
+
await tick();
|
|
54
|
+
expect(el.getAttribute('aria-busy')).toBe('true');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('renders a <skeleton-ui> inside the value slot when [loading]', async () => {
|
|
58
|
+
const el = mount('<stat-ui label="Total" loading></stat-ui>');
|
|
59
|
+
await tick();
|
|
60
|
+
const valueSlot = el.querySelector(':scope > [slot="value"]');
|
|
61
|
+
expect(valueSlot).not.toBeNull();
|
|
62
|
+
const sk = valueSlot.querySelector('skeleton-ui');
|
|
63
|
+
expect(sk).not.toBeNull();
|
|
64
|
+
expect(sk.tagName.toLowerCase()).toBe('skeleton-ui');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('renders a <skeleton-ui> inside the change slot when [loading]', async () => {
|
|
68
|
+
const el = mount('<stat-ui label="Total" loading></stat-ui>');
|
|
69
|
+
await tick();
|
|
70
|
+
const changeSlot = el.querySelector(':scope > [slot="change"]');
|
|
71
|
+
expect(changeSlot).not.toBeNull();
|
|
72
|
+
expect(changeSlot.hidden).toBe(false);
|
|
73
|
+
const sk = changeSlot.querySelector('skeleton-ui');
|
|
74
|
+
expect(sk).not.toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('preserves label text content when [loading] (label is static metadata)', async () => {
|
|
78
|
+
const el = mount('<stat-ui label="Total Users" loading></stat-ui>');
|
|
79
|
+
await tick();
|
|
80
|
+
const labelSlot = el.querySelector(':scope > [slot="label"]');
|
|
81
|
+
expect(labelSlot.textContent).toBe('Total Users');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('restores value + change text + clears aria-busy when loading toggles off', async () => {
|
|
85
|
+
const el = mount('<stat-ui label="Total" loading></stat-ui>');
|
|
86
|
+
await tick();
|
|
87
|
+
// Now flip off + add real values
|
|
88
|
+
el.removeAttribute('loading');
|
|
89
|
+
el.setAttribute('value', '1,234');
|
|
90
|
+
el.setAttribute('change', '+12%');
|
|
91
|
+
await tick();
|
|
92
|
+
expect(el.getAttribute('aria-busy')).toBeNull();
|
|
93
|
+
expect(el.querySelector(':scope > [slot="value"]').textContent).toBe('1,234');
|
|
94
|
+
expect(el.querySelector(':scope > [slot="change"]').textContent).toBe('+12%');
|
|
95
|
+
// skeleton-ui children should be gone (textContent setter clobbers innerHTML)
|
|
96
|
+
expect(el.querySelector(':scope > [slot="value"] skeleton-ui')).toBeNull();
|
|
97
|
+
expect(el.querySelector(':scope > [slot="change"] skeleton-ui')).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('keeps icon visible when [loading] if icon prop is set', async () => {
|
|
101
|
+
const el = mount('<stat-ui label="Total" icon="users" loading></stat-ui>');
|
|
102
|
+
await tick();
|
|
103
|
+
const iconSlot = el.querySelector(':scope > [slot="icon"]');
|
|
104
|
+
expect(iconSlot).not.toBeNull();
|
|
105
|
+
expect(iconSlot.hidden).toBe(false);
|
|
106
|
+
expect(iconSlot.getAttribute('name')).toBe('users');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -5,6 +5,7 @@ name: UIStat
|
|
|
5
5
|
tag: stat-ui
|
|
6
6
|
composes:
|
|
7
7
|
- icon-ui
|
|
8
|
+
- skeleton-ui
|
|
8
9
|
component: Stat
|
|
9
10
|
category: display
|
|
10
11
|
version: 1
|
|
@@ -22,6 +23,14 @@ props:
|
|
|
22
23
|
description: Eyebrow label describing the metric
|
|
23
24
|
type: string
|
|
24
25
|
default: ""
|
|
26
|
+
loading:
|
|
27
|
+
description: >-
|
|
28
|
+
Renders skeleton-ui shimmer placeholders in place of the value and change
|
|
29
|
+
slots while data is fetching. Sets aria-busy="true" on the host. Label
|
|
30
|
+
and icon are preserved (they're static metadata, not fetched data).
|
|
31
|
+
Toggle back to false when data arrives.
|
|
32
|
+
type: boolean
|
|
33
|
+
default: false
|
|
25
34
|
trend:
|
|
26
35
|
description: >-
|
|
27
36
|
Trend direction or narrative subtitle. Canonical values color the change
|
|
@@ -844,18 +844,52 @@ export class UITable extends UIElement {
|
|
|
844
844
|
let loadingEl = this.querySelector(':scope > [data-loading]');
|
|
845
845
|
|
|
846
846
|
if (this.loading) {
|
|
847
|
-
//
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
847
|
+
// Skeleton rows: render N ghost rows of <skeleton-ui> cells inside the
|
|
848
|
+
// body rowgroup. Preserves table layout (header + columns intact) while
|
|
849
|
+
// signalling pending data. aria-busy on host announces the busy state.
|
|
850
|
+
// Old behavior (progress-ui spinner overlay) hid the table layout — the
|
|
851
|
+
// yaml description always promised "skeleton rows", impl shipped overlay
|
|
852
|
+
// (yaml-vs-impl drift fixed in v0.6.18 per FB-12 P2).
|
|
853
|
+
this.setAttribute('aria-busy', 'true');
|
|
854
|
+
// Real rows reconciled above already cleared if data is empty; if data
|
|
855
|
+
// is present, leave it but layer skeletons on top of the body. Simpler:
|
|
856
|
+
// always replace body children with skeleton rows when loading.
|
|
857
|
+
const visCols = this.#visibleColumns;
|
|
858
|
+
const skeletonRowCount = this.paginate > 0 ? Math.min(this.paginate, 8) : 5;
|
|
859
|
+
const totalCellCount =
|
|
860
|
+
(this.expandable ? 1 : 0) +
|
|
861
|
+
(this.selectable ? 1 : 0) +
|
|
862
|
+
visCols.length;
|
|
863
|
+
|
|
864
|
+
// Remove all existing body children (real rows + detail rows) — they're
|
|
865
|
+
// replaced by skeleton rows while loading.
|
|
866
|
+
while (body.firstChild) body.firstChild.remove();
|
|
867
|
+
|
|
868
|
+
for (let r = 0; r < skeletonRowCount; r++) {
|
|
869
|
+
const row = document.createElement('div');
|
|
870
|
+
row.setAttribute('role', 'row');
|
|
871
|
+
row.setAttribute('data-skeleton-row', '');
|
|
872
|
+
for (let c = 0; c < totalCellCount; c++) {
|
|
873
|
+
const cell = document.createElement('div');
|
|
874
|
+
cell.setAttribute('role', 'gridcell');
|
|
875
|
+
const sk = document.createElement('skeleton-ui');
|
|
876
|
+
// Vary width across cells so the row reads as natural data rows,
|
|
877
|
+
// not a uniform bar. Pattern: 60% / 80% / 70% / 50% / 90%, cycling.
|
|
878
|
+
const widths = ['60%', '80%', '70%', '50%', '90%'];
|
|
879
|
+
sk.setAttribute('width', widths[c % widths.length]);
|
|
880
|
+
sk.setAttribute('height', '1em');
|
|
881
|
+
sk.setAttribute('radius', 'sm');
|
|
882
|
+
cell.appendChild(sk);
|
|
883
|
+
row.appendChild(cell);
|
|
884
|
+
}
|
|
885
|
+
body.appendChild(row);
|
|
855
886
|
}
|
|
887
|
+
// Remove legacy overlay if it lingers from a prior render.
|
|
888
|
+
if (loadingEl) loadingEl.remove();
|
|
856
889
|
if (emptyEl) emptyEl.remove();
|
|
857
890
|
} else if (this.#data.length === 0) {
|
|
858
891
|
// Show empty state
|
|
892
|
+
this.removeAttribute('aria-busy');
|
|
859
893
|
if (!emptyEl) {
|
|
860
894
|
emptyEl = document.createElement('div');
|
|
861
895
|
emptyEl.setAttribute('data-empty', '');
|
|
@@ -870,6 +904,7 @@ export class UITable extends UIElement {
|
|
|
870
904
|
if (loadingEl) loadingEl.remove();
|
|
871
905
|
} else {
|
|
872
906
|
// Remove both overlays
|
|
907
|
+
this.removeAttribute('aria-busy');
|
|
873
908
|
if (emptyEl) emptyEl.remove();
|
|
874
909
|
if (loadingEl) loadingEl.remove();
|
|
875
910
|
}
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"default": false
|
|
43
43
|
},
|
|
44
44
|
"loading": {
|
|
45
|
-
"description": "
|
|
45
|
+
"description": "Renders N ghost skeleton rows in place of the body data (count derived from `paginate` if set, else 5). Header + columns stay intact so the table layout is preserved while data fetches. Sets aria-busy=\"true\" on the host. Data updates are deferred until loading is set back to false.",
|
|
46
46
|
"type": "boolean",
|
|
47
47
|
"default": false
|
|
48
48
|
},
|
|
@@ -89,6 +89,7 @@
|
|
|
89
89
|
"icon-ui",
|
|
90
90
|
"progress-ui",
|
|
91
91
|
"pagination-ui",
|
|
92
|
+
"skeleton-ui",
|
|
92
93
|
"badge-ui"
|
|
93
94
|
],
|
|
94
95
|
"events": {
|
|
@@ -364,13 +364,29 @@
|
|
|
364
364
|
color: var(--table-fg-disabled);
|
|
365
365
|
}
|
|
366
366
|
|
|
367
|
-
/* ═══════ Loading ═══════
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
367
|
+
/* ═══════ Loading (skeleton rows) ═══════
|
|
368
|
+
Skeleton rows replace real rows while [loading] is set on the host
|
|
369
|
+
(see class.js #renderOverlays). Each row is a [data-skeleton-row]
|
|
370
|
+
containing <skeleton-ui> cells. Inherit body-row layout so column
|
|
371
|
+
widths track the header, then suppress hover/striping/click states
|
|
372
|
+
(no real data to interact with). */
|
|
373
|
+
|
|
374
|
+
[data-body] > [data-skeleton-row] {
|
|
371
375
|
pointer-events: none;
|
|
372
376
|
}
|
|
373
377
|
|
|
378
|
+
[data-body] > [data-skeleton-row]:hover {
|
|
379
|
+
background: transparent;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
:scope[striped] [data-body] > [data-skeleton-row]:nth-child(even) {
|
|
383
|
+
background: transparent;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/* No-op the dim-when-loading rule the old overlay relied on: with skeleton
|
|
387
|
+
rows, the body IS the loading affordance — dimming it would just blur
|
|
388
|
+
the shimmer. */
|
|
389
|
+
|
|
374
390
|
/* ═══════ Filter UI ═══════ */
|
|
375
391
|
|
|
376
392
|
[data-filter-btn] {
|
|
@@ -86,7 +86,7 @@ export class UITable extends UIElement {
|
|
|
86
86
|
density: 'compact' | 'standard' | 'comfortable';
|
|
87
87
|
/** Enable row expansion */
|
|
88
88
|
expandable: boolean;
|
|
89
|
-
/**
|
|
89
|
+
/** Renders N ghost skeleton rows in place of the body data (count derived from `paginate` if set, else 5). Header + columns stay intact so the table layout is preserved while data fetches. Sets aria-busy="true" on the host. Data updates are deferred until loading is set back to false. */
|
|
90
90
|
loading: boolean;
|
|
91
91
|
/** Rows per page. 0 = show all rows without pagination. When > 0, renders a pagination bar below the table. */
|
|
92
92
|
paginate: number;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* table-ui — focused unit tests for the v0.6.18 loading=skeleton-rows
|
|
3
|
+
* behavior change (FB-12 P2 resolution).
|
|
4
|
+
*
|
|
5
|
+
* Pre-v0.6.18: `loading=true` rendered a `<progress-ui>` spinner overlay
|
|
6
|
+
* inside the body via `[data-loading]`. The yaml description always said
|
|
7
|
+
* "Shows a loading overlay AND skeleton rows" but the impl only did the
|
|
8
|
+
* overlay — yaml-vs-impl drift since the initial table cut.
|
|
9
|
+
*
|
|
10
|
+
* v0.6.18 changes the loading branch to render N ghost skeleton rows
|
|
11
|
+
* (N = paginate if set, else 5) inside the [data-body] rowgroup. Header +
|
|
12
|
+
* columns stay intact so layout is preserved. Sets aria-busy="true" on the
|
|
13
|
+
* host. Old `[data-loading]` overlay element is removed if it lingers.
|
|
14
|
+
*
|
|
15
|
+
* Note: table-ui needs columns + data set imperatively (via the .columns /
|
|
16
|
+
* .data properties on the element) — declarative <col-def> children also
|
|
17
|
+
* work but require the col-def element to be registered first. Tests use
|
|
18
|
+
* the imperative path.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
22
|
+
import '../../core/element.js';
|
|
23
|
+
import './table.js';
|
|
24
|
+
import '../skeleton/skeleton.js';
|
|
25
|
+
|
|
26
|
+
const tick = () => new Promise((r) => queueMicrotask(r));
|
|
27
|
+
const raf = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
|
28
|
+
|
|
29
|
+
function mount(html) {
|
|
30
|
+
const wrap = document.createElement('div');
|
|
31
|
+
wrap.innerHTML = html;
|
|
32
|
+
document.body.appendChild(wrap);
|
|
33
|
+
return wrap.firstElementChild;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const COLS = [
|
|
37
|
+
{ key: 'id', label: 'ID' },
|
|
38
|
+
{ key: 'name', label: 'Name' },
|
|
39
|
+
{ key: 'email', label: 'Email' },
|
|
40
|
+
];
|
|
41
|
+
const ROWS = [
|
|
42
|
+
{ id: 1, name: 'Alice', email: 'alice@acme.com' },
|
|
43
|
+
{ id: 2, name: 'Bob', email: 'bob@acme.com' },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
describe('table-ui — v0.6.18 loading=skeleton-rows (FB-12 P2)', () => {
|
|
47
|
+
beforeEach(() => { document.body.innerHTML = ''; });
|
|
48
|
+
|
|
49
|
+
it('renders skeleton rows in [data-body] when [loading] is set', async () => {
|
|
50
|
+
const el = mount('<table-ui></table-ui>');
|
|
51
|
+
el.columns = COLS;
|
|
52
|
+
el.data = ROWS;
|
|
53
|
+
await tick();
|
|
54
|
+
el.setAttribute('loading', '');
|
|
55
|
+
await tick();
|
|
56
|
+
const body = el.querySelector(':scope > [data-body]');
|
|
57
|
+
expect(body).not.toBeNull();
|
|
58
|
+
const skRows = body.querySelectorAll('[data-skeleton-row]');
|
|
59
|
+
expect(skRows.length).toBeGreaterThan(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('sets aria-busy="true" on the host when [loading]', async () => {
|
|
63
|
+
const el = mount('<table-ui></table-ui>');
|
|
64
|
+
el.columns = COLS;
|
|
65
|
+
el.data = ROWS;
|
|
66
|
+
await tick();
|
|
67
|
+
el.setAttribute('loading', '');
|
|
68
|
+
await tick();
|
|
69
|
+
expect(el.getAttribute('aria-busy')).toBe('true');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('replaces real body rows with skeleton rows when [loading]', async () => {
|
|
73
|
+
const el = mount('<table-ui></table-ui>');
|
|
74
|
+
el.columns = COLS;
|
|
75
|
+
el.data = ROWS;
|
|
76
|
+
// table-ui uses requestAnimationFrame in #requestRender(), not microtasks.
|
|
77
|
+
// queueMicrotask-based tick() won't drain RAF callbacks — must await raf().
|
|
78
|
+
// Loop up to 5 RAF cycles in case the initial mount needs multiple renders
|
|
79
|
+
// to settle (columns set → render → data set → render).
|
|
80
|
+
for (let i = 0; i < 5; i++) {
|
|
81
|
+
await raf();
|
|
82
|
+
const body = el.querySelector(':scope > [data-body]');
|
|
83
|
+
if (body && body.children.length >= 2) break;
|
|
84
|
+
}
|
|
85
|
+
const body = el.querySelector(':scope > [data-body]');
|
|
86
|
+
expect(body).not.toBeNull();
|
|
87
|
+
// Real rows present (2)
|
|
88
|
+
const realRows = body.querySelectorAll(':scope > [role="row"]:not([data-skeleton-row])');
|
|
89
|
+
expect(realRows.length).toBe(2);
|
|
90
|
+
el.setAttribute('loading', '');
|
|
91
|
+
await raf();
|
|
92
|
+
// Real rows gone, skeleton rows present
|
|
93
|
+
expect(body.querySelectorAll(':scope > [role="row"]:not([data-skeleton-row])').length).toBe(0);
|
|
94
|
+
expect(body.querySelectorAll(':scope > [data-skeleton-row]').length).toBeGreaterThan(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('each skeleton row has cell count matching column count', async () => {
|
|
98
|
+
const el = mount('<table-ui></table-ui>');
|
|
99
|
+
el.columns = COLS; // 3 columns
|
|
100
|
+
el.data = ROWS;
|
|
101
|
+
await tick();
|
|
102
|
+
el.setAttribute('loading', '');
|
|
103
|
+
await tick();
|
|
104
|
+
const skRow = el.querySelector('[data-skeleton-row]');
|
|
105
|
+
expect(skRow.children.length).toBe(3);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('each skeleton cell contains a <skeleton-ui> shimmer element', async () => {
|
|
109
|
+
const el = mount('<table-ui></table-ui>');
|
|
110
|
+
el.columns = COLS;
|
|
111
|
+
el.data = ROWS;
|
|
112
|
+
await tick();
|
|
113
|
+
el.setAttribute('loading', '');
|
|
114
|
+
await tick();
|
|
115
|
+
const skCells = el.querySelectorAll('[data-skeleton-row] > [role="gridcell"]');
|
|
116
|
+
expect(skCells.length).toBeGreaterThan(0);
|
|
117
|
+
for (const cell of skCells) {
|
|
118
|
+
const sk = cell.querySelector('skeleton-ui');
|
|
119
|
+
expect(sk).not.toBeNull();
|
|
120
|
+
expect(sk.tagName.toLowerCase()).toBe('skeleton-ui');
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('does NOT render a [data-loading] overlay element (old behavior removed)', async () => {
|
|
125
|
+
const el = mount('<table-ui></table-ui>');
|
|
126
|
+
el.columns = COLS;
|
|
127
|
+
el.data = ROWS;
|
|
128
|
+
await tick();
|
|
129
|
+
el.setAttribute('loading', '');
|
|
130
|
+
await tick();
|
|
131
|
+
// Old impl created <div data-loading> with <progress-ui> child.
|
|
132
|
+
// v0.6.18 does NOT — skeleton rows ARE the loading affordance.
|
|
133
|
+
expect(el.querySelector(':scope > [data-loading]')).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('restores real rows + clears aria-busy when loading toggles off', async () => {
|
|
137
|
+
const el = mount('<table-ui></table-ui>');
|
|
138
|
+
el.columns = COLS;
|
|
139
|
+
el.data = ROWS;
|
|
140
|
+
await tick();
|
|
141
|
+
el.setAttribute('loading', '');
|
|
142
|
+
await tick();
|
|
143
|
+
el.removeAttribute('loading');
|
|
144
|
+
await tick();
|
|
145
|
+
expect(el.getAttribute('aria-busy')).toBeNull();
|
|
146
|
+
expect(el.querySelectorAll('[data-body] > [data-skeleton-row]').length).toBe(0);
|
|
147
|
+
expect(el.querySelectorAll('[data-body] > [role="row"]').length).toBe(2);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('skeleton row count tracks paginate when set, capped at 8', async () => {
|
|
151
|
+
const el = mount('<table-ui paginate="3"></table-ui>');
|
|
152
|
+
el.columns = COLS;
|
|
153
|
+
el.data = ROWS;
|
|
154
|
+
await tick();
|
|
155
|
+
el.setAttribute('loading', '');
|
|
156
|
+
await tick();
|
|
157
|
+
expect(el.querySelectorAll('[data-body] > [data-skeleton-row]').length).toBe(3);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('preserves header row when [loading]', async () => {
|
|
161
|
+
const el = mount('<table-ui></table-ui>');
|
|
162
|
+
el.columns = COLS;
|
|
163
|
+
el.data = ROWS;
|
|
164
|
+
await tick();
|
|
165
|
+
const beforeHeader = el.querySelector(':scope > [data-header]');
|
|
166
|
+
expect(beforeHeader).not.toBeNull();
|
|
167
|
+
el.setAttribute('loading', '');
|
|
168
|
+
await tick();
|
|
169
|
+
const afterHeader = el.querySelector(':scope > [data-header]');
|
|
170
|
+
expect(afterHeader).not.toBeNull();
|
|
171
|
+
// Header cells unchanged in count
|
|
172
|
+
expect(afterHeader.children.length).toBe(3);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -15,6 +15,7 @@ composes:
|
|
|
15
15
|
- icon-ui
|
|
16
16
|
- progress-ui
|
|
17
17
|
- pagination-ui
|
|
18
|
+
- skeleton-ui
|
|
18
19
|
- badge-ui
|
|
19
20
|
props:
|
|
20
21
|
columns:
|
|
@@ -39,8 +40,11 @@ props:
|
|
|
39
40
|
type: boolean
|
|
40
41
|
default: false
|
|
41
42
|
loading:
|
|
42
|
-
description:
|
|
43
|
-
|
|
43
|
+
description: >-
|
|
44
|
+
Renders N ghost skeleton rows in place of the body data (count derived
|
|
45
|
+
from `paginate` if set, else 5). Header + columns stay intact so the
|
|
46
|
+
table layout is preserved while data fetches. Sets aria-busy="true" on
|
|
47
|
+
the host. Data updates are deferred until loading is set back to false.
|
|
44
48
|
type: boolean
|
|
45
49
|
default: false
|
|
46
50
|
reflect: true
|
package/components/text/class.js
CHANGED
|
@@ -28,10 +28,20 @@ import { UIElement } from '../../core/element.js';
|
|
|
28
28
|
|
|
29
29
|
export class UIText extends UIElement {
|
|
30
30
|
static properties = {
|
|
31
|
-
variant:
|
|
32
|
-
strong:
|
|
33
|
-
truncate:
|
|
34
|
-
lines:
|
|
31
|
+
variant: { type: String, default: 'body', reflect: true },
|
|
32
|
+
strong: { type: Boolean, default: false, reflect: true },
|
|
33
|
+
truncate: { type: Boolean, default: false, reflect: true },
|
|
34
|
+
lines: { type: Number, default: 0, reflect: true },
|
|
35
|
+
// ── v0.6.18 (FB-10) — finer-control overrides on top of `variant` ──
|
|
36
|
+
// Pre-v0.6.18, sizing/coloring/weighting required choosing a different
|
|
37
|
+
// `variant` (e.g. `label-sm` → `caption`). The skill already documents
|
|
38
|
+
// an intuitive overlay API (color="subtle", size="sm", weight="semibold",
|
|
39
|
+
// text-align="center"); v0.6.18 implements it. Each prop is an
|
|
40
|
+
// attribute selector in text.css that overrides the variant default.
|
|
41
|
+
size: { type: String, default: '', reflect: true },
|
|
42
|
+
color: { type: String, default: '', reflect: true },
|
|
43
|
+
weight: { type: String, default: '', reflect: true },
|
|
44
|
+
'text-align': { type: String, default: '', reflect: true },
|
|
35
45
|
};
|
|
36
46
|
|
|
37
47
|
static template = () => null;
|