@adia-ai/web-components 0.6.17 → 0.6.18
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 +85 -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/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/toggle-scheme/class.js +6 -0
- package/components/toggle-scheme/toggle-scheme.yaml +7 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,90 @@
|
|
|
1
1
|
# Changelog — @adia-ai/web-components
|
|
2
2
|
|
|
3
|
+
## [0.6.18] — 2026-05-21
|
|
4
|
+
|
|
5
|
+
### Added — `[data-scheme]` attribute on `toggle-scheme-ui` (FB-14)
|
|
6
|
+
|
|
7
|
+
- **`toggle-scheme-ui` now writes the `[data-scheme]` attribute** on the target
|
|
8
|
+
element alongside the existing `color-scheme` inline style. `styles/themes.css`
|
|
9
|
+
selects on `[data-scheme="light|dark|system"]`; consumer CSS patterned after
|
|
10
|
+
it never fired because the component wrote only the inline style.
|
|
11
|
+
`#writeTarget()` sets the attribute, `#clearTargetOverride()` removes it —
|
|
12
|
+
paired with the style so the two never diverge. Non-breaking: consumers
|
|
13
|
+
relying on the `color-scheme` style alone are unaffected. (~6 LOC `class.js`.)
|
|
14
|
+
|
|
15
|
+
### Added — `button-ui` text/icon symbol-duplication warning (FB-15)
|
|
16
|
+
|
|
17
|
+
- **`button-ui` emits a one-shot `console.warn`** when `text=` begins with a
|
|
18
|
+
symbol that `icon=` already renders — e.g. `text="+ New Item" icon="plus"`
|
|
19
|
+
renders a doubled `+`. Covers the high-frequency `plus` / `minus` / `x` /
|
|
20
|
+
`check` / `arrow-*` collisions. Warning-only (WeakSet-guarded, GC-friendly);
|
|
21
|
+
the button still renders and `text=` is never mutated. `button.yaml` gains the
|
|
22
|
+
matching `anti_patterns` entry so A2UI validators flag it at generation time.
|
|
23
|
+
(~36 LOC `class.js` + `button.yaml` + a2ui regen.)
|
|
24
|
+
|
|
25
|
+
### Added — Loading state as first-class citizen (FB-12 P2)
|
|
26
|
+
|
|
27
|
+
- **`stat-ui loading` prop.** New `loading: Boolean` (reflect: true). When set,
|
|
28
|
+
the value + change slots render `<skeleton-ui>` shimmer placeholders and the
|
|
29
|
+
host gets `aria-busy="true"`. Label and icon are preserved as static
|
|
30
|
+
metadata. Toggle off when data arrives — slot textContent setters restore
|
|
31
|
+
cleanly. `composes:` now includes `skeleton-ui`. (~28 LOC `stat.js` + 9 LOC
|
|
32
|
+
`stat.yaml` + a2ui chunk regen.)
|
|
33
|
+
- **`table-ui` skeleton rows.** `loading=true` now renders N ghost
|
|
34
|
+
`[data-skeleton-row]` rows (N = `paginate` capped at 8, else 5) instead of
|
|
35
|
+
the legacy `<progress-ui>` overlay. Header + columns stay intact so layout
|
|
36
|
+
is preserved while data fetches. Cell widths cycle 60/80/70/50/90% so rows
|
|
37
|
+
read as natural data. Host gets `aria-busy="true"`. `composes:` now
|
|
38
|
+
includes `skeleton-ui`. Fixes yaml-vs-impl drift: the yaml description
|
|
39
|
+
always promised "skeleton rows" since the initial cut; the impl only
|
|
40
|
+
shipped the overlay. (~42 LOC `class.js` rewrite of `#renderOverlays` +
|
|
41
|
+
CSS rewrite of the `:scope[loading] [data-body]` rule.)
|
|
42
|
+
|
|
43
|
+
### Added — `text-ui` overlay attributes (FB-10)
|
|
44
|
+
|
|
45
|
+
- **`size` / `color` / `weight` / `text-align` props on `text-ui`.** Pre-v0.6.18
|
|
46
|
+
the skill documented an overlay API (`<text-ui color="subtle" size="sm"
|
|
47
|
+
weight="semibold" text-align="center">`) but the substrate ignored those
|
|
48
|
+
attributes — they were valid HTML but no-op CSS. v0.6.18 implements them
|
|
49
|
+
as `:scope[attr="value"]` rules that override the `--text-*` CSS vars set
|
|
50
|
+
by `[variant]`. Enums:
|
|
51
|
+
- `size` — `sm | md | lg` → `--a-body-sm | --a-body-md | --a-body-lg`
|
|
52
|
+
- `color` — `default | subtle | strong | accent | danger | success | warning`
|
|
53
|
+
- `weight` — `regular | medium | semibold | bold`
|
|
54
|
+
- `text-align` — `start | center | end | justify` (sets `text-align` directly;
|
|
55
|
+
only takes effect when text-ui is block-like)
|
|
56
|
+
Permissive: unknown values are no-ops (variant default wins). Substrate now
|
|
57
|
+
matches the skill's already-documented intent.
|
|
58
|
+
|
|
59
|
+
### Tests
|
|
60
|
+
|
|
61
|
+
- 9 new tests in `stat.test.js` (FB-12 P2 #1 coverage).
|
|
62
|
+
- 9 new tests in `table.test.js` (FB-12 P2 #3 coverage; uses RAF-based settle
|
|
63
|
+
helper because table-ui renders via `requestAnimationFrame`, not microtasks).
|
|
64
|
+
- 18 new tests in `text.test.js` for the overlay attributes (attribute
|
|
65
|
+
reflection × 18 values + CSS rule presence × 18 + 3 enum-vs-CSS
|
|
66
|
+
consistency checks + permissive-fallback smoke).
|
|
67
|
+
- **Total: 99/99 passing** (text 82 + stat 9 + table 9 — the existing text
|
|
68
|
+
variant tests still pass alongside the new overlay tests).
|
|
69
|
+
|
|
70
|
+
### Deferred (see `outbox/FEEDBACK-12--...--followup.md`)
|
|
71
|
+
|
|
72
|
+
- **`chart-ui` `loading` prop (FB-12 P2 #2)** — design decision upstream.
|
|
73
|
+
`chart.examples.html` documents intentional "compose loading wrappers"
|
|
74
|
+
pattern; pivoting in a patch cut would silently lock in a direction.
|
|
75
|
+
- **`skeleton-ui` `variant` reconcile (FB-12 P3)** — two valid dispositions
|
|
76
|
+
(purge a2ui corpus refs / implement prop) need maintainer signoff.
|
|
77
|
+
|
|
78
|
+
### No-op (FB-11 P3 substrate)
|
|
79
|
+
|
|
80
|
+
- Verified that `import { UIFeed } from '@adia-ai/web-components/components/feed/class'`
|
|
81
|
+
resolves correctly via the existing `./components/*/class` exports pattern.
|
|
82
|
+
No package.json change needed. The original ticket flagged
|
|
83
|
+
`/components/feed/feed.js` as failing — confirmed (Node exports wildcards
|
|
84
|
+
can't substitute `*` across multiple path segments) but the `/class`
|
|
85
|
+
shorthand is the canonical path and works today. Skill v2.11.0 documents
|
|
86
|
+
this.
|
|
87
|
+
|
|
3
88
|
## [0.6.17] — 2026-05-21
|
|
4
89
|
|
|
5
90
|
### Maintenance
|
|
@@ -96,7 +96,14 @@
|
|
|
96
96
|
],
|
|
97
97
|
"unevaluatedProperties": false,
|
|
98
98
|
"x-adiaui": {
|
|
99
|
-
"anti_patterns": [
|
|
99
|
+
"anti_patterns": [
|
|
100
|
+
{
|
|
101
|
+
"description": "Beginning text= with a symbol that icon= already renders. icon=\"plus\" paints a Phosphor \"+\" glyph; text=\"+ New Item\" then renders the literal \"+\" too, so the symbol appears twice ([+ icon] [+ New Item]).",
|
|
102
|
+
"right": "<button-ui text=\"New Claim\" icon=\"plus\" variant=\"primary\"></button-ui>\n",
|
|
103
|
+
"rule": "Do not repeat the icon's glyph in text=. The icon provides the symbol; text= carries only the words. Applies to plus / minus / x / check / arrow icons.",
|
|
104
|
+
"wrong": "<button-ui text=\"+ New Claim\" icon=\"plus\" variant=\"primary\"></button-ui>\n"
|
|
105
|
+
}
|
|
106
|
+
],
|
|
100
107
|
"category": "action",
|
|
101
108
|
"composes": [
|
|
102
109
|
"icon-ui"
|
|
@@ -140,7 +140,19 @@ tokens:
|
|
|
140
140
|
description: Inherited multiplier for padding
|
|
141
141
|
a2ui:
|
|
142
142
|
rules: []
|
|
143
|
-
anti_patterns:
|
|
143
|
+
anti_patterns:
|
|
144
|
+
- description: >-
|
|
145
|
+
Beginning text= with a symbol that icon= already renders. icon="plus"
|
|
146
|
+
paints a Phosphor "+" glyph; text="+ New Item" then renders the literal
|
|
147
|
+
"+" too, so the symbol appears twice ([+ icon] [+ New Item]).
|
|
148
|
+
wrong: |
|
|
149
|
+
<button-ui text="+ New Claim" icon="plus" variant="primary"></button-ui>
|
|
150
|
+
right: |
|
|
151
|
+
<button-ui text="New Claim" icon="plus" variant="primary"></button-ui>
|
|
152
|
+
rule: >-
|
|
153
|
+
Do not repeat the icon's glyph in text=. The icon provides the symbol;
|
|
154
|
+
text= carries only the words. Applies to plus / minus / x / check /
|
|
155
|
+
arrow icons.
|
|
144
156
|
examples: []
|
|
145
157
|
keywords: []
|
|
146
158
|
synonyms: {}
|
|
@@ -16,6 +16,19 @@
|
|
|
16
16
|
import { UIElement, signal, html } from '../../core/element.js';
|
|
17
17
|
import { getIcon } from '../../core/icons.js';
|
|
18
18
|
|
|
19
|
+
// FEEDBACK-15: highest-frequency icon ⇄ text-prefix collisions. When an
|
|
20
|
+
// author writes e.g. text="+ New Item" icon="plus", the Phosphor glyph and
|
|
21
|
+
// the literal symbol both render — a doubled "+". Map is intentionally a
|
|
22
|
+
// curated high-frequency set, not exhaustive; the warning is non-breaking.
|
|
23
|
+
const ICON_TEXT_PREFIXES = {
|
|
24
|
+
plus: ['+', '+'],
|
|
25
|
+
minus: ['-', '−', '–'],
|
|
26
|
+
x: ['×', 'x ', 'X '],
|
|
27
|
+
check: ['✓', '✔'],
|
|
28
|
+
'arrow-right': ['→', '>'],
|
|
29
|
+
'arrow-left': ['←', '<'],
|
|
30
|
+
};
|
|
31
|
+
|
|
19
32
|
export class UIButton extends UIElement {
|
|
20
33
|
static properties = {
|
|
21
34
|
text: { type: String, default: '', reflect: true },
|
|
@@ -57,6 +70,26 @@ export class UIButton extends UIElement {
|
|
|
57
70
|
}
|
|
58
71
|
}
|
|
59
72
|
|
|
73
|
+
// FEEDBACK-15: warn when text= begins with a symbol that icon= already
|
|
74
|
+
// renders (e.g. text="+ New Claim" icon="plus" → a doubled "+"). One-shot
|
|
75
|
+
// per element via WeakSet; warning-only — the button still renders. The
|
|
76
|
+
// text value is never mutated (text="+1" with no icon is a valid label).
|
|
77
|
+
if (this.icon && this.text) {
|
|
78
|
+
const prefixes = ICON_TEXT_PREFIXES[this.icon];
|
|
79
|
+
if (prefixes && prefixes.some((p) => this.text.startsWith(p))) {
|
|
80
|
+
if (!UIButton.#dupSymbolWarned.has(this)) {
|
|
81
|
+
UIButton.#dupSymbolWarned.add(this);
|
|
82
|
+
// eslint-disable-next-line no-console
|
|
83
|
+
console.warn(
|
|
84
|
+
`[button-ui] text="${this.text}" begins with a symbol that ` +
|
|
85
|
+
`icon="${this.icon}" already renders — the glyph appears twice. ` +
|
|
86
|
+
`Drop the leading symbol from text= (the icon provides it).`,
|
|
87
|
+
this,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
60
93
|
// §184 (v0.5.5, FEEDBACK-08 §8): icon-only a11y warning + title→aria-label
|
|
61
94
|
// auto-derive. Two complementary mechanisms (consumer chooses):
|
|
62
95
|
// (a) When [title="Undo"] is set on an icon-only button without an
|
|
@@ -97,6 +130,9 @@ export class UIButton extends UIElement {
|
|
|
97
130
|
// is gone the entry is collected.
|
|
98
131
|
static #a11yWarned = new WeakSet();
|
|
99
132
|
|
|
133
|
+
// FEEDBACK-15: one-shot set for the text/icon symbol-duplication warning.
|
|
134
|
+
static #dupSymbolWarned = new WeakSet();
|
|
135
|
+
|
|
100
136
|
#onClick = (e) => {
|
|
101
137
|
if (this.disabled) { e.stopPropagation(); return; }
|
|
102
138
|
if (this.type === 'submit') {
|
|
@@ -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;
|
|
@@ -13,6 +13,20 @@
|
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"properties": {
|
|
16
|
+
"color": {
|
|
17
|
+
"description": "Override the variant's color token. Permissive: unknown values are no-ops (variant color wins). Added v0.6.18 (FB-10).",
|
|
18
|
+
"type": "string",
|
|
19
|
+
"enum": [
|
|
20
|
+
"default",
|
|
21
|
+
"subtle",
|
|
22
|
+
"strong",
|
|
23
|
+
"accent",
|
|
24
|
+
"danger",
|
|
25
|
+
"success",
|
|
26
|
+
"warning"
|
|
27
|
+
],
|
|
28
|
+
"default": ""
|
|
29
|
+
},
|
|
16
30
|
"component": {
|
|
17
31
|
"const": "Text"
|
|
18
32
|
},
|
|
@@ -21,11 +35,32 @@
|
|
|
21
35
|
"type": "number",
|
|
22
36
|
"default": 0
|
|
23
37
|
},
|
|
38
|
+
"size": {
|
|
39
|
+
"description": "Override the variant's font-size on the body ladder. Maps to --a-body-sm / --a-body-md / --a-body-lg. Permissive: unknown values are no-ops (variant size wins). Added v0.6.18 (FB-10).",
|
|
40
|
+
"type": "string",
|
|
41
|
+
"enum": [
|
|
42
|
+
"sm",
|
|
43
|
+
"md",
|
|
44
|
+
"lg"
|
|
45
|
+
],
|
|
46
|
+
"default": ""
|
|
47
|
+
},
|
|
24
48
|
"strong": {
|
|
25
49
|
"description": "When true, applies stronger emphasis (heavier weight + accent color). Styled via :scope[strong] in text.css. Use instead of variant=heading when you want a single emphasized word inline in body copy.",
|
|
26
50
|
"type": "boolean",
|
|
27
51
|
"default": false
|
|
28
52
|
},
|
|
53
|
+
"text-align": {
|
|
54
|
+
"description": "Override text alignment. Note: text-ui defaults to display:inline, so this only takes effect when text-ui is block-like (wrapping or parent display:block/grid). Added v0.6.18 (FB-10).",
|
|
55
|
+
"type": "string",
|
|
56
|
+
"enum": [
|
|
57
|
+
"start",
|
|
58
|
+
"center",
|
|
59
|
+
"end",
|
|
60
|
+
"justify"
|
|
61
|
+
],
|
|
62
|
+
"default": ""
|
|
63
|
+
},
|
|
29
64
|
"textContent": {
|
|
30
65
|
"description": "Display text content. The main payload field for Text components extracted from HTML.",
|
|
31
66
|
"$ref": "common_types.json#/$defs/DynamicString"
|
|
@@ -67,6 +102,17 @@
|
|
|
67
102
|
"section": "Inline form-group / navlist heading (visual rank H4). Small-cap. Use for form group labels, nav list headings.",
|
|
68
103
|
"subsection": "Sub-landmark within a section (visual rank H3). 14px / semibold. Use for card titles within a section."
|
|
69
104
|
}
|
|
105
|
+
},
|
|
106
|
+
"weight": {
|
|
107
|
+
"description": "Override the variant's font-weight. Maps to --a-weight / --a-weight-medium / --a-weight-semibold / --a-weight-bold. Permissive: unknown values are no-ops (variant weight wins). Added v0.6.18 (FB-10).",
|
|
108
|
+
"type": "string",
|
|
109
|
+
"enum": [
|
|
110
|
+
"regular",
|
|
111
|
+
"medium",
|
|
112
|
+
"semibold",
|
|
113
|
+
"bold"
|
|
114
|
+
],
|
|
115
|
+
"default": ""
|
|
70
116
|
}
|
|
71
117
|
},
|
|
72
118
|
"required": [
|
package/components/text/text.css
CHANGED
|
@@ -61,6 +61,47 @@
|
|
|
61
61
|
:scope[variant="deck"] { --text-family: var(--a-deck-family); --text-weight: var(--a-deck-weight); --text-size: var(--a-deck-size); --text-leading: var(--a-deck-leading); --text-tracking: var(--a-deck-tracking); --text-case: var(--a-deck-case); --text-color: var(--a-deck-color); }
|
|
62
62
|
:scope[variant="metric"] { --text-family: var(--a-metric-family); --text-weight: var(--a-metric-weight); --text-size: var(--a-metric-size); --text-leading: var(--a-metric-leading); --text-tracking: var(--a-metric-tracking); --text-case: var(--a-metric-case); --text-color: var(--a-metric-color); }
|
|
63
63
|
|
|
64
|
+
/* ── v0.6.18 (FB-10) — finer-control overrides on top of `variant` ──
|
|
65
|
+
The skill always documented an overlay API (color="subtle",
|
|
66
|
+
size="sm", weight="semibold", text-align="center"). Pre-v0.6.18 these
|
|
67
|
+
attributes existed only as documentation — text-ui ignored them and
|
|
68
|
+
rendered the variant default. v0.6.18 implements them as attribute
|
|
69
|
+
selectors that override the `--text-*` CSS variables set by [variant].
|
|
70
|
+
Each is intentionally permissive: unknown values fall back to the
|
|
71
|
+
variant's value rather than throwing. Authoring `<text-ui color="...">`
|
|
72
|
+
with an unknown color = no-op (variant color wins). */
|
|
73
|
+
|
|
74
|
+
/* size — sm | md | lg (md is the body default ~15-16px) */
|
|
75
|
+
:scope[size="sm"] { --text-size: var(--a-body-sm); }
|
|
76
|
+
:scope[size="md"] { --text-size: var(--a-body-md); }
|
|
77
|
+
:scope[size="lg"] { --text-size: var(--a-body-lg); }
|
|
78
|
+
|
|
79
|
+
/* color — default | subtle | strong | accent | danger | success | warning
|
|
80
|
+
(no value = variant default; "default" is an explicit reset). */
|
|
81
|
+
:scope[color="default"] { --text-color: var(--a-fg); }
|
|
82
|
+
:scope[color="subtle"] { --text-color: var(--a-fg-muted); }
|
|
83
|
+
:scope[color="strong"] { --text-color: var(--a-fg-strong); }
|
|
84
|
+
:scope[color="accent"] { --text-color: var(--a-accent); }
|
|
85
|
+
:scope[color="danger"] { --text-color: var(--a-danger-bg); }
|
|
86
|
+
:scope[color="success"] { --text-color: var(--a-success-bg); }
|
|
87
|
+
:scope[color="warning"] { --text-color: var(--a-warning-bg); }
|
|
88
|
+
|
|
89
|
+
/* weight — regular | medium | semibold | bold */
|
|
90
|
+
:scope[weight="regular"] { --text-weight: var(--a-weight); }
|
|
91
|
+
:scope[weight="medium"] { --text-weight: var(--a-weight-medium); }
|
|
92
|
+
:scope[weight="semibold"] { --text-weight: var(--a-weight-semibold); }
|
|
93
|
+
:scope[weight="bold"] { --text-weight: var(--a-weight-bold); }
|
|
94
|
+
|
|
95
|
+
/* text-align — start | center | end | justify */
|
|
96
|
+
:scope[text-align="start"] { text-align: start; }
|
|
97
|
+
:scope[text-align="center"] { text-align: center; }
|
|
98
|
+
:scope[text-align="end"] { text-align: end; }
|
|
99
|
+
:scope[text-align="justify"] { text-align: justify; }
|
|
100
|
+
/* Note: `<text-ui>` defaults to display:inline. text-align only takes
|
|
101
|
+
effect when text-ui itself is block-like (e.g. via wrapping or a
|
|
102
|
+
`display: block`/`grid` parent). The override-attribute pattern keeps
|
|
103
|
+
the inline default but lets consumers flip alignment with one attr. */
|
|
104
|
+
|
|
64
105
|
/* ── Truncation (single-line) ── */
|
|
65
106
|
:scope[truncate] {
|
|
66
107
|
overflow: hidden;
|
|
@@ -39,8 +39,12 @@ export type UITextVariant =
|
|
|
39
39
|
| 'code';
|
|
40
40
|
|
|
41
41
|
export class UIText extends UIElement {
|
|
42
|
+
/** Override the variant's color token. Permissive: unknown values are no-ops (variant color wins). Added v0.6.18 (FB-10). */
|
|
43
|
+
color: 'default' | 'subtle' | 'strong' | 'accent' | 'danger' | 'success' | 'warning';
|
|
42
44
|
/** Multi-line clamp count (0 = no clamp) */
|
|
43
45
|
lines: number;
|
|
46
|
+
/** Override the variant's font-size on the body ladder. Maps to --a-body-sm / --a-body-md / --a-body-lg. Permissive: unknown values are no-ops (variant size wins). Added v0.6.18 (FB-10). */
|
|
47
|
+
size: 'sm' | 'md' | 'lg';
|
|
44
48
|
/** When true, applies stronger emphasis (heavier weight + accent color). Styled via :scope[strong] in text.css. Use instead of variant=heading when you want a single emphasized word inline in body copy. */
|
|
45
49
|
strong: boolean;
|
|
46
50
|
/** Display text content. The main payload field for Text components extracted from HTML. */
|
|
@@ -57,4 +61,6 @@ For semantic headings, wrap with native `<h1>`-`<h6>` OR add
|
|
|
57
61
|
labels (eyebrows, kickers, captions, deck), the presentational default
|
|
58
62
|
is correct. The §221k chooser guide in USAGE.md documents picker heuristics. */
|
|
59
63
|
variant: UITextVariant;
|
|
64
|
+
/** Override the variant's font-weight. Maps to --a-weight / --a-weight-medium / --a-weight-semibold / --a-weight-bold. Permissive: unknown values are no-ops (variant weight wins). Added v0.6.18 (FB-10). */
|
|
65
|
+
weight: 'regular' | 'medium' | 'semibold' | 'bold';
|
|
60
66
|
}
|
|
@@ -110,3 +110,93 @@ describe('text-ui §210 — variant enum vs CSS rule completeness', () => {
|
|
|
110
110
|
}
|
|
111
111
|
});
|
|
112
112
|
});
|
|
113
|
+
|
|
114
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
115
|
+
// v0.6.18 (FB-10) — overlay attributes on top of `variant`
|
|
116
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
describe('text-ui v0.6.18 — overlay props (FB-10)', () => {
|
|
119
|
+
beforeEach(() => { document.body.innerHTML = ''; });
|
|
120
|
+
|
|
121
|
+
// ── Attribute reflection: each new prop reflects to the host ──
|
|
122
|
+
it.each([
|
|
123
|
+
['size', 'sm'],
|
|
124
|
+
['size', 'md'],
|
|
125
|
+
['size', 'lg'],
|
|
126
|
+
['color', 'subtle'],
|
|
127
|
+
['color', 'strong'],
|
|
128
|
+
['color', 'accent'],
|
|
129
|
+
['color', 'danger'],
|
|
130
|
+
['color', 'success'],
|
|
131
|
+
['color', 'warning'],
|
|
132
|
+
['color', 'default'],
|
|
133
|
+
['weight', 'regular'],
|
|
134
|
+
['weight', 'medium'],
|
|
135
|
+
['weight', 'semibold'],
|
|
136
|
+
['weight', 'bold'],
|
|
137
|
+
['text-align', 'start'],
|
|
138
|
+
['text-align', 'center'],
|
|
139
|
+
['text-align', 'end'],
|
|
140
|
+
['text-align', 'justify'],
|
|
141
|
+
])('<text-ui %s="%s"> reflects the attribute', async (prop, value) => {
|
|
142
|
+
const el = mount(`<text-ui ${prop}="${value}">x</text-ui>`);
|
|
143
|
+
await tick();
|
|
144
|
+
expect(el.getAttribute(prop)).toBe(value);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ── CSS-side: every documented value has a :scope[attr="value"] rule ──
|
|
148
|
+
const sizes = ['sm', 'md', 'lg'];
|
|
149
|
+
const colors = ['default', 'subtle', 'strong', 'accent', 'danger', 'success', 'warning'];
|
|
150
|
+
const weights = ['regular', 'medium', 'semibold', 'bold'];
|
|
151
|
+
const aligns = ['start', 'center', 'end', 'justify'];
|
|
152
|
+
|
|
153
|
+
it.each(sizes)('text.css ships :scope[size="%s"] rule', (s) => {
|
|
154
|
+
expect(TEXT_CSS).toMatch(new RegExp(`:scope\\[size="${s}"\\]`));
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it.each(colors)('text.css ships :scope[color="%s"] rule', (c) => {
|
|
158
|
+
expect(TEXT_CSS).toMatch(new RegExp(`:scope\\[color="${c}"\\]`));
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it.each(weights)('text.css ships :scope[weight="%s"] rule', (w) => {
|
|
162
|
+
expect(TEXT_CSS).toMatch(new RegExp(`:scope\\[weight="${w}"\\]`));
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it.each(aligns)('text.css ships :scope[text-align="%s"] rule', (a) => {
|
|
166
|
+
expect(TEXT_CSS).toMatch(new RegExp(`:scope\\[text-align="${a}"\\]`));
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ── yaml-vs-impl consistency: every prop in the a2ui enum has a CSS rule ──
|
|
170
|
+
it('a2ui.json color enum matches CSS rules 1:1', () => {
|
|
171
|
+
const colorEnum = TEXT_A2UI.properties.color?.enum ?? [];
|
|
172
|
+
expect(colorEnum.sort()).toEqual([...colors].sort());
|
|
173
|
+
for (const c of colorEnum) {
|
|
174
|
+
expect(TEXT_CSS).toMatch(new RegExp(`:scope\\[color="${c}"\\]`));
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('a2ui.json size enum matches CSS rules 1:1', () => {
|
|
179
|
+
const sizeEnum = TEXT_A2UI.properties.size?.enum ?? [];
|
|
180
|
+
expect(sizeEnum.sort()).toEqual([...sizes].sort());
|
|
181
|
+
for (const s of sizeEnum) {
|
|
182
|
+
expect(TEXT_CSS).toMatch(new RegExp(`:scope\\[size="${s}"\\]`));
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('a2ui.json weight enum matches CSS rules 1:1', () => {
|
|
187
|
+
const weightEnum = TEXT_A2UI.properties.weight?.enum ?? [];
|
|
188
|
+
expect(weightEnum.sort()).toEqual([...weights].sort());
|
|
189
|
+
for (const w of weightEnum) {
|
|
190
|
+
expect(TEXT_CSS).toMatch(new RegExp(`:scope\\[weight="${w}"\\]`));
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// ── Permissive fallback: unknown values are no-ops (no JS error) ──
|
|
195
|
+
it('unknown color value renders without throwing', async () => {
|
|
196
|
+
const el = mount('<text-ui color="banana">x</text-ui>');
|
|
197
|
+
await tick();
|
|
198
|
+
expect(el.getAttribute('color')).toBe('banana');
|
|
199
|
+
// The variant default wins; no CSS rule matches "banana". Test the
|
|
200
|
+
// shape: no thrown error during mount, no console.error.
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -8,20 +8,56 @@ category: display
|
|
|
8
8
|
version: 1
|
|
9
9
|
description: Typography wrapper that applies role tokens. Supports truncation and line clamping.
|
|
10
10
|
props:
|
|
11
|
+
color:
|
|
12
|
+
description: >-
|
|
13
|
+
Override the variant's color token. Permissive: unknown values are
|
|
14
|
+
no-ops (variant color wins). Added v0.6.18 (FB-10).
|
|
15
|
+
type: string
|
|
16
|
+
enum: ["default", "subtle", "strong", "accent", "danger", "success", "warning"]
|
|
17
|
+
default: ""
|
|
18
|
+
reflect: true
|
|
11
19
|
lines:
|
|
12
20
|
description: Multi-line clamp count (0 = no clamp)
|
|
13
21
|
type: number
|
|
14
22
|
default: 0
|
|
23
|
+
size:
|
|
24
|
+
description: >-
|
|
25
|
+
Override the variant's font-size on the body ladder. Maps to
|
|
26
|
+
--a-body-sm / --a-body-md / --a-body-lg. Permissive: unknown values
|
|
27
|
+
are no-ops (variant size wins). Added v0.6.18 (FB-10).
|
|
28
|
+
type: string
|
|
29
|
+
enum: ["sm", "md", "lg"]
|
|
30
|
+
default: ""
|
|
31
|
+
reflect: true
|
|
15
32
|
strong:
|
|
16
33
|
description: When true, applies stronger emphasis (heavier weight + accent color). Styled via :scope[strong] in text.css. Use instead of variant=heading when you want a single emphasized word inline in body copy.
|
|
17
34
|
type: boolean
|
|
18
35
|
default: false
|
|
19
36
|
reflect: true
|
|
37
|
+
text-align:
|
|
38
|
+
description: >-
|
|
39
|
+
Override text alignment. Note: text-ui defaults to display:inline,
|
|
40
|
+
so this only takes effect when text-ui is block-like (wrapping or
|
|
41
|
+
parent display:block/grid). Added v0.6.18 (FB-10).
|
|
42
|
+
type: string
|
|
43
|
+
enum: ["start", "center", "end", "justify"]
|
|
44
|
+
default: ""
|
|
45
|
+
reflect: true
|
|
20
46
|
truncate:
|
|
21
47
|
description: Single-line truncation with ellipsis. Ignored when `lines` is set.
|
|
22
48
|
type: boolean
|
|
23
49
|
default: false
|
|
24
50
|
reflect: true
|
|
51
|
+
weight:
|
|
52
|
+
description: >-
|
|
53
|
+
Override the variant's font-weight. Maps to --a-weight /
|
|
54
|
+
--a-weight-medium / --a-weight-semibold / --a-weight-bold.
|
|
55
|
+
Permissive: unknown values are no-ops (variant weight wins). Added
|
|
56
|
+
v0.6.18 (FB-10).
|
|
57
|
+
type: string
|
|
58
|
+
enum: ["regular", "medium", "semibold", "bold"]
|
|
59
|
+
default: ""
|
|
60
|
+
reflect: true
|
|
25
61
|
textContent:
|
|
26
62
|
description: Display text content. The main payload field for Text components extracted from HTML.
|
|
27
63
|
type: string
|
|
@@ -272,11 +272,17 @@ export class UIToggleScheme extends UIElement {
|
|
|
272
272
|
#writeTarget(scheme) {
|
|
273
273
|
const t = this.#resolveTarget();
|
|
274
274
|
t.style.setProperty('color-scheme', scheme);
|
|
275
|
+
// FEEDBACK-14: also write the [data-scheme] attribute. styles/themes.css
|
|
276
|
+
// selects on [data-scheme="dark"] etc., so consumer CSS patterned after
|
|
277
|
+
// themes.css needs the attribute — the inline color-scheme style alone
|
|
278
|
+
// satisfies AdiaUI's light-dark() tokens but not [data-scheme]-scoped CSS.
|
|
279
|
+
t.setAttribute('data-scheme', scheme);
|
|
275
280
|
}
|
|
276
281
|
|
|
277
282
|
#clearTargetOverride() {
|
|
278
283
|
const t = this.#resolveTarget();
|
|
279
284
|
t.style.removeProperty('color-scheme');
|
|
285
|
+
t.removeAttribute('data-scheme'); // FEEDBACK-14 — paired with #writeTarget
|
|
280
286
|
if (this.persist) {
|
|
281
287
|
try { localStorage.removeItem(`${this.storagePrefix}scheme`); } catch {}
|
|
282
288
|
}
|
|
@@ -149,7 +149,13 @@ tokens:
|
|
|
149
149
|
--toggle-scheme-icon-transition:
|
|
150
150
|
description: Duration + easing for icon-color transition when scheme flips.
|
|
151
151
|
a2ui:
|
|
152
|
-
rules:
|
|
152
|
+
rules:
|
|
153
|
+
- >-
|
|
154
|
+
Place toggle-scheme-ui in the shell topbar's trailing action cluster
|
|
155
|
+
— slot="action" inside <admin-topbar slot="header"> of
|
|
156
|
+
<admin-content>. It is a persistent, app-wide preference control;
|
|
157
|
+
never put it in a sidebar footer / <admin-statusbar>, which hosts
|
|
158
|
+
user-account items only.
|
|
153
159
|
anti_patterns: []
|
|
154
160
|
examples:
|
|
155
161
|
- name: header-action
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adia-ai/web-components",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.18",
|
|
4
4
|
"description": "AdiaUI web components \u2014 vanilla custom elements. A2UI runtime (renderer, registry, streams, wiring) lives in @adia-ai/a2ui-runtime.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "./index.d.ts",
|