luxen-ui 0.9.1 → 0.9.2

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.
@@ -109,6 +109,17 @@ export class TreeItem extends LuxenElement {
109
109
  get depth() {
110
110
  return this._depth;
111
111
  }
112
+ /**
113
+ * Set by `<l-tree>`: ARIA position within the tree. `level` is 1-based depth,
114
+ * `posInSet`/`setSize` describe the item's rank among its siblings. These let
115
+ * screen readers announce "level 2, 3 of 5" even when `lazy` children keep the
116
+ * full set out of the DOM.
117
+ */
118
+ setPosition(level, posInSet, setSize) {
119
+ this._aria('ariaLevel', 'aria-level', String(level));
120
+ this._aria('ariaPosInSet', 'aria-posinset', String(posInSet));
121
+ this._aria('ariaSetSize', 'aria-setsize', String(setSize));
122
+ }
112
123
  /** Whether this item has nested tree-item children. */
113
124
  get hasChildren() {
114
125
  return this._hasChildren;
@@ -136,6 +147,12 @@ export class TreeItem extends LuxenElement {
136
147
  connectedCallback() {
137
148
  super.connectedCallback();
138
149
  this._internals.role = 'treeitem';
150
+ // Mirror the role to a DOM attribute too, so `[role="treeitem"]` selectors
151
+ // (CSS, querySelector, Cypress/Playwright CSS) keep matching — the
152
+ // ElementInternals role alone is not attribute-selectable. ARIA states are
153
+ // mirrored the same way in `_aria()` (aria-expanded/selected/disabled/…).
154
+ if (!this.hasAttribute('role'))
155
+ this.setAttribute('role', 'treeitem');
139
156
  this._childObserver = new MutationObserver(() => this._syncChildren());
140
157
  this._childObserver.observe(this, { childList: true });
141
158
  this._syncChildren();
@@ -146,16 +163,42 @@ export class TreeItem extends LuxenElement {
146
163
  }
147
164
  updated(changed) {
148
165
  if (changed.has('expanded')) {
149
- this._internals.ariaExpanded = this.isLeaf() ? null : String(this.expanded);
166
+ this._reflectExpanded();
167
+ // Emit lazy-load for ANY transition to expanded (keyboard, chevron,
168
+ // `expandAll()`…), not just `toggle()` — otherwise keyboard users expand a
169
+ // lazy branch with no children and no fetch. Fires while still `lazy`; the
170
+ // consumer appends children and clears `lazy`, so it won't re-fire.
171
+ if (this.expanded && this.lazy)
172
+ this.emit('lazy-load');
150
173
  this.emit(this.expanded ? 'expand' : 'collapse');
151
174
  }
152
175
  if (changed.has('selected')) {
153
- this._internals.ariaSelected = String(this.selected);
176
+ this._aria('ariaSelected', 'aria-selected', String(this.selected));
154
177
  }
155
178
  if (changed.has('disabled')) {
156
- this._internals.ariaDisabled = this.disabled ? 'true' : null;
179
+ this._aria('ariaDisabled', 'aria-disabled', this.disabled ? 'true' : null);
180
+ }
181
+ if (changed.has('loading')) {
182
+ this._aria('ariaBusy', 'aria-busy', this.loading ? 'true' : null);
157
183
  }
158
184
  }
185
+ /** Leaf items omit `aria-expanded` entirely; branches reflect their state. */
186
+ _reflectExpanded() {
187
+ this._aria('ariaExpanded', 'aria-expanded', this.isLeaf() ? null : String(this.expanded));
188
+ }
189
+ /**
190
+ * Write an ARIA state to BOTH ElementInternals (the semantic source, in the
191
+ * accessibility tree) and a content attribute (so `[aria-*]` CSS / query / test
192
+ * selectors keep matching — same belt-and-suspenders as the mirrored `role`).
193
+ * A `null` value clears both.
194
+ */
195
+ _aria(key, attr, value) {
196
+ this._internals[key] = value;
197
+ if (value === null)
198
+ this.removeAttribute(attr);
199
+ else
200
+ this.setAttribute(attr, value);
201
+ }
159
202
  _setState(name, on) {
160
203
  if (!this._internals.states)
161
204
  return;
@@ -181,17 +224,14 @@ export class TreeItem extends LuxenElement {
181
224
  if (!this._hasChildren && !this.lazy && this.expanded) {
182
225
  this.expanded = false;
183
226
  }
184
- this._internals.ariaExpanded = this.isLeaf() ? null : String(this.expanded);
227
+ this._reflectExpanded();
185
228
  }
186
- /** Toggle expand state. Emits `lazy-load` the first time a lazy item opens. */
229
+ /** Toggle expand state. Opening a `lazy` item emits `lazy-load` (via `updated`). */
187
230
  toggle() {
188
231
  if (this.isLeaf() && !this.lazy)
189
232
  return;
190
- const next = !this.expanded;
191
- if (next && this.lazy) {
192
- this.emit('lazy-load');
193
- }
194
- this.expanded = next;
233
+ // lazy-load is emitted centrally from `updated()` on the expand transition.
234
+ this.expanded = !this.expanded;
195
235
  }
196
236
  render() {
197
237
  return html `
@@ -245,6 +285,7 @@ export class TreeItem extends LuxenElement {
245
285
  part="checkbox"
246
286
  type="checkbox"
247
287
  tabindex="-1"
288
+ aria-hidden="true"
248
289
  .checked=${this.selected}
249
290
  .indeterminate=${this.indeterminate}
250
291
  ?disabled=${this.disabled}
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.9.1",
2
+ "version": "0.9.2",
3
3
  "elements": [
4
4
  {
5
5
  "name": "avatar",
@@ -4017,7 +4017,7 @@
4017
4017
  "tag": "l-tree",
4018
4018
  "nativeTag": null,
4019
4019
  "selector": "l-tree",
4020
- "summary": "A hierarchical tree view composed of `<l-tree-item>` children.",
4020
+ "summary": "A hierarchical tree view composed of `<l-tree-item>` children.\n\nThe host carries `role=\"tree\"`, so give it an accessible name with\n`aria-label` or `aria-labelledby` (e.g. `<l-tree aria-label=\"Files\">`).",
4021
4021
  "status": "stable",
4022
4022
  "appearances": [],
4023
4023
  "import": {
@@ -4247,6 +4247,25 @@
4247
4247
  }
4248
4248
  ],
4249
4249
  "methods": [
4250
+ {
4251
+ "name": "setPosition",
4252
+ "params": [
4253
+ {
4254
+ "name": "level",
4255
+ "type": "number"
4256
+ },
4257
+ {
4258
+ "name": "posInSet",
4259
+ "type": "number"
4260
+ },
4261
+ {
4262
+ "name": "setSize",
4263
+ "type": "number"
4264
+ }
4265
+ ],
4266
+ "returns": null,
4267
+ "description": "Set by `<l-tree>`: ARIA position within the tree. `level` is 1-based depth,\n`posInSet`/`setSize` describe the item's rank among its siblings. These let\nscreen readers announce \"level 2, 3 of 5\" even when `lazy` children keep the\nfull set out of the DOM."
4268
+ },
4250
4269
  {
4251
4270
  "name": "getChildrenItems",
4252
4271
  "params": [
@@ -4274,7 +4293,7 @@
4274
4293
  "name": "toggle",
4275
4294
  "params": [],
4276
4295
  "returns": null,
4277
- "description": "Toggle expand state. Emits `lazy-load` the first time a lazy item opens."
4296
+ "description": "Toggle expand state. Opening a `lazy` item emits `lazy-load` (via `updated`)."
4278
4297
  }
4279
4298
  ],
4280
4299
  "slots": [
@@ -106,6 +106,25 @@
106
106
  }
107
107
  ],
108
108
  "methods": [
109
+ {
110
+ "name": "setPosition",
111
+ "params": [
112
+ {
113
+ "name": "level",
114
+ "type": "number"
115
+ },
116
+ {
117
+ "name": "posInSet",
118
+ "type": "number"
119
+ },
120
+ {
121
+ "name": "setSize",
122
+ "type": "number"
123
+ }
124
+ ],
125
+ "returns": null,
126
+ "description": "Set by `<l-tree>`: ARIA position within the tree. `level` is 1-based depth,\n`posInSet`/`setSize` describe the item's rank among its siblings. These let\nscreen readers announce \"level 2, 3 of 5\" even when `lazy` children keep the\nfull set out of the DOM."
127
+ },
109
128
  {
110
129
  "name": "getChildrenItems",
111
130
  "params": [
@@ -133,7 +152,7 @@
133
152
  "name": "toggle",
134
153
  "params": [],
135
154
  "returns": null,
136
- "description": "Toggle expand state. Emits `lazy-load` the first time a lazy item opens."
155
+ "description": "Toggle expand state. Opening a `lazy` item emits `lazy-load` (via `updated`)."
137
156
  }
138
157
  ],
139
158
  "slots": [
@@ -6,7 +6,7 @@
6
6
  "tag": "l-tree",
7
7
  "nativeTag": null,
8
8
  "selector": "l-tree",
9
- "summary": "A hierarchical tree view composed of `<l-tree-item>` children.",
9
+ "summary": "A hierarchical tree view composed of `<l-tree-item>` children.\n\nThe host carries `role=\"tree\"`, so give it an accessible name with\n`aria-label` or `aria-labelledby` (e.g. `<l-tree aria-label=\"Files\">`).",
10
10
  "status": "stable",
11
11
  "appearances": [],
12
12
  "import": {
@@ -286,6 +286,24 @@ A roadmap of phases and tasks, where collapsing folds away phase details for a h
286
286
  - `Space` — Activates the item (selects or toggles expansion depending on mode)
287
287
  - `*` — Expands all sibling branches of the focused item
288
288
 
289
+ ### Selectors & testing
290
+
291
+ Roles and ARIA states are set on `ElementInternals` (the accessibility-tree source) **and** mirrored to DOM attributes, so both `[role]` and `[aria-*]` selectors keep matching in CSS, `querySelector`, and Cypress/Playwright. Selection state is additionally exposed as the reflected `selected`/`expanded`/`disabled`/`indeterminate` boolean attributes (the component's own API).
292
+
293
+ ```js
294
+ document.querySelectorAll('[role="treeitem"]'); // ✅ role is mirrored to an attribute
295
+ document.querySelectorAll('[aria-selected="true"]'); // ✅ ARIA state is mirrored too
296
+ tree.querySelectorAll('l-tree-item[selected]'); // ✅ reflected boolean attribute
297
+ screen.getByRole('treeitem', { selected: true, expanded: true }); // ✅ name + state
298
+ ```
299
+
300
+ ```css
301
+ /* Style by ARIA state or the reflected boolean attribute — both work. */
302
+ l-tree-item[aria-selected='true']::part(base) {
303
+ background: var(--l-color-bg-fill-brand-subtle);
304
+ }
305
+ ```
306
+
289
307
  ## API reference
290
308
 
291
309
  ### Importing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "luxen-ui",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "Modern web components and CSS-first UI library built with Lit. Framework-agnostic, customizable prefix, design tokens.",
5
5
  "keywords": [
6
6
  "custom-elements",
@@ -161,7 +161,9 @@
161
161
  "manifest": "cem analyze",
162
162
  "metadata": "pnpm run manifest && node scripts/normalize-metadata.mjs && node scripts/check-metadata.mjs",
163
163
  "preview": "vp preview",
164
- "test": "vp test run --passWithNoTests",
164
+ "test": "vp test run --passWithNoTests && vp test run --config vitest.browser.config.ts",
165
+ "test:unit": "vp test run --passWithNoTests",
166
+ "test:components": "vp test run --config vitest.browser.config.ts",
165
167
  "test:e2e": "playwright test"
166
168
  }
167
169
  }