@adia-ai/web-components 0.7.1 → 0.7.3
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 +32 -0
- package/components/card/card.css +1 -1
- package/components/col/col.a2ui.json +1 -1
- package/components/col/col.css +1 -1
- package/components/col/col.d.ts +1 -1
- package/components/col/col.yaml +6 -3
- package/components/grid/grid.a2ui.json +1 -1
- package/components/grid/grid.css +4 -3
- package/components/grid/grid.d.ts +1 -1
- package/components/grid/grid.yaml +5 -3
- package/components/menu/menu.class.js +14 -2
- package/components/row/row.a2ui.json +1 -1
- package/components/row/row.css +6 -4
- package/components/row/row.d.ts +1 -1
- package/components/row/row.yaml +4 -2
- package/components/stat/stat.css +1 -1
- package/components/swiper/swiper.css +1 -1
- package/components/tree/tree.class.js +89 -16
- package/components/tree/tree.test.js +49 -0
- package/core/template.js +9 -0
- package/core/template.test.js +36 -0
- package/dist/web-components.min.css +1 -1
- package/dist/web-components.min.js +3 -3
- package/package.json +1 -1
- package/styles/api/sizing.css +52 -16
- package/styles/foundation/space.css +20 -4
- package/styles/prose.css +7 -4
- package/styles/type/roles.css +5 -0
- package/styles/typography.css +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# Changelog — @adia-ai/web-components
|
|
2
2
|
|
|
3
|
+
## [0.7.3] — 2026-06-01
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Parametric named gap scale `xs`–`xl` (`--a-gap-k`)** — the named gap vocabulary is now a complete, parametric 5-step scale: `--a-gap-{xs,sm,md,lg,xl}` = `half + half·--a-gap-k` (a fixed floor + a k-scaled half). The new `--a-gap-k` knob (`@property`, default 1) is a root/provider-level scale control with the same semantics as `--a-density` (subtree override is inert — set it at `:root`/a top provider). At k=1: **4/8/12/16/20px**. `gap="xs"` and `gap="xl"` now resolve (they were documented in yaml but unimplemented — silently did nothing in both the CSS `[gap]` path and the JS `@bp` responsive path). At k=1 the named scale coincides with the integer scale: xs=1, sm=2, md=3, lg=4, xl=5. (spec GS-P2-SPEC-2 §5.2)
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **Behavior change (MINOR): explicit `[gap="sm|md|lg"]` resolves smaller** — `gap="sm|md|lg"` on layout primitives (col/row/grid) now resolves to `--a-gap-{sm,md,lg}` = **8/12/16px** (was the hardcoded `--a-space-4/6/8` = 16/24/32px). This unifies the three gap paths that previously disagreed — explicit `[gap="md"]` was 24px while the responsive (`gap="md@bp"`, JS `_gapToCss`) and ambient (`[size]`) paths were 12px; all three are now `--a-gap-md`. Integer `gap="N"` is unchanged (literal `--a-space-N`, k-independent). `[size]` / `<card-ui size="…">` unaffected. (audit C1/C2)
|
|
12
|
+
- **`[gap]` no longer leaks into nested layout primitives (audit C3)** — `[gap="N"]` now sets a NON-inheriting `--a-gap-self` (`@property{syntax:"*";inherits:false}`); the inheriting `--a-gap` is reserved for the *ambient* `[size]`/`[prose]`/`:root` cascade. The six gap-readers (col/row/grid/card/swiper/stat) resolve `var(--a-gap-self, var(--a-gap))` — explicit-on-self wins, else the inherited ambient. Previously a parent's explicit `gap="8"` bled into a nested `<row-ui>` that declared no gap of its own. Verified via `scripts/qa/cascade-gap-self-probe.mjs` (5 truth-table rows + parametric + coincidence, all green).
|
|
13
|
+
- **`col`/`row`/`grid` yaml gap docs + layout demo reconciled to impl** — documented the two grammars (named parametric + integer 0–16), fixed grid's range claim (`0–12`→`0–16`), dropped the phantom-value framing (`xs`/`xl` real now). `layout.examples.html`: Properties tables corrected, broken `gap="none"` demo → `gap="0"`, col demo filled out to the complete `xs`→`xl` scale. Sidecars regenerated.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **`<tree-item-ui>` adopts interpolated `[slot="actions"]`/`[slot="caret"]` children (FB-96)** — the FB-89 actions-adoption used a `this.querySelectorAll(':scope > [slot="actions"]')` direct-child query that ran once at `#stamp()` time, so a `<button-ui slot="actions">` rendered through the template engine (`.map()`/`repeat()`/conditional) was missed twice over: it arrives in a *later* reactive tick (after `#stamp`) **and** nested in a `display:contents` wrapper span. The un-adopted button stayed a bare `role="button"` child of the `group`/`tree`, tripping axe `aria-required-children` even after the FB-94 `role="presentation"` fix (which addresses role-flattening, not structural adoption). `#stamp()` now stamps an empty placeholder and a re-entrancy-guarded `MutationObserver` adopts the real `[slot="actions"]`/`[slot="caret"]` into the row whenever they appear, via a wrapper-piercing "logical children" walk that stops at nested `tree-item-ui` boundaries (so a child item's actions aren't mis-adopted) and drops the placeholder once a real child lands. +2 regression tests; verified with axe-core (`.map()` tree + interpolated actions button → 0 `aria-required-children`). The structural-adoption sibling of the FB-92 `menu #show()` fix — same `:scope >` + `display:contents` trap. (Tokens Studio / color-app FB-96)
|
|
18
|
+
|
|
19
|
+
### Changed — styles + build
|
|
20
|
+
|
|
21
|
+
- **Gap scale + grammar span `styles/` → `components/` → `patterns/` → `dist/`** — the parametric `--a-gap-k` scale is defined in `styles/` (foundation tokens); the `[gap]` grammar applies on `components/col` / `components/row` / `components/grid`; `patterns/` demos were updated to the named scale; and the `dist/` CDN bundles (`web-components.min.css` + `web-components.min.js`) were regenerated.
|
|
22
|
+
|
|
23
|
+
## [0.7.2] — 2026-06-01
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- **template engine — ARIA-transparent `display:contents` wrapper spans (FB-94)** — the interpolation wrapper span the engine emits for `${…}` parts (`.map()` / conditional / `repeat()`) now carries `role="presentation"`. `display:contents` removes the span's box from layout but it still generated a generic accessibility node, which broke `aria-required-children` / `aria-required-parent` when the span sat between a role-bearing parent and role-bearing children rendered via interpolation — e.g. a `.map()`-rendered `<tree-ui>` (the mainstream consumer case) tripped axe `aria-required-children` even after the FB-91 static-markup fix landed. `role="presentation"` promotes the children to the real parent in the a11y tree. Fixed at all three wrapper sites (`wrap()` + array branch + keyed `repeat()`); +3 regression tests assert the wrappers carry `role="presentation"`. This is the consumer-preferred + peer-recommended central fix and is structurally sound (the spurious generic node between `tree`→`treeitem` is removed). **Honest verification note:** `aria-required-children` could not be made to fire on any in-repo tree (shallow *or* deeply-nested, axe-core, `:5300`) — it triggers only on color-app's exact published-package `dts-token-tree` — so local axe reads 0 both before and after; the firing→cleared transition is **pending the consumer's confirmation** on their next `@adia-ai/web-components` upgrade. (Tokens Studio / color-app FB-94)
|
|
28
|
+
- **`<menu-item-ui>` no longer an orphan `menuitem` when the menu is closed (FB-95)** — `UIMenuItem.connected()` set `role="menuitem"` unconditionally, but a closed `<menu-ui>` moves its items back into its own light DOM (no `role="menu"` ancestor), so every closed declarative menu tripped axe `aria-required-parent`. The role (+ `tabindex="-1"`) is now applied only when the item has a `role="menu"`/`menubar` ancestor; the open popover adopts items via `appendChild` (which re-fires `connected()`), so the role re-applies on open and drops on close. **Verified with axe-core**: closed menu → **0** `aria-required-parent` violations (was 3); open menu → items are `role="menuitem"`, 0 violations. (Tokens Studio / color-app FB-95)
|
|
29
|
+
|
|
30
|
+
### Changed — styles + build
|
|
31
|
+
|
|
32
|
+
- **Tokens cascade-layer completed (`styles/`)** — `typography.css` (`type/scale.css`) + `roles.css` `:root` role-token blocks moved into `@layer(tokens)`, finishing the `@layer` migration's tokens cut (the type-scale + role tokens had stayed unlayered). Behavior-neutral — the `[variant]`/state rules keep their existing precedence; only the base `:root` token declarations join the layer.
|
|
33
|
+
- **`dist/` CDN bundles regenerated** (`web-components.min.css` + `web-components.min.js`) for the template-engine + tokens-layer changes.
|
|
34
|
+
|
|
3
35
|
## [0.7.1] — 2026-05-31
|
|
4
36
|
|
|
5
37
|
### Fixed
|
package/components/card/card.css
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
:where(:scope) {
|
|
9
9
|
/* ── Spacing ── */
|
|
10
10
|
--card-inset-default: var(--a-inset);
|
|
11
|
-
--card-gap-default: var(--a-gap);
|
|
11
|
+
--card-gap-default: var(--a-gap-self, var(--a-gap));
|
|
12
12
|
--card-header-gap-default: var(--a-space-2);
|
|
13
13
|
--card-footer-gap-default: var(--a-space-2);
|
|
14
14
|
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"const": "Col"
|
|
23
23
|
},
|
|
24
24
|
"gap": {
|
|
25
|
-
"description": "Gap between children.
|
|
25
|
+
"description": "Gap between children. Two grammars: a NAMED scale (xs/sm/md/lg/xl) and an integer space-rung (\"0\"…\"10\", \"12\", \"16\", mapped to --a-space-N). The named scale is parametric (half + half·--a-gap-k, = 4/8/12/16/20px at the default k=1); the integer rungs are literal / k-independent. At k=1 the two coincide: xs=1, sm=2, md=3, lg=4, xl=5. Accepts `@bp` notation: gap=\"2 4@md\" = 2 below md, 4 from md upward.",
|
|
26
26
|
"type": "string",
|
|
27
27
|
"default": "md"
|
|
28
28
|
},
|
package/components/col/col.css
CHANGED
package/components/col/col.d.ts
CHANGED
|
@@ -23,7 +23,7 @@ import { UIElement } from '../../core/element.js';
|
|
|
23
23
|
export class UICol extends UIElement {
|
|
24
24
|
/** Cross-axis alignment (start/center/end/stretch). Accepts `@bp` notation: align="stretch center@sm" applies stretch below sm, center from sm up. */
|
|
25
25
|
align: string;
|
|
26
|
-
/** Gap between children.
|
|
26
|
+
/** Gap between children. Two grammars: a NAMED scale (xs/sm/md/lg/xl) and an integer space-rung ("0"…"10", "12", "16", mapped to --a-space-N). The named scale is parametric (half + half·--a-gap-k, = 4/8/12/16/20px at the default k=1); the integer rungs are literal / k-independent. At k=1 the two coincide: xs=1, sm=2, md=3, lg=4, xl=5. Accepts `@bp` notation: gap="2 4@md" = 2 below md, 4 from md upward. */
|
|
27
27
|
gap: string;
|
|
28
28
|
/** Fills remaining space in a flex parent (e.g. inside a Row). CSS-only attribute via :scope[grow] in col.css. */
|
|
29
29
|
grow: boolean;
|
package/components/col/col.yaml
CHANGED
|
@@ -25,9 +25,12 @@ props:
|
|
|
25
25
|
default: stretch
|
|
26
26
|
gap:
|
|
27
27
|
description: >-
|
|
28
|
-
Gap between children.
|
|
29
|
-
rung ("
|
|
30
|
-
|
|
28
|
+
Gap between children. Two grammars: a NAMED scale (xs/sm/md/lg/xl) and an
|
|
29
|
+
integer space-rung ("0"…"10", "12", "16", mapped to --a-space-N). The named
|
|
30
|
+
scale is parametric (half + half·--a-gap-k, = 4/8/12/16/20px at the default
|
|
31
|
+
k=1); the integer rungs are literal / k-independent. At k=1 the two coincide:
|
|
32
|
+
xs=1, sm=2, md=3, lg=4, xl=5. Accepts `@bp` notation: gap="2 4@md" = 2 below
|
|
33
|
+
md, 4 from md upward.
|
|
31
34
|
type: string
|
|
32
35
|
default: md
|
|
33
36
|
grow:
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"const": "Grid"
|
|
28
28
|
},
|
|
29
29
|
"gap": {
|
|
30
|
-
"description": "Grid gap
|
|
30
|
+
"description": "Grid gap (row + column). Two grammars: a NAMED scale (xs/sm/md/lg/xl, parametric via --a-gap-k — 4/8/12/16/20px at k=1) and an integer space-rung (\"0\"…\"10\", \"12\", \"16\", literal). At k=1 they coincide: xs=1, sm=2, md=3, lg=4, xl=5. Responsive `@bp` notation supported: \"2 4@md\" = 2 below md, 4 from md upward.",
|
|
31
31
|
"type": "string",
|
|
32
32
|
"default": "md"
|
|
33
33
|
},
|
package/components/grid/grid.css
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
@scope (grid-ui) {
|
|
2
2
|
:where(:scope) {
|
|
3
|
-
/*
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
/* Explicit-then-ambient: `[gap="N"]` sets non-inheriting
|
|
4
|
+
`--a-gap-self`; `[size]`/`:root` set inheriting `--a-gap` (base
|
|
5
|
+
`--a-gap-md`). See styles/api/sizing.css + spec §5.2. */
|
|
6
|
+
--grid-gap-default: var(--a-gap-self, var(--a-gap));
|
|
6
7
|
--grid-column-gap-default: var(--grid-gap, var(--grid-gap-default));
|
|
7
8
|
--grid-row-gap-default: var(--grid-gap, var(--grid-gap-default));
|
|
8
9
|
}
|
|
@@ -17,7 +17,7 @@ export class UIGrid extends UIElement {
|
|
|
17
17
|
columnGap: string;
|
|
18
18
|
/** Number of equal columns (1–6), "auto-fill", or "auto-fit". Accepts responsive `@bp` notation: "1 2@sm 4@lg" = 1 on xs, 2 from sm, 4 from lg/xl. Unannotated value is the mobile-first base. */
|
|
19
19
|
columns: string;
|
|
20
|
-
/** Grid gap
|
|
20
|
+
/** Grid gap (row + column). Two grammars: a NAMED scale (xs/sm/md/lg/xl, parametric via --a-gap-k — 4/8/12/16/20px at k=1) and an integer space-rung ("0"…"10", "12", "16", literal). At k=1 they coincide: xs=1, sm=2, md=3, lg=4, xl=5. Responsive `@bp` notation supported: "2 4@md" = 2 below md, 4 from md upward. */
|
|
21
21
|
gap: string;
|
|
22
22
|
/** Minimum track width for columns="auto-fit"/"auto-fill" (any CSS length, e.g. "240px", "16rem"). Sets the minmax() floor so cards don't shrink below it before wrapping; unset uses the 12rem default. No effect on numeric columns. */
|
|
23
23
|
minColumnWidth: string;
|
|
@@ -30,9 +30,11 @@ props:
|
|
|
30
30
|
default: "3"
|
|
31
31
|
gap:
|
|
32
32
|
description: >-
|
|
33
|
-
Grid gap
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
Grid gap (row + column). Two grammars: a NAMED scale (xs/sm/md/lg/xl,
|
|
34
|
+
parametric via --a-gap-k — 4/8/12/16/20px at k=1) and an integer space-rung
|
|
35
|
+
("0"…"10", "12", "16", literal). At k=1 they coincide: xs=1, sm=2, md=3, lg=4,
|
|
36
|
+
xl=5. Responsive `@bp` notation supported: "2 4@md" = 2 below md, 4 from md
|
|
37
|
+
upward.
|
|
36
38
|
type: string
|
|
37
39
|
default: md
|
|
38
40
|
minColumnWidth:
|
|
@@ -266,8 +266,20 @@ export class UIMenuItem extends UIElement {
|
|
|
266
266
|
static template = () => null;
|
|
267
267
|
|
|
268
268
|
connected() {
|
|
269
|
-
|
|
270
|
-
|
|
269
|
+
// role="menuitem" ONLY when actually inside a menu container. A menu-ui
|
|
270
|
+
// closes by moving its items back into its own light DOM (see #hide()),
|
|
271
|
+
// where there is no role="menu"/menubar ancestor — a bare role="menuitem"
|
|
272
|
+
// there trips axe `aria-required-parent` on every closed menu (FB-95). The
|
|
273
|
+
// open popover (role="menu") adopts items via appendChild, which re-fires
|
|
274
|
+
// connected(), so the role re-applies when the menu opens and is dropped
|
|
275
|
+
// again on close.
|
|
276
|
+
if (this.closest('[role="menu"], [role="menubar"]')) {
|
|
277
|
+
this.setAttribute('role', 'menuitem');
|
|
278
|
+
if (!this.hasAttribute('tabindex')) this.setAttribute('tabindex', '-1');
|
|
279
|
+
} else {
|
|
280
|
+
this.removeAttribute('role');
|
|
281
|
+
this.removeAttribute('tabindex');
|
|
282
|
+
}
|
|
271
283
|
this.#stamp();
|
|
272
284
|
this.#syncAria();
|
|
273
285
|
}
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"default": false
|
|
28
28
|
},
|
|
29
29
|
"gap": {
|
|
30
|
-
"description": "Gap between children.
|
|
30
|
+
"description": "Gap between children. Two grammars: a NAMED scale (xs/sm/md/lg/xl, parametric via --a-gap-k — 4/8/12/16/20px at k=1) and an integer space-rung (\"0\"…\"10\", \"12\", \"16\", literal). At k=1 they coincide: xs=1, sm=2, md=3, lg=4, xl=5. Accepts `@bp` notation: gap=\"2 4@md\".",
|
|
31
31
|
"type": "string",
|
|
32
32
|
"default": "md"
|
|
33
33
|
},
|
package/components/row/row.css
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
@scope (row-ui) {
|
|
2
2
|
:where(:scope) {
|
|
3
|
-
/*
|
|
4
|
-
|
|
5
|
-
`--a-gap`
|
|
6
|
-
|
|
3
|
+
/* Gap resolves explicit-then-ambient: `[gap="N"]` sets the
|
|
4
|
+
non-inheriting `--a-gap-self`; `[size]`/`:root` set the inheriting
|
|
5
|
+
ambient `--a-gap` (base `--a-gap-md`). Reading
|
|
6
|
+
`var(--a-gap-self, var(--a-gap))` picks up `<row-ui gap="…">` on
|
|
7
|
+
this element, else falls through to the inherited ambient. */
|
|
8
|
+
--row-gap-default: var(--a-gap-self, var(--a-gap));
|
|
7
9
|
--row-justify-default: flex-start;
|
|
8
10
|
--row-align-default: center;
|
|
9
11
|
--row-drag-bg-active-default: var(--a-accent-muted);
|
package/components/row/row.d.ts
CHANGED
|
@@ -24,7 +24,7 @@ export class UIRow extends UIElement {
|
|
|
24
24
|
align: string;
|
|
25
25
|
/** Enables drag handle + cursor:grab. Wires the draggable trait; dispatches drag-end. */
|
|
26
26
|
draggable: boolean;
|
|
27
|
-
/** Gap between children.
|
|
27
|
+
/** Gap between children. Two grammars: a NAMED scale (xs/sm/md/lg/xl, parametric via --a-gap-k — 4/8/12/16/20px at k=1) and an integer space-rung ("0"…"10", "12", "16", literal). At k=1 they coincide: xs=1, sm=2, md=3, lg=4, xl=5. Accepts `@bp` notation: gap="2 4@md". */
|
|
28
28
|
gap: string;
|
|
29
29
|
/** Fills remaining space in a flex parent. CSS-only attribute via :scope[grow] in row.css. */
|
|
30
30
|
grow: boolean;
|
package/components/row/row.yaml
CHANGED
|
@@ -29,8 +29,10 @@ props:
|
|
|
29
29
|
reflect: true
|
|
30
30
|
gap:
|
|
31
31
|
description: >-
|
|
32
|
-
Gap between children.
|
|
33
|
-
|
|
32
|
+
Gap between children. Two grammars: a NAMED scale (xs/sm/md/lg/xl, parametric
|
|
33
|
+
via --a-gap-k — 4/8/12/16/20px at k=1) and an integer space-rung ("0"…"10",
|
|
34
|
+
"12", "16", literal). At k=1 they coincide: xs=1, sm=2, md=3, lg=4, xl=5.
|
|
35
|
+
Accepts `@bp` notation: gap="2 4@md".
|
|
34
36
|
type: string
|
|
35
37
|
default: md
|
|
36
38
|
grow:
|
package/components/stat/stat.css
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
--stat-icon-fg-default: var(--a-fg-muted);
|
|
14
14
|
|
|
15
15
|
/* ── Spacing ── */
|
|
16
|
-
--stat-column-gap-default: var(--a-gap);
|
|
16
|
+
--stat-column-gap-default: var(--a-gap-self, var(--a-gap));
|
|
17
17
|
--stat-row-gap-default: var(--a-gap-sm);
|
|
18
18
|
text-align: start; /* §text-align-reset — blocks inheritance from centered ancestors */
|
|
19
19
|
}
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
@scope (swiper-ui) {
|
|
22
22
|
:where(:scope) {
|
|
23
23
|
/* ── Layout ── */
|
|
24
|
-
--swiper-gap-default: var(--a-gap);
|
|
24
|
+
--swiper-gap-default: var(--a-gap-self, var(--a-gap));
|
|
25
25
|
--swiper-peek-default: var(--a-space-8);
|
|
26
26
|
--swiper-snap-default: start;
|
|
27
27
|
--swiper-columns-default: 1;
|
|
@@ -266,6 +266,11 @@ export class UITreeItem extends UIElement {
|
|
|
266
266
|
|
|
267
267
|
static template = () => null;
|
|
268
268
|
|
|
269
|
+
// FB-96: late-adoption of interpolated [slot="actions"]/[slot="caret"]
|
|
270
|
+
// children — see connected() + #adoptSlotted().
|
|
271
|
+
#slotObserver = null;
|
|
272
|
+
#adopting = false;
|
|
273
|
+
|
|
269
274
|
get hasChildren() {
|
|
270
275
|
return this.querySelector(':scope > tree-item-ui') !== null;
|
|
271
276
|
}
|
|
@@ -303,6 +308,17 @@ export class UITreeItem extends UIElement {
|
|
|
303
308
|
row.setAttribute('role', 'treeitem');
|
|
304
309
|
if (!row.hasAttribute('tabindex')) row.setAttribute('tabindex', '0');
|
|
305
310
|
}
|
|
311
|
+
|
|
312
|
+
// FB-96: declarative [slot="actions"]/[slot="caret"] children rendered via
|
|
313
|
+
// the template engine (`.map()`/`repeat()`/conditional) arrive in a LATER
|
|
314
|
+
// reactive tick — AFTER #stamp() — nested in the engine's display:contents
|
|
315
|
+
// wrapper spans. So neither a connect-time read nor a `:scope >` direct-child
|
|
316
|
+
// query sees them, and e.g. a `<button-ui slot="actions">` is left as a bare
|
|
317
|
+
// role=button child of the group/tree (axe `aria-required-children`). Adopt
|
|
318
|
+
// what's present now, then observe for the late arrivals.
|
|
319
|
+
this.#adoptSlotted();
|
|
320
|
+
this.#slotObserver = new MutationObserver(() => this.#adoptSlotted());
|
|
321
|
+
this.#slotObserver.observe(this, { childList: true, subtree: true });
|
|
306
322
|
}
|
|
307
323
|
|
|
308
324
|
#stamp() {
|
|
@@ -310,15 +326,18 @@ export class UITreeItem extends UIElement {
|
|
|
310
326
|
row.setAttribute('slot', 'row');
|
|
311
327
|
row.setAttribute('tabindex', '0');
|
|
312
328
|
|
|
313
|
-
// Caret — adopt a declarative [slot="caret"] child if
|
|
314
|
-
//
|
|
315
|
-
|
|
329
|
+
// Caret — adopt a declarative [slot="caret"] child if one is already present
|
|
330
|
+
// (incl. inside the engine's display:contents wrappers, FB-96), else stamp
|
|
331
|
+
// the default, marked so #adoptSlotted() can drop it if a declared caret
|
|
332
|
+
// arrives late.
|
|
333
|
+
const declaredCaret = this.#logicalSlotChildren('caret')[0];
|
|
316
334
|
if (declaredCaret) {
|
|
317
335
|
row.appendChild(declaredCaret);
|
|
318
336
|
} else {
|
|
319
337
|
const caret = document.createElement('icon-ui');
|
|
320
338
|
caret.setAttribute('slot', 'caret');
|
|
321
339
|
caret.setAttribute('name', 'caret-right');
|
|
340
|
+
caret.dataset.treeCaretDefault = '1';
|
|
322
341
|
row.appendChild(caret);
|
|
323
342
|
}
|
|
324
343
|
|
|
@@ -343,19 +362,16 @@ export class UITreeItem extends UIElement {
|
|
|
343
362
|
if (this.badge) badgeEl.textContent = this.badge;
|
|
344
363
|
row.appendChild(badgeEl);
|
|
345
364
|
|
|
346
|
-
// Actions —
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
// #
|
|
350
|
-
//
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
actions.setAttribute('slot', 'actions');
|
|
357
|
-
row.appendChild(actions);
|
|
358
|
-
}
|
|
365
|
+
// Actions — stamp an empty placeholder; the real declarative [slot="actions"]
|
|
366
|
+
// children (FEEDBACK-89) are moved into the row by #adoptSlotted(), which
|
|
367
|
+
// runs in connected() AND on later mutations, so interpolated buttons that
|
|
368
|
+
// arrive after #stamp (nested in the engine's display:contents wrappers,
|
|
369
|
+
// FB-96) get adopted too. The placeholder is dropped once a real one lands.
|
|
370
|
+
// (#onClick excludes [slot="actions"] * from row selection — adoption-safe.)
|
|
371
|
+
const actions = document.createElement('span');
|
|
372
|
+
actions.setAttribute('slot', 'actions');
|
|
373
|
+
actions.dataset.treeActionsPlaceholder = '1';
|
|
374
|
+
row.appendChild(actions);
|
|
359
375
|
|
|
360
376
|
this.prepend(row);
|
|
361
377
|
}
|
|
@@ -385,4 +401,61 @@ export class UITreeItem extends UIElement {
|
|
|
385
401
|
const badgeEl = row.querySelector('[slot="badge"]');
|
|
386
402
|
if (badgeEl) badgeEl.textContent = this.badge || '';
|
|
387
403
|
}
|
|
404
|
+
|
|
405
|
+
// ── FB-96: wrapper-piercing, late adoption of slotted row children ──
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Collect this item's logical `[slot="<slot>"]` children — direct, or nested
|
|
409
|
+
* inside the template engine's `display:contents` wrapper spans (which carry
|
|
410
|
+
* `role="presentation"` since 0.7.2) — but NOT inside a nested `<tree-item-ui>`
|
|
411
|
+
* (a plain descendant query would wrongly grab a child item's actions/caret).
|
|
412
|
+
*/
|
|
413
|
+
#logicalSlotChildren(slot) {
|
|
414
|
+
const out = [];
|
|
415
|
+
const walk = (parent) => {
|
|
416
|
+
for (const ch of parent.children) {
|
|
417
|
+
if (ch.matches('tree-item-ui')) continue; // nested-item boundary
|
|
418
|
+
if (ch.getAttribute('slot') === slot) { out.push(ch); continue; }
|
|
419
|
+
// pierce the engine's transparent wrapper spans
|
|
420
|
+
if (ch.matches('[role="presentation"]') || ch.style?.display === 'contents') walk(ch);
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
walk(this);
|
|
424
|
+
return out;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Move declarative `[slot="actions"]`/`[slot="caret"]` children into the
|
|
429
|
+
* stamped row. Idempotent + re-entrancy-guarded (it mutates `this`, which
|
|
430
|
+
* re-fires #slotObserver). Drops the auto-stamped placeholder/default once a
|
|
431
|
+
* real child is adopted. (FEEDBACK-89 + FB-96.)
|
|
432
|
+
*/
|
|
433
|
+
#adoptSlotted() {
|
|
434
|
+
if (this.#adopting) return;
|
|
435
|
+
const row = this.querySelector(':scope > [slot="row"]');
|
|
436
|
+
if (!row) return;
|
|
437
|
+
this.#adopting = true;
|
|
438
|
+
try {
|
|
439
|
+
const actions = this.#logicalSlotChildren('actions')
|
|
440
|
+
.filter((el) => !el.dataset.treeActionsPlaceholder && el.parentElement !== row);
|
|
441
|
+
if (actions.length) {
|
|
442
|
+
row.querySelector(':scope > [slot="actions"][data-tree-actions-placeholder]')?.remove();
|
|
443
|
+
for (const el of actions) row.appendChild(el);
|
|
444
|
+
}
|
|
445
|
+
const caret = this.#logicalSlotChildren('caret')
|
|
446
|
+
.find((el) => !el.dataset.treeCaretDefault && el.parentElement !== row);
|
|
447
|
+
if (caret) {
|
|
448
|
+
row.querySelector(':scope > [slot="caret"][data-tree-caret-default]')?.remove();
|
|
449
|
+
row.prepend(caret);
|
|
450
|
+
}
|
|
451
|
+
} finally {
|
|
452
|
+
this.#slotObserver?.takeRecords(); // drain self-mutations — no re-entrant pass
|
|
453
|
+
this.#adopting = false;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
disconnected() {
|
|
458
|
+
this.#slotObserver?.disconnect();
|
|
459
|
+
this.#slotObserver = null;
|
|
460
|
+
}
|
|
388
461
|
}
|
|
@@ -11,6 +11,7 @@ import { html, stamp } from '../../core/template.js';
|
|
|
11
11
|
|
|
12
12
|
beforeAll(async () => {
|
|
13
13
|
await import('./tree.js');
|
|
14
|
+
await import('../button/button.js');
|
|
14
15
|
});
|
|
15
16
|
|
|
16
17
|
describe('<tree-ui> tree-select forwards modifier keys (FB-46)', () => {
|
|
@@ -261,3 +262,51 @@ describe('<tree-item-ui> ARIA tree containment (FEEDBACK-91)', () => {
|
|
|
261
262
|
expect(parent.querySelector(':scope > [slot="row"]').hasAttribute('aria-selected')).toBe(false);
|
|
262
263
|
});
|
|
263
264
|
});
|
|
265
|
+
|
|
266
|
+
describe('<tree-item-ui> adopts interpolated [slot="actions"] into the row (FEEDBACK-96)', () => {
|
|
267
|
+
let host;
|
|
268
|
+
const settle = () => new Promise((r) => setTimeout(r, 40));
|
|
269
|
+
beforeEach(() => { host = document.createElement('div'); document.body.appendChild(host); });
|
|
270
|
+
afterEach(() => host.remove());
|
|
271
|
+
|
|
272
|
+
it('moves a `.map()`-rendered actions button into the treeitem row (not a stray child of the group)', async () => {
|
|
273
|
+
const PALS = [{ id: 'n', name: 'Neutral' }, { id: 'b', name: 'Brand' }];
|
|
274
|
+
stamp(html`
|
|
275
|
+
<tree-ui>
|
|
276
|
+
<tree-item-ui text="Colors" value="colors" open>
|
|
277
|
+
${html`<button-ui slot="actions" icon="plus" title="Add"></button-ui>`}
|
|
278
|
+
${PALS.map((p) => html`<tree-item-ui text="${p.name}" value="${p.id}"></tree-item-ui>`)}
|
|
279
|
+
</tree-item-ui>
|
|
280
|
+
</tree-ui>
|
|
281
|
+
`, host);
|
|
282
|
+
await settle();
|
|
283
|
+
|
|
284
|
+
const colors = host.querySelector('tree-item-ui[value="colors"]');
|
|
285
|
+
const row = colors.querySelector(':scope > [slot="row"]');
|
|
286
|
+
const btn = colors.querySelector('button-ui[slot="actions"]');
|
|
287
|
+
expect(btn).not.toBeNull();
|
|
288
|
+
// The fix: the interpolated button (wrapped in a display:contents span) is
|
|
289
|
+
// adopted INTO the row. Pre-fix, #stamp's `:scope >` query missed it (the
|
|
290
|
+
// button arrives after #stamp, inside a wrapper span) and it stayed a bare
|
|
291
|
+
// child of the group/tree → axe aria-required-children.
|
|
292
|
+
expect(btn.parentElement).toBe(row);
|
|
293
|
+
// Auto-stamped empty placeholder dropped — exactly one actions slot in the row.
|
|
294
|
+
expect(row.querySelectorAll(':scope > [slot="actions"]').length).toBe(1);
|
|
295
|
+
// Nested items NOT mis-adopted as actions (the tree-item-ui boundary holds).
|
|
296
|
+
expect(host.querySelectorAll('tree-item-ui').length).toBe(3);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('is idempotent — no double-adopt across settles', async () => {
|
|
300
|
+
stamp(html`
|
|
301
|
+
<tree-ui>
|
|
302
|
+
<tree-item-ui text="Colors" value="colors" open>
|
|
303
|
+
${html`<button-ui slot="actions" icon="plus"></button-ui>`}
|
|
304
|
+
</tree-item-ui>
|
|
305
|
+
</tree-ui>
|
|
306
|
+
`, host);
|
|
307
|
+
await settle();
|
|
308
|
+
await settle();
|
|
309
|
+
const row = host.querySelector('tree-item-ui[value="colors"] > [slot="row"]');
|
|
310
|
+
expect(row.querySelectorAll(':scope > [slot="actions"]').length).toBe(1);
|
|
311
|
+
});
|
|
312
|
+
});
|
package/core/template.js
CHANGED
|
@@ -430,6 +430,13 @@ function wrap(part) {
|
|
|
430
430
|
if (part.n.nodeType === 1 && part.n[PART]) return part.n;
|
|
431
431
|
const c = document.createElement('span');
|
|
432
432
|
c.style.display = 'contents';
|
|
433
|
+
// ARIA-transparent: display:contents removes the box from layout, but the
|
|
434
|
+
// span still generates a generic accessibility node — which breaks
|
|
435
|
+
// required-owned / required-parent relationships when it sits between a
|
|
436
|
+
// role-bearing parent and role-bearing children rendered via interpolation
|
|
437
|
+
// (tree→treeitem, menu→menuitem via `.map()`/`repeat()`). role="presentation"
|
|
438
|
+
// promotes the children to the real parent in the a11y tree. (FB-94 / FB-95)
|
|
439
|
+
c.setAttribute('role', 'presentation');
|
|
433
440
|
c[PART] = true;
|
|
434
441
|
part.n.replaceWith(c);
|
|
435
442
|
part.n = c;
|
|
@@ -453,6 +460,7 @@ function applyValue(p, v) {
|
|
|
453
460
|
if (isResult(item)) {
|
|
454
461
|
const el = document.createElement('span');
|
|
455
462
|
el.style.display = 'contents';
|
|
463
|
+
el.setAttribute('role', 'presentation'); // ARIA-transparent — see wrap()
|
|
456
464
|
c.appendChild(el);
|
|
457
465
|
stamp(item, el);
|
|
458
466
|
} else {
|
|
@@ -526,6 +534,7 @@ export function repeat(items, keyFn, tplFn) {
|
|
|
526
534
|
} else {
|
|
527
535
|
const el = document.createElement('span');
|
|
528
536
|
el.style.display = 'contents';
|
|
537
|
+
el.setAttribute('role', 'presentation'); // ARIA-transparent — see wrap()
|
|
529
538
|
stamp(result, el);
|
|
530
539
|
newMap.set(key, el);
|
|
531
540
|
}
|
package/core/template.test.js
CHANGED
|
@@ -573,3 +573,39 @@ describe('html template — FB-57 (v0.6.8) nested SVG/MathML template namespace
|
|
|
573
573
|
expect(p.namespaceURI).toBe('http://www.w3.org/1999/xhtml');
|
|
574
574
|
});
|
|
575
575
|
});
|
|
576
|
+
|
|
577
|
+
describe('html template — FB-94/FB-95 (v0.7.x) ARIA-transparent wrapper spans', () => {
|
|
578
|
+
let container;
|
|
579
|
+
beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); });
|
|
580
|
+
afterEach(() => container.remove());
|
|
581
|
+
|
|
582
|
+
// The display:contents wrapper span the engine emits for interpolated parts
|
|
583
|
+
// must carry role="presentation" — display:contents removes the box from
|
|
584
|
+
// layout but the span still generates a generic a11y node, which breaks
|
|
585
|
+
// required-owned / required-parent relationships when it sits between a
|
|
586
|
+
// role-bearing parent and role-bearing children rendered via .map()/repeat()
|
|
587
|
+
// (tree→treeitem = FB-94, menu→menuitem = FB-95). Verified end-to-end with
|
|
588
|
+
// axe-core; these lock the substrate behaviour against regression.
|
|
589
|
+
const isContentsSpan = (s) => /display:\s*contents/.test(s.getAttribute('style') || '');
|
|
590
|
+
|
|
591
|
+
it('a `.map()` of templates wraps each item in a role="presentation" display:contents span', () => {
|
|
592
|
+
stamp(html`<ul>${['a', 'b', 'c'].map((x) => html`<li>${x}</li>`)}</ul>`, container);
|
|
593
|
+
const spans = [...container.querySelectorAll('span')].filter(isContentsSpan);
|
|
594
|
+
expect(spans.length).toBeGreaterThan(0);
|
|
595
|
+
spans.forEach((s) => expect(s.getAttribute('role')).toBe('presentation'));
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it('a conditional `${cond ? html`` : null}` branch wraps in role="presentation"', () => {
|
|
599
|
+
stamp(html`<div>${true ? html`<span data-x>hi</span>` : null}</div>`, container);
|
|
600
|
+
const wrap = [...container.querySelectorAll('div > span')].find(isContentsSpan);
|
|
601
|
+
expect(wrap).toBeTruthy();
|
|
602
|
+
expect(wrap.getAttribute('role')).toBe('presentation');
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it('`repeat()` keyed items wrap in role="presentation"', () => {
|
|
606
|
+
stamp(html`<ul>${repeat(['a', 'b'], (x) => x, (x) => html`<li>${x}</li>`)}</ul>`, container);
|
|
607
|
+
const spans = [...container.querySelectorAll('span')].filter(isContentsSpan);
|
|
608
|
+
expect(spans.length).toBeGreaterThan(0);
|
|
609
|
+
spans.forEach((s) => expect(s.getAttribute('role')).toBe('presentation'));
|
|
610
|
+
});
|
|
611
|
+
});
|