@adia-ai/web-components 0.8.0 → 0.8.1

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 CHANGED
@@ -1,4 +1,14 @@
1
1
  # Changelog — @adia-ai/web-components
2
+ ## [0.8.1] — 2026-07-15
3
+
4
+ ### Added
5
+ - **`tags-input-ui` chip slot template (TKT-0023)** (`components/tags-input/`). An author `<template slot="chip">` child is cloned once per committed token, with `{{value}}`/`{{index}}` substituted into every text node and attribute of the clone (single regex pass — a token literally equal to a placeholder can't be re-substituted); the template's first element child is the reconcile unit (positional diff — unchanged tokens keep their DOM node), and chip removal delegates from any `[data-index]` root via a bubbling `remove` event. Falls back to the default `<tag-ui removable text>` when the slot is absent or has no element content. Also fixed the latent `#stampShell()` first-connect `innerHTML` replacement that would have destroyed an author's template before it was ever read. 8 new tests.
6
+
7
+ ### Fixed
8
+ - **`--a-link` / `--a-link-hover` / `--a-link-visited` failed WCAG AA** (`styles/colors/semantics/core.css`; as low as 2.23:1 on the light canvas) — they read the bare `--md-sys-color-primary` CTA fill, tuned for large filled surfaces, not small text. Re-pointed to Material's `-on-surface` state family (the same family `--a-primary-text` uses); clears 4.5:1 with margin in both schemes. Visible change: links render darker (light scheme) / lighter (dark scheme) than 0.8.0. Caught by the rewritten `verify:contrast` repo gate, which now resolves the real token cascade instead of the retired parametric formula.
9
+
10
+ ### Maintenance
11
+ - **`dist/` bundles rebuilt** (tags-input + link-fix payloads; theme-provider CDN script inlines the swept CSS).
2
12
  ## [0.8.0] — 2026-07-15
3
13
 
4
14
  ### BREAKING — Material Design color-token adoption (MINOR cut; see `.claude/docs/MIGRATION GUIDE.md` § v0.8.0)
@@ -227,7 +227,7 @@
227
227
  ],
228
228
  "slots": {
229
229
  "chip": {
230
- "description": "Planned: custom chip template, receiving `{value, index}` per\ntoken NOT YET IMPLEMENTED (TKT-0023; the template mechanism's\nshape needs a design decision before it's built). Every token\ncurrently renders as the fixed default,\n`<tag-ui removable text=\"<value>\">`, with no override path."
230
+ "description": "Custom per-token chip template (TKT-0023). Provide a single\n`<template slot=\"chip\">` child; it's cloned once per committed\ntoken, with `{{value}}` / `{{index}}` substituted into every text\nnode and attribute value in the clone (e.g.\n`<tag-ui removable text=\"{{value}}\" title=\"Item #{{index}}\">`).\nThe template's first element child is the reconcile unit — data-*\nbookkeeping attrs land on it, so removal / re-render still track\nby position the same way the default chip does. When absent, or\nwhen the template has no element content (malformed), each token\nrenders as the default `<tag-ui removable text=\"<value>\">`.\nReadonly/disabled toggling of the built-in `removable` affordance\nonly applies to the default chip — a custom template always owns\nits own removability, independent of the host's readonly/disabled\nstate."
231
231
  }
232
232
  },
233
233
  "states": [
@@ -261,7 +261,7 @@
261
261
  "name": "disabled"
262
262
  },
263
263
  {
264
- "description": "Read-only; chips render without remove affordance.",
264
+ "description": "Read-only; default chips render without remove affordance (a\ncustom `chip` template owns its own removability and is\nunaffected).",
265
265
  "attribute": "readonly",
266
266
  "name": "readonly"
267
267
  }
@@ -238,6 +238,11 @@ export class UITagsInput extends UIFormElement {
238
238
  this.#bindListeners();
239
239
  return;
240
240
  }
241
+ // Capture any author-declared chip template (TKT-0023) BEFORE the
242
+ // wholesale `innerHTML` replacement below, which would otherwise
243
+ // discard it — the captured reference stays valid (just detached)
244
+ // across the replacement and gets re-appended after.
245
+ const chipTemplate = this.querySelector(':scope > template[slot="chip"]');
241
246
  const suggestId = `${this.#instanceId}-suggestions`;
242
247
  const inputId = `${this.#instanceId}-input`;
243
248
  this.innerHTML = `
@@ -257,6 +262,7 @@ export class UITagsInput extends UIFormElement {
257
262
  </span>
258
263
  <div data-suggestions id="${suggestId}" role="listbox" hidden></div>
259
264
  `;
265
+ if (chipTemplate) this.appendChild(chipTemplate);
260
266
  this.#queryRefs();
261
267
  this.#bindListeners();
262
268
  }
@@ -299,29 +305,38 @@ export class UITagsInput extends UIFormElement {
299
305
  }
300
306
 
301
307
  // ── Render chips ──
308
+ //
309
+ // SPEC-042 §chip slot (TKT-0023): an author-provided
310
+ // `<template slot="chip">` overrides the default `<tag-ui>` render, one
311
+ // clone per token. `{{value}}` / `{{index}}` are substituted into every
312
+ // text node and attribute value in the cloned subtree — declarative, no
313
+ // JS required, so it works from static markup and a2ui-generated pages.
302
314
 
303
315
  #renderChips() {
304
316
  if (!this.#chipList) return;
305
- // Reconcile in place: existing chips that still match keep their nodes
306
- // (so the user's :hover / :focus / animation states don't flicker on
307
- // each typed-character render); excess chips get dropped; new chips
308
- // append at the end.
309
- const existing = Array.from(this.#chipList.querySelectorAll(':scope > tag-ui'));
317
+ const template = this.querySelector(':scope > template[slot="chip"]');
318
+ const mode = template ? 'custom' : 'default';
319
+ // Reconcile in place: an existing chip at a position keeps its node
320
+ // when its value + mode haven't changed (so :hover / :focus / animation
321
+ // states don't flicker on each typed-character render); a changed or
322
+ // absent position gets a fresh node; excess chips drop; new ones append.
323
+ const existing = Array.from(this.#chipList.children);
310
324
  for (let i = 0; i < this.#value.length; i++) {
311
325
  const v = this.#value[i];
312
- let chip = existing[i];
313
- if (!chip) {
314
- chip = document.createElement('tag-ui');
315
- chip.setAttribute('role', 'listitem');
316
- this.#chipList.appendChild(chip);
317
- }
318
- chip.setAttribute('text', v);
319
- if (this.readonly || this.disabled) {
320
- chip.removeAttribute('removable');
321
- } else {
322
- chip.setAttribute('removable', '');
326
+ const current = existing[i];
327
+ const reusable = current && current.dataset.chipMode === mode && current.dataset.chipValue === v;
328
+ if (reusable) {
329
+ current.setAttribute('data-index', String(i));
330
+ if (!template) {
331
+ if (this.readonly || this.disabled) current.removeAttribute('removable');
332
+ else current.setAttribute('removable', '');
333
+ }
334
+ continue;
323
335
  }
324
- chip.setAttribute('data-index', String(i));
336
+ const fresh = template ? this.#buildCustomChip(template, v, i) : this.#buildDefaultChip(v, i);
337
+ if (current) this.#chipList.replaceChild(fresh, current);
338
+ else this.#chipList.appendChild(fresh);
339
+ existing[i] = fresh;
325
340
  }
326
341
  // Drop overflow chips.
327
342
  for (let i = this.#value.length; i < existing.length; i++) {
@@ -329,6 +344,58 @@ export class UITagsInput extends UIFormElement {
329
344
  }
330
345
  }
331
346
 
347
+ #buildDefaultChip(value, index) {
348
+ const chip = document.createElement('tag-ui');
349
+ chip.setAttribute('role', 'listitem');
350
+ chip.setAttribute('text', value);
351
+ if (!(this.readonly || this.disabled)) chip.setAttribute('removable', '');
352
+ chip.setAttribute('data-index', String(index));
353
+ chip.dataset.chipMode = 'default';
354
+ chip.dataset.chipValue = value;
355
+ return chip;
356
+ }
357
+
358
+ #buildCustomChip(template, value, index) {
359
+ const frag = template.content.cloneNode(true);
360
+ this.#substitutePlaceholders(frag, value, index);
361
+ // Reconcile unit = first element child (ignores incidental whitespace
362
+ // text nodes from formatted template markup).
363
+ const root = frag.firstElementChild;
364
+ if (!root) return this.#buildDefaultChip(value, index); // malformed template — degrade to default rather than render nothing
365
+ root.setAttribute('role', 'listitem');
366
+ root.setAttribute('data-index', String(index));
367
+ root.dataset.chipMode = 'custom';
368
+ root.dataset.chipValue = value;
369
+ return root;
370
+ }
371
+
372
+ /** Replace `{{value}}` / `{{index}}` in every text node + attribute of
373
+ * `root` (a cloned template fragment), in place. Single-pass regex —
374
+ * chained `.replaceAll()` calls would let a token whose literal text is
375
+ * `{{index}}` get re-matched by the second pass after the first pass
376
+ * substitutes it in. */
377
+ #substitutePlaceholders(root, value, index) {
378
+ const idxStr = String(index);
379
+ const substitute = (str) =>
380
+ str.replace(/\{\{value\}\}|\{\{index\}\}/g, (m) => (m === '{{value}}' ? value : idxStr));
381
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT);
382
+ let node;
383
+ // eslint-disable-next-line no-cond-assign
384
+ while ((node = walker.nextNode())) {
385
+ if (node.nodeType === Node.TEXT_NODE) {
386
+ if (node.data.includes('{{')) {
387
+ node.data = substitute(node.data);
388
+ }
389
+ } else {
390
+ for (const attr of node.attributes) {
391
+ if (attr.value.includes('{{')) {
392
+ attr.value = substitute(attr.value);
393
+ }
394
+ }
395
+ }
396
+ }
397
+ }
398
+
332
399
  #renderSuggestions() {
333
400
  if (!this.#suggestEl) return;
334
401
  const typed = this.#inputEl ? (this.#inputEl.textContent || '').trim().toLowerCase() : '';
@@ -516,7 +583,12 @@ export class UITagsInput extends UIFormElement {
516
583
  }
517
584
 
518
585
  #handleChipRemove(e) {
519
- const chip = e.target.closest('tag-ui[data-index]');
586
+ // `[data-index]` (not `tag-ui[data-index]`) — a chip-slot template
587
+ // (TKT-0023) may wrap its own `<tag-ui removable>` inside an arbitrary
588
+ // root element; `data-index` is stamped on whichever node is the
589
+ // reconcile unit for that position (see `#buildCustomChip`), so
590
+ // `closest()` finds the right ancestor regardless of its tag name.
591
+ const chip = e.target.closest('[data-index]');
520
592
  if (!chip) return;
521
593
  // tag-ui's `remove` CustomEvent bubbles SYNCHRONOUSLY before tag-ui's
522
594
  // own `this.remove()` runs (see tag/tag.class.js:59-63 — dispatch then
@@ -525,8 +597,9 @@ export class UITagsInput extends UIFormElement {
525
597
  // positional reconcile in `#renderChips()` mutates the WRONG chip's
526
598
  // text — visually erasing one or more adjacent chips. Pre-emptively
527
599
  // detach the source chip ourselves so the DOM matches `#value` before
528
- // render. tag-ui's later `this.remove()` is a harmless no-op on an
529
- // already-detached node.
600
+ // render. tag-ui's later `this.remove()` is a harmless no-op either
601
+ // on an already-detached node (default chip == the tag-ui itself), or
602
+ // on a nested tag-ui whose ancestor `chip` root was just detached.
530
603
  e.stopPropagation();
531
604
  const idx = Number(chip.dataset.index);
532
605
  if (!Number.isInteger(idx)) return;
@@ -320,6 +320,115 @@ describe('tags-input-ui — disabled / readonly / form', () => {
320
320
  });
321
321
  });
322
322
 
323
+ describe('tags-input-ui — chip slot template (TKT-0023)', () => {
324
+ beforeEach(() => { document.body.innerHTML = ''; });
325
+
326
+ it('with no template, renders the default <tag-ui removable text>', async () => {
327
+ const el = mount('<tags-input-ui value=\'["a"]\'></tags-input-ui>');
328
+ await tick();
329
+ const chip = el.querySelector(':scope > [data-chip-list] > *');
330
+ expect(chip.tagName.toLowerCase()).toBe('tag-ui');
331
+ expect(chip.getAttribute('text')).toBe('a');
332
+ expect(chip.hasAttribute('removable')).toBe(true);
333
+ });
334
+
335
+ it('an author <template slot="chip"> overrides the default render, substituting {{value}}/{{index}}', async () => {
336
+ const el = mount(`
337
+ <tags-input-ui value='["alpha","beta"]'>
338
+ <template slot="chip"><tag-ui removable text="{{value}}" title="Item #{{index}}"></tag-ui></template>
339
+ </tags-input-ui>
340
+ `);
341
+ await tick();
342
+ const chips = el.querySelectorAll(':scope > [data-chip-list] > *');
343
+ expect(chips).toHaveLength(2);
344
+ expect(chips[0].getAttribute('text')).toBe('alpha');
345
+ expect(chips[0].getAttribute('title')).toBe('Item #0');
346
+ expect(chips[1].getAttribute('text')).toBe('beta');
347
+ expect(chips[1].getAttribute('title')).toBe('Item #1');
348
+ });
349
+
350
+ it('the chip template survives the first-connect shell stamp (not wiped by innerHTML replacement)', async () => {
351
+ const el = mount(`
352
+ <tags-input-ui value='["x"]'>
353
+ <template slot="chip"><span class="custom">{{value}}</span></template>
354
+ </tags-input-ui>
355
+ `);
356
+ await tick();
357
+ expect(el.querySelector(':scope > template[slot="chip"]')).toBeTruthy();
358
+ const chip = el.querySelector(':scope > [data-chip-list] > .custom');
359
+ expect(chip?.textContent).toBe('x');
360
+ });
361
+
362
+ it('substitutes {{value}}/{{index}} in attribute values, not just text content', async () => {
363
+ const el = mount(`
364
+ <tags-input-ui value='["a","b"]'>
365
+ <template slot="chip"><span data-slot-value="{{value}}" data-slot-index="{{index}}">{{value}}</span></template>
366
+ </tags-input-ui>
367
+ `);
368
+ await tick();
369
+ const chips = el.querySelectorAll(':scope > [data-chip-list] > span');
370
+ expect(chips[0].dataset.slotValue).toBe('a');
371
+ expect(chips[0].dataset.slotIndex).toBe('0');
372
+ expect(chips[1].dataset.slotValue).toBe('b');
373
+ expect(chips[1].dataset.slotIndex).toBe('1');
374
+ });
375
+
376
+ it('reconciles by position: adding a token appends without recreating existing custom chips', async () => {
377
+ const el = mount(`
378
+ <tags-input-ui value='["a"]'>
379
+ <template slot="chip"><span>{{value}}</span></template>
380
+ </tags-input-ui>
381
+ `);
382
+ await tick();
383
+ const first = el.querySelector(':scope > [data-chip-list] > span');
384
+ el.addToken('b');
385
+ await tick();
386
+ const chips = el.querySelectorAll(':scope > [data-chip-list] > span');
387
+ expect(chips).toHaveLength(2);
388
+ expect(chips[0]).toBe(first); // unchanged position — same node, no flicker
389
+ expect(chips[1].textContent).toBe('b');
390
+ });
391
+
392
+ it('a custom chip participates in removal via a bubbling `remove` event from a nested tag-ui', async () => {
393
+ const el = mount(`
394
+ <tags-input-ui value='["a","b"]'>
395
+ <template slot="chip"><span class="wrap"><tag-ui removable text="{{value}}"></tag-ui></span></template>
396
+ </tags-input-ui>
397
+ `);
398
+ await tick();
399
+ const nestedTag = el.querySelector(':scope > [data-chip-list] .wrap tag-ui[text="a"]');
400
+ nestedTag.dispatchEvent(new CustomEvent('remove', { bubbles: true }));
401
+ await tick();
402
+ expect(el.value).toEqual(['b']);
403
+ });
404
+
405
+ it('a malformed template (no element content) degrades to the default chip rather than rendering nothing', async () => {
406
+ const el = mount(`
407
+ <tags-input-ui value='["a"]'>
408
+ <template slot="chip"> </template>
409
+ </tags-input-ui>
410
+ `);
411
+ await tick();
412
+ const chip = el.querySelector(':scope > [data-chip-list] > *');
413
+ expect(chip.tagName.toLowerCase()).toBe('tag-ui');
414
+ expect(chip.getAttribute('text')).toBe('a');
415
+ });
416
+
417
+ it('a token literally equal to "{{index}}" is not clobbered by the index substitution pass', async () => {
418
+ const el = mount(`
419
+ <tags-input-ui value='["{{index}}","real"]'>
420
+ <template slot="chip"><span data-slot-index="{{index}}">{{value}}</span></template>
421
+ </tags-input-ui>
422
+ `);
423
+ await tick();
424
+ const chips = el.querySelectorAll(':scope > [data-chip-list] > span');
425
+ expect(chips[0].textContent).toBe('{{index}}');
426
+ expect(chips[0].dataset.slotIndex).toBe('0');
427
+ expect(chips[1].textContent).toBe('real');
428
+ expect(chips[1].dataset.slotIndex).toBe('1');
429
+ });
430
+ });
431
+
323
432
  describe('tags-input-ui — change event', () => {
324
433
  beforeEach(() => { document.body.innerHTML = ''; });
325
434
 
@@ -186,11 +186,20 @@ events:
186
186
  slots:
187
187
  chip:
188
188
  description: |-
189
- Planned: custom chip template, receiving `{value, index}` per
190
- token NOT YET IMPLEMENTED (TKT-0023; the template mechanism's
191
- shape needs a design decision before it's built). Every token
192
- currently renders as the fixed default,
193
- `<tag-ui removable text="<value>">`, with no override path.
189
+ Custom per-token chip template (TKT-0023). Provide a single
190
+ `<template slot="chip">` child; it's cloned once per committed
191
+ token, with `{{value}}` / `{{index}}` substituted into every text
192
+ node and attribute value in the clone (e.g.
193
+ `<tag-ui removable text="{{value}}" title="Item #{{index}}">`).
194
+ The template's first element child is the reconcile unit — data-*
195
+ bookkeeping attrs land on it, so removal / re-render still track
196
+ by position the same way the default chip does. When absent, or
197
+ when the template has no element content (malformed), each token
198
+ renders as the default `<tag-ui removable text="<value>">`.
199
+ Readonly/disabled toggling of the built-in `removable` affordance
200
+ only applies to the default chip — a custom template always owns
201
+ its own removability, independent of the host's readonly/disabled
202
+ state.
194
203
  states:
195
204
  - name: idle
196
205
  description: No tokens; no typed text.
@@ -210,7 +219,10 @@ states:
210
219
  description: Non-interactive; dimmed.
211
220
  attribute: disabled
212
221
  - name: readonly
213
- description: Read-only; chips render without remove affordance.
222
+ description: |-
223
+ Read-only; default chips render without remove affordance (a
224
+ custom `chip` template owns its own removability and is
225
+ unaffected).
214
226
  attribute: readonly
215
227
  traits: []
216
228
  tokens: