@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/USAGE.md +6 -0
  3. package/components/button/button.a2ui.json +8 -1
  4. package/components/button/button.yaml +13 -1
  5. package/components/button/class.js +36 -0
  6. package/components/chart/chart.a2ui.json +5 -0
  7. package/components/chart/chart.d.ts +2 -0
  8. package/components/chart/chart.yaml +13 -0
  9. package/components/chart/class.js +13 -0
  10. package/components/drawer/class.js +60 -2
  11. package/components/drawer/drawer.a2ui.json +11 -1
  12. package/components/drawer/drawer.d.ts +2 -0
  13. package/components/drawer/drawer.yaml +26 -1
  14. package/components/segmented/class.js +23 -0
  15. package/components/segmented/segmented.a2ui.json +11 -4
  16. package/components/segmented/segmented.yaml +52 -6
  17. package/components/stat/stat.a2ui.json +7 -1
  18. package/components/stat/stat.d.ts +2 -0
  19. package/components/stat/stat.js +34 -5
  20. package/components/stat/stat.test.js +108 -0
  21. package/components/stat/stat.yaml +9 -0
  22. package/components/table/class.js +43 -8
  23. package/components/table/table.a2ui.json +2 -1
  24. package/components/table/table.css +20 -4
  25. package/components/table/table.d.ts +1 -1
  26. package/components/table/table.test.js +174 -0
  27. package/components/table/table.yaml +6 -2
  28. package/components/text/class.js +14 -4
  29. package/components/text/text.a2ui.json +46 -0
  30. package/components/text/text.css +41 -0
  31. package/components/text/text.d.ts +6 -0
  32. package/components/text/text.test.js +90 -0
  33. package/components/text/text.yaml +36 -0
  34. package/components/textarea/textarea.a2ui.json +25 -0
  35. package/components/textarea/textarea.yaml +23 -0
  36. package/components/toggle-scheme/class.js +6 -0
  37. package/components/toggle-scheme/toggle-scheme.yaml +7 -1
  38. package/core/element.js +13 -0
  39. package/css-module.d.ts +6 -0
  40. 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: <segmented-ui value="tab1">
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: Basic Segmented usage
28
- a2ui: "[\n {\n \"id\": \"root\",\n \"component\": \"Card\",\n \"children\": [\n \"sec\"\n ]\n },\n {\n\
29
- \ \"id\": \"sec\",\n \"component\": \"Section\",\n \"children\": [\n \"comp\"\n ]\n },\n {\n \"\
30
- id\": \"comp\",\n \"component\": \"Segmented\",\n \"value\": \"\"\n }\n]"
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 */
@@ -12,11 +12,12 @@ import { UIElement } from '../../core/element.js';
12
12
 
13
13
  class UIStat extends UIElement {
14
14
  static properties = {
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 },
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
- // Show loading overlay
848
- if (!loadingEl) {
849
- loadingEl = document.createElement('div');
850
- loadingEl.setAttribute('data-loading', '');
851
- const prog = document.createElement('progress-ui');
852
- prog.setAttribute('indeterminate', '');
853
- loadingEl.appendChild(prog);
854
- body.after(loadingEl);
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": "Shows a loading overlay and skeleton rows. Sets aria-busy=\"true\". Data updates are deferred until loading is set back to false.",
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
- :scope[loading] [data-body] {
370
- opacity: 0.5;
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
- /** Shows a loading overlay and skeleton rows. Sets aria-busy="true". Data updates are deferred until loading is set back to false. */
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: Shows a loading overlay and skeleton rows. Sets aria-busy="true". Data updates are
43
- deferred until loading is set back to false.
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
@@ -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: { 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 },
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;