@adia-ai/web-components 0.6.34 → 0.6.36
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 +71 -0
- package/color/index.js +1 -1
- package/components/accordion/accordion-item.yaml +2 -2
- package/components/accordion/accordion.js +1 -1
- package/components/action-list/action-item.yaml +2 -2
- package/components/action-list/action-list.js +1 -1
- package/components/agent-artifact/{class.js → agent-artifact.class.js} +1 -1
- package/components/agent-artifact/agent-artifact.js +1 -1
- package/components/agent-feedback-bar/agent-feedback-bar.js +1 -1
- package/components/agent-questions/agent-questions.js +1 -1
- package/components/agent-reasoning/agent-reasoning.js +1 -1
- package/components/agent-suggestions/agent-suggestions.js +1 -1
- package/components/alert/alert.a2ui.json +64 -1
- package/components/alert/{class.js → alert.class.js} +189 -2
- package/components/alert/alert.css +78 -0
- package/components/alert/alert.d.ts +14 -0
- package/components/alert/alert.js +1 -1
- package/components/alert/alert.test.js +184 -0
- package/components/alert/alert.yaml +114 -1
- package/components/avatar/avatar-group.yaml +2 -2
- package/components/avatar/avatar.js +1 -1
- package/components/badge/badge.js +1 -1
- package/components/block/block.js +1 -1
- package/components/breadcrumb/breadcrumb.js +1 -1
- package/components/button/button.js +1 -1
- package/components/calendar-grid/calendar-grid.a2ui.json +10 -0
- package/components/calendar-grid/{class.js → calendar-grid.class.js} +30 -4
- package/components/calendar-grid/calendar-grid.css +20 -0
- package/components/calendar-grid/calendar-grid.d.ts +4 -0
- package/components/calendar-grid/calendar-grid.js +1 -1
- package/components/calendar-grid/calendar-grid.yaml +20 -0
- package/components/calendar-picker/calendar-picker.js +1 -1
- package/components/card/card.js +1 -1
- package/components/chart/chart.js +1 -1
- package/components/chart-legend/chart-legend.js +1 -1
- package/components/chat-thread/chat-input.a2ui.json +1 -1
- package/components/chat-thread/chat-input.js +6 -1
- package/components/chat-thread/chat-input.yaml +4 -1
- package/components/chat-thread/chat-thread.js +1 -1
- package/components/check/check.js +1 -1
- package/components/code/code.js +1 -1
- package/components/col/col.js +1 -1
- package/components/color-input/color-input.js +1 -1
- package/components/color-picker/color-picker.js +1 -1
- package/components/combobox/combobox.css +12 -0
- package/components/combobox/combobox.js +1 -1
- package/components/command/command.js +1 -1
- package/components/date-range-picker/{class.js → date-range-picker.class.js} +19 -3
- package/components/date-range-picker/date-range-picker.css +55 -6
- package/components/date-range-picker/date-range-picker.js +1 -1
- package/components/datetime-picker/{class.js → datetime-picker.class.js} +1 -1
- package/components/datetime-picker/datetime-picker.css +7 -1
- package/components/datetime-picker/datetime-picker.js +1 -1
- package/components/demo-toggle/demo-toggle.js +1 -1
- package/components/description-list/description-list.js +1 -1
- package/components/divider/divider.js +1 -1
- package/components/drawer/drawer.js +1 -1
- package/components/embed/embed.js +1 -1
- package/components/empty-state/empty-state.js +1 -1
- package/components/feed/feed.js +1 -1
- package/components/field/field.js +1 -1
- package/components/field/field.test.js +1 -1
- package/components/fields/fields.js +1 -1
- package/components/grid/grid.js +1 -1
- package/components/heatmap/heatmap.js +1 -1
- package/components/icon/icon.js +1 -1
- package/components/image/image.js +1 -1
- package/components/index.js +3 -0
- package/components/inline-message/inline-message.a2ui.json +143 -0
- package/components/inline-message/inline-message.class.js +169 -0
- package/components/inline-message/inline-message.css +75 -0
- package/components/inline-message/inline-message.d.ts +31 -0
- package/components/inline-message/inline-message.examples.md +19 -0
- package/components/inline-message/inline-message.js +17 -0
- package/components/inline-message/inline-message.test.js +203 -0
- package/components/inline-message/inline-message.yaml +205 -0
- package/components/input/input.css +16 -2
- package/components/input/input.js +1 -1
- package/components/input/input.test.js +40 -0
- package/components/input/input.yaml +5 -4
- package/components/inspector/inspector.js +1 -1
- package/components/integration-card/integration-card.js +1 -1
- package/components/kbd/kbd.js +1 -1
- package/components/link/link.js +1 -1
- package/components/list/list-item.yaml +2 -2
- package/components/list/list.js +1 -1
- package/components/list-window/list-window.js +1 -1
- package/components/loading-overlay/loading-overlay.a2ui.json +176 -0
- package/components/loading-overlay/loading-overlay.class.js +203 -0
- package/components/loading-overlay/loading-overlay.css +81 -0
- package/components/loading-overlay/loading-overlay.d.ts +24 -0
- package/components/loading-overlay/loading-overlay.examples.md +50 -0
- package/components/loading-overlay/loading-overlay.js +17 -0
- package/components/loading-overlay/loading-overlay.test.js +257 -0
- package/components/loading-overlay/loading-overlay.yaml +260 -0
- package/components/menu/menu-divider.yaml +1 -1
- package/components/menu/menu-item.yaml +1 -1
- package/components/menu/menu.a2ui.json +3 -0
- package/components/menu/menu.js +1 -1
- package/components/menu/menu.yaml +7 -0
- package/components/modal/{class.js → modal.class.js} +12 -1
- package/components/modal/modal.css +11 -1
- package/components/modal/modal.js +1 -1
- package/components/nav/nav.js +1 -1
- package/components/nav-group/nav-group.js +1 -1
- package/components/nav-item/nav-item.js +1 -1
- package/components/noodles/noodles.js +1 -1
- package/components/option-card/option-card.js +1 -1
- package/components/otp-input/otp-input.js +1 -1
- package/components/page/page.js +1 -1
- package/components/pagination/pagination.js +1 -1
- package/components/pane/pane.js +1 -1
- package/components/pipeline-status/pipeline-status.js +1 -1
- package/components/popover/popover.a2ui.json +8 -1
- package/components/popover/popover.js +1 -1
- package/components/popover/popover.yaml +14 -1
- package/components/progress/progress.js +1 -1
- package/components/progress-row/progress-row.js +1 -1
- package/components/radio/radio.js +1 -1
- package/components/range/range.js +1 -1
- package/components/rating/rating.js +1 -1
- package/components/richtext/richtext.js +1 -1
- package/components/row/row.js +1 -1
- package/components/search/{class.js → search.class.js} +2 -0
- package/components/search/search.js +1 -1
- package/components/segment/segment.js +1 -1
- package/components/segmented/segmented.js +1 -1
- package/components/select/select.a2ui.json +58 -4
- package/components/select/{class.js → select.class.js} +415 -6
- package/components/select/select.css +158 -0
- package/components/select/select.d.ts +31 -1
- package/components/select/select.js +1 -1
- package/components/select/select.test.js +202 -0
- package/components/select/select.yaml +126 -5
- package/components/skeleton/skeleton.js +1 -1
- package/components/slider/slider.js +1 -1
- package/components/spinner/spinner.a2ui.json +3 -2
- package/components/spinner/{class.js → spinner.class.js} +33 -3
- package/components/spinner/spinner.css +91 -35
- package/components/spinner/spinner.d.ts +2 -2
- package/components/spinner/spinner.js +1 -1
- package/components/spinner/spinner.test.js +49 -11
- package/components/spinner/spinner.yaml +9 -1
- package/components/stack/stack.js +1 -1
- package/components/step-progress/step-progress.js +1 -1
- package/components/stepper/stepper-item.yaml +1 -1
- package/components/stepper/stepper.js +1 -1
- package/components/stream/stream.js +1 -1
- package/components/swatch/swatch.js +1 -1
- package/components/swiper/swiper.js +1 -1
- package/components/switch/switch.js +1 -1
- package/components/table/table.css +1 -1
- package/components/table/table.js +1 -1
- package/components/table-toolbar/{class.js → table-toolbar.class.js} +2 -1
- package/components/table-toolbar/table-toolbar.js +1 -1
- package/components/tabs/tab.yaml +2 -2
- package/components/tabs/tabs.js +1 -1
- package/components/tag/tag.a2ui.json +9 -0
- package/components/tag/{class.js → tag.class.js} +8 -1
- package/components/tag/tag.css +84 -20
- package/components/tag/tag.js +1 -1
- package/components/tag/tag.test.js +75 -1
- package/components/tag/tag.yaml +14 -0
- package/components/tags-input/tags-input.a2ui.json +337 -0
- package/components/tags-input/tags-input.class.js +783 -0
- package/components/tags-input/tags-input.css +210 -0
- package/components/tags-input/tags-input.d.ts +120 -0
- package/components/tags-input/tags-input.examples.md +92 -0
- package/components/tags-input/tags-input.js +17 -0
- package/components/tags-input/tags-input.test.js +368 -0
- package/components/tags-input/tags-input.yaml +367 -0
- package/components/text/text.js +1 -1
- package/components/textarea/textarea.a2ui.json +1 -1
- package/components/textarea/textarea.css +10 -1
- package/components/textarea/textarea.js +1 -1
- package/components/textarea/textarea.yaml +11 -8
- package/components/time-picker/time-picker.js +1 -1
- package/components/timeline/timeline-item.yaml +2 -2
- package/components/timeline/{class.js → timeline.class.js} +1 -1
- package/components/timeline/timeline.js +1 -1
- package/components/toast/toast.js +1 -1
- package/components/toggle-group/toggle-group.js +1 -1
- package/components/toggle-group/toggle-option.yaml +1 -1
- package/components/toggle-scheme/toggle-scheme.js +1 -1
- package/components/toolbar/toolbar-group.yaml +1 -1
- package/components/toolbar/toolbar.js +1 -1
- package/components/tooltip/tooltip.js +1 -1
- package/components/tree/tree-item.yaml +1 -1
- package/components/tree/tree.js +1 -1
- package/components/upload/upload.js +1 -1
- package/core/provider.js +19 -2
- package/dist/web-components.min.css +1 -1
- package/dist/web-components.min.js +112 -90
- package/package.json +3 -3
- package/styles/components.css +3 -0
- /package/components/accordion/{class.js → accordion.class.js} +0 -0
- /package/components/action-list/{class.js → action-list.class.js} +0 -0
- /package/components/agent-feedback-bar/{class.js → agent-feedback-bar.class.js} +0 -0
- /package/components/agent-questions/{class.js → agent-questions.class.js} +0 -0
- /package/components/agent-reasoning/{class.js → agent-reasoning.class.js} +0 -0
- /package/components/agent-suggestions/{class.js → agent-suggestions.class.js} +0 -0
- /package/components/avatar/{class.js → avatar.class.js} +0 -0
- /package/components/badge/{class.js → badge.class.js} +0 -0
- /package/components/block/{class.js → block.class.js} +0 -0
- /package/components/breadcrumb/{class.js → breadcrumb.class.js} +0 -0
- /package/components/button/{class.js → button.class.js} +0 -0
- /package/components/calendar-picker/{class.js → calendar-picker.class.js} +0 -0
- /package/components/card/{class.js → card.class.js} +0 -0
- /package/components/chart/{class.js → chart.class.js} +0 -0
- /package/components/chart-legend/{class.js → chart-legend.class.js} +0 -0
- /package/components/chat-thread/{class.js → chat-thread.class.js} +0 -0
- /package/components/check/{class.js → check.class.js} +0 -0
- /package/components/code/{class.js → code.class.js} +0 -0
- /package/components/col/{class.js → col.class.js} +0 -0
- /package/components/color-input/{class.js → color-input.class.js} +0 -0
- /package/components/color-picker/{class.js → color-picker.class.js} +0 -0
- /package/components/combobox/{class.js → combobox.class.js} +0 -0
- /package/components/command/{class.js → command.class.js} +0 -0
- /package/components/demo-toggle/{class.js → demo-toggle.class.js} +0 -0
- /package/components/description-list/{class.js → description-list.class.js} +0 -0
- /package/components/divider/{class.js → divider.class.js} +0 -0
- /package/components/drawer/{class.js → drawer.class.js} +0 -0
- /package/components/embed/{class.js → embed.class.js} +0 -0
- /package/components/empty-state/{class.js → empty-state.class.js} +0 -0
- /package/components/feed/{class.js → feed.class.js} +0 -0
- /package/components/field/{class.js → field.class.js} +0 -0
- /package/components/fields/{class.js → fields.class.js} +0 -0
- /package/components/grid/{class.js → grid.class.js} +0 -0
- /package/components/heatmap/{class.js → heatmap.class.js} +0 -0
- /package/components/icon/{class.js → icon.class.js} +0 -0
- /package/components/image/{class.js → image.class.js} +0 -0
- /package/components/input/{class.js → input.class.js} +0 -0
- /package/components/inspector/{class.js → inspector.class.js} +0 -0
- /package/components/integration-card/{class.js → integration-card.class.js} +0 -0
- /package/components/kbd/{class.js → kbd.class.js} +0 -0
- /package/components/link/{class.js → link.class.js} +0 -0
- /package/components/list/{class.js → list.class.js} +0 -0
- /package/components/list-window/{class.js → list-window.class.js} +0 -0
- /package/components/menu/{class.js → menu.class.js} +0 -0
- /package/components/nav/{class.js → nav.class.js} +0 -0
- /package/components/nav-group/{class.js → nav-group.class.js} +0 -0
- /package/components/nav-item/{class.js → nav-item.class.js} +0 -0
- /package/components/noodles/{class.js → noodles.class.js} +0 -0
- /package/components/option-card/{class.js → option-card.class.js} +0 -0
- /package/components/otp-input/{class.js → otp-input.class.js} +0 -0
- /package/components/page/{class.js → page.class.js} +0 -0
- /package/components/pagination/{class.js → pagination.class.js} +0 -0
- /package/components/pane/{class.js → pane.class.js} +0 -0
- /package/components/pipeline-status/{class.js → pipeline-status.class.js} +0 -0
- /package/components/popover/{class.js → popover.class.js} +0 -0
- /package/components/progress/{class.js → progress.class.js} +0 -0
- /package/components/progress-row/{class.js → progress-row.class.js} +0 -0
- /package/components/radio/{class.js → radio.class.js} +0 -0
- /package/components/range/{class.js → range.class.js} +0 -0
- /package/components/rating/{class.js → rating.class.js} +0 -0
- /package/components/richtext/{class.js → richtext.class.js} +0 -0
- /package/components/row/{class.js → row.class.js} +0 -0
- /package/components/segment/{class.js → segment.class.js} +0 -0
- /package/components/segmented/{class.js → segmented.class.js} +0 -0
- /package/components/skeleton/{class.js → skeleton.class.js} +0 -0
- /package/components/slider/{class.js → slider.class.js} +0 -0
- /package/components/stack/{class.js → stack.class.js} +0 -0
- /package/components/step-progress/{class.js → step-progress.class.js} +0 -0
- /package/components/stepper/{class.js → stepper.class.js} +0 -0
- /package/components/stream/{class.js → stream.class.js} +0 -0
- /package/components/swatch/{class.js → swatch.class.js} +0 -0
- /package/components/swiper/{class.js → swiper.class.js} +0 -0
- /package/components/switch/{class.js → switch.class.js} +0 -0
- /package/components/table/{class.js → table.class.js} +0 -0
- /package/components/tabs/{class.js → tabs.class.js} +0 -0
- /package/components/text/{class.js → text.class.js} +0 -0
- /package/components/textarea/{class.js → textarea.class.js} +0 -0
- /package/components/time-picker/{class.js → time-picker.class.js} +0 -0
- /package/components/toast/{class.js → toast.class.js} +0 -0
- /package/components/toggle-group/{class.js → toggle-group.class.js} +0 -0
- /package/components/toggle-scheme/{class.js → toggle-scheme.class.js} +0 -0
- /package/components/toolbar/{class.js → toolbar.class.js} +0 -0
- /package/components/tooltip/{class.js → tooltip.class.js} +0 -0
- /package/components/tree/{class.js → tree.class.js} +0 -0
- /package/components/upload/{class.js → upload.class.js} +0 -0
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Non-side-effect class export for `<tags-input-ui>`.
|
|
3
|
+
*
|
|
4
|
+
* Importing this file gives you the class(es) without auto-registering the tag.
|
|
5
|
+
* Useful for test isolation, subclassing with tag-name override, or selective
|
|
6
|
+
* composition.
|
|
7
|
+
*
|
|
8
|
+
* The auto-register path stays at `@adia-ai/web-components/components/tags-input`
|
|
9
|
+
* (which imports this file + calls `defineIfFree()`).
|
|
10
|
+
*
|
|
11
|
+
* @see ../../USAGE.md#registration--auto-vs-explicit
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `<tags-input-ui>` — SPEC-042.
|
|
16
|
+
*
|
|
17
|
+
* Free-form token / chip input. Type a value, press Enter (or the
|
|
18
|
+
* configured delimiter), and the typed value commits as a chip;
|
|
19
|
+
* Backspace from the empty inline input removes the last chip.
|
|
20
|
+
* Distinct from `<select-ui multiple>` (SPEC-040), which gates against
|
|
21
|
+
* a fixed options list — this primitive is for OPEN sets (labels,
|
|
22
|
+
* email recipients, keyword inputs).
|
|
23
|
+
*
|
|
24
|
+
* Per ADR-0025 the inline editor is a `contenteditable` surface; there
|
|
25
|
+
* is no `<input>` element wrapped inside. Form participation goes
|
|
26
|
+
* through `UIFormElement` + `ElementInternals`; the form value is a
|
|
27
|
+
* JSON-serialized string array under `name`.
|
|
28
|
+
*
|
|
29
|
+
* <tags-input-ui name="labels" placeholder="Add a label…"
|
|
30
|
+
* transform="lowercase" max="10"></tags-input-ui>
|
|
31
|
+
*
|
|
32
|
+
* <tags-input-ui id="to" name="to" placeholder="Email…"></tags-input-ui>
|
|
33
|
+
* <script>
|
|
34
|
+
* const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
35
|
+
* document.getElementById('to').validateFn = (value) => EMAIL_RE.test(value);
|
|
36
|
+
* </script>
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { UIFormElement } from '../../core/form.js';
|
|
40
|
+
import { untracked } from '../../core/signals.js';
|
|
41
|
+
|
|
42
|
+
let tagsInstanceSeq = 0;
|
|
43
|
+
|
|
44
|
+
function escapeRegExp(s) {
|
|
45
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class UITagsInput extends UIFormElement {
|
|
49
|
+
// §198-pattern: above-field labels handled by <field-ui>; the host
|
|
50
|
+
// does not render an inert label itself. Opt out of the base-class
|
|
51
|
+
// deprecation warning that targets per-control labels.
|
|
52
|
+
static labelDeprecated = false;
|
|
53
|
+
|
|
54
|
+
static requiredIcons = ['circle-notch'];
|
|
55
|
+
|
|
56
|
+
// NOTE: we intentionally exclude `value` from the reactive-property
|
|
57
|
+
// table even though UIFormElement declares it. `installProps()` runs
|
|
58
|
+
// `Object.defineProperty(this, 'value', …)` on the instance, which
|
|
59
|
+
// would clobber the array-shaped `get/set value` accessors below
|
|
60
|
+
// (instance descriptors win over prototype accessors). We re-implement
|
|
61
|
+
// the attribute-routing for `value` in `attributeChangedCallback`.
|
|
62
|
+
static get properties() {
|
|
63
|
+
const { value: _drop, ...rest } = UIFormElement.properties;
|
|
64
|
+
void _drop;
|
|
65
|
+
return {
|
|
66
|
+
...rest,
|
|
67
|
+
placeholder: { type: String, default: 'Add tag…', reflect: true },
|
|
68
|
+
delimiter: { type: String, default: ',', reflect: true },
|
|
69
|
+
pasteSplit: { type: String, default: ',\n', reflect: true, attribute: 'paste-split' },
|
|
70
|
+
max: { type: Number, default: 0, reflect: true },
|
|
71
|
+
min: { type: Number, default: 0, reflect: true },
|
|
72
|
+
minLength: { type: Number, default: 1, reflect: true, attribute: 'min-length' },
|
|
73
|
+
maxLength: { type: Number, default: 0, reflect: true, attribute: 'max-length' },
|
|
74
|
+
unique: { type: Boolean, default: true, reflect: true },
|
|
75
|
+
transform: { type: String, default: '', reflect: true },
|
|
76
|
+
size: { type: String, default: 'md', reflect: true },
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
static get observedAttributes() {
|
|
81
|
+
// Re-add `value` so attribute-side writes route through our setter.
|
|
82
|
+
const base = super.observedAttributes;
|
|
83
|
+
return base.includes('value') ? base : [...base, 'value'];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
attributeChangedCallback(name, oldV, newV) {
|
|
87
|
+
if (name === 'value') {
|
|
88
|
+
// String → array routing for declarative `value="…"` writes.
|
|
89
|
+
if (newV != null && oldV !== newV) {
|
|
90
|
+
const parsed = this.#parseValueAttr(newV);
|
|
91
|
+
if (parsed) this.value = parsed;
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
super.attributeChangedCallback(name, oldV, newV);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
static template = () => null;
|
|
99
|
+
|
|
100
|
+
// ── Instance state ──
|
|
101
|
+
#value = []; // string[] — the committed tokens
|
|
102
|
+
#suggestions = []; // string[] — autocomplete hints
|
|
103
|
+
#validateFn = null; // (value, index) => boolean|Promise<boolean>
|
|
104
|
+
#activeSuggestion = -1;
|
|
105
|
+
#suppressInput = false; // guard contenteditable resyncs during programmatic ops
|
|
106
|
+
#pendingValidationId = 0; // async-validator generation counter
|
|
107
|
+
|
|
108
|
+
// ── Refs ──
|
|
109
|
+
#chipList = null;
|
|
110
|
+
#inputEl = null;
|
|
111
|
+
#suggestEl = null;
|
|
112
|
+
#spinnerEl = null;
|
|
113
|
+
#instanceId = `tags-input-${++tagsInstanceSeq}`;
|
|
114
|
+
|
|
115
|
+
// ── Stable handler refs (so removeEventListener finds them) ──
|
|
116
|
+
#onInputEvent = () => this.#handleInput();
|
|
117
|
+
#onKeydown = (e) => this.#handleKeydown(e);
|
|
118
|
+
#onPaste = (e) => this.#handlePaste(e);
|
|
119
|
+
#onChipRemove = (e) => this.#handleChipRemove(e);
|
|
120
|
+
#onHostMousedown = (e) => this.#handleHostMousedown(e);
|
|
121
|
+
#onSuggestionClick = (e) => this.#handleSuggestionClick(e);
|
|
122
|
+
|
|
123
|
+
// ── Lifecycle ──
|
|
124
|
+
|
|
125
|
+
connected() {
|
|
126
|
+
super.connected();
|
|
127
|
+
// Read `value` attribute as JSON array (when set declaratively).
|
|
128
|
+
if (!this.#value.length && this.hasAttribute('value')) {
|
|
129
|
+
const parsed = this.#parseValueAttr(this.getAttribute('value'));
|
|
130
|
+
if (parsed) this.#value = parsed;
|
|
131
|
+
}
|
|
132
|
+
this.#stampShell();
|
|
133
|
+
this.#renderChips();
|
|
134
|
+
this.#syncStateAttrs();
|
|
135
|
+
this.#syncFormValue();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
disconnected() {
|
|
139
|
+
super.disconnected();
|
|
140
|
+
this.#teardownListeners();
|
|
141
|
+
this.#chipList = null;
|
|
142
|
+
this.#inputEl = null;
|
|
143
|
+
this.#suggestEl = null;
|
|
144
|
+
this.#spinnerEl = null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
render() {
|
|
148
|
+
if (!this.#inputEl) return;
|
|
149
|
+
// Disabled / readonly toggle the editable surface.
|
|
150
|
+
if (this.disabled || this.readonly) {
|
|
151
|
+
this.#inputEl.contentEditable = 'false';
|
|
152
|
+
} else {
|
|
153
|
+
this.#inputEl.contentEditable = 'plaintext-only';
|
|
154
|
+
}
|
|
155
|
+
this.#inputEl.setAttribute('data-placeholder', this.placeholder || '');
|
|
156
|
+
// ARIA: combobox when suggestions wired, group otherwise.
|
|
157
|
+
if (this.#suggestions.length) {
|
|
158
|
+
this.setAttribute('role', 'combobox');
|
|
159
|
+
this.setAttribute('aria-haspopup', 'listbox');
|
|
160
|
+
this.setAttribute('aria-expanded', String(!!this.hasAttribute('suggesting')));
|
|
161
|
+
} else {
|
|
162
|
+
this.setAttribute('role', 'group');
|
|
163
|
+
this.removeAttribute('aria-haspopup');
|
|
164
|
+
this.removeAttribute('aria-expanded');
|
|
165
|
+
}
|
|
166
|
+
if (!this.hasAttribute('aria-label')) {
|
|
167
|
+
this.setAttribute('aria-label', this.placeholder || 'Tags');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── Public properties ──
|
|
172
|
+
|
|
173
|
+
/** Current token list. Setting replaces the entire list. */
|
|
174
|
+
set value(list) {
|
|
175
|
+
untracked(() => {
|
|
176
|
+
const next = Array.isArray(list)
|
|
177
|
+
? list.map((s) => String(s ?? ''))
|
|
178
|
+
: (typeof list === 'string' ? this.#parseValueAttr(list) || [] : []);
|
|
179
|
+
const before = this.#value.slice();
|
|
180
|
+
this.#value = next;
|
|
181
|
+
this.#renderChips();
|
|
182
|
+
this.#syncStateAttrs();
|
|
183
|
+
this.#syncFormValue();
|
|
184
|
+
this.#announceChange(before, next, 'programmatic');
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
get value() { return this.#value.slice(); }
|
|
189
|
+
|
|
190
|
+
set suggestions(list) {
|
|
191
|
+
untracked(() => {
|
|
192
|
+
this.#suggestions = Array.isArray(list) ? list.map((s) => String(s ?? '')) : [];
|
|
193
|
+
this.#renderSuggestions();
|
|
194
|
+
this.render();
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
get suggestions() { return this.#suggestions.slice(); }
|
|
199
|
+
|
|
200
|
+
set validateFn(fn) {
|
|
201
|
+
this.#validateFn = typeof fn === 'function' ? fn : null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
get validateFn() { return this.#validateFn; }
|
|
205
|
+
|
|
206
|
+
// ── Public methods ──
|
|
207
|
+
|
|
208
|
+
/** Commit a token programmatically. Returns true on success. */
|
|
209
|
+
addToken(text) {
|
|
210
|
+
return this.#commitToken(String(text ?? ''), 'programmatic');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Remove a token by index. */
|
|
214
|
+
removeToken(index) {
|
|
215
|
+
const i = Number(index);
|
|
216
|
+
if (!Number.isInteger(i) || i < 0 || i >= this.#value.length) return;
|
|
217
|
+
this.#removeByIndex(i, 'programmatic');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Clear all tokens. */
|
|
221
|
+
clear() {
|
|
222
|
+
if (this.#value.length === 0) return;
|
|
223
|
+
const before = this.#value.slice();
|
|
224
|
+
this.#value = [];
|
|
225
|
+
this.#renderChips();
|
|
226
|
+
this.#syncStateAttrs();
|
|
227
|
+
this.#syncFormValue();
|
|
228
|
+
this.#announceChange(before, [], 'programmatic');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
focus() { this.#inputEl?.focus(); }
|
|
232
|
+
|
|
233
|
+
// ── Shell ──
|
|
234
|
+
|
|
235
|
+
#stampShell() {
|
|
236
|
+
if (this.querySelector(':scope > [data-chip-list]')) {
|
|
237
|
+
this.#queryRefs();
|
|
238
|
+
this.#bindListeners();
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const suggestId = `${this.#instanceId}-suggestions`;
|
|
242
|
+
const inputId = `${this.#instanceId}-input`;
|
|
243
|
+
this.innerHTML = `
|
|
244
|
+
<div data-chip-list role="list" aria-label="Tags"></div>
|
|
245
|
+
<span data-inline-input
|
|
246
|
+
id="${inputId}"
|
|
247
|
+
contenteditable="plaintext-only"
|
|
248
|
+
role="searchbox"
|
|
249
|
+
tabindex="0"
|
|
250
|
+
spellcheck="false"
|
|
251
|
+
aria-autocomplete="list"
|
|
252
|
+
aria-controls="${suggestId}"
|
|
253
|
+
data-placeholder="${this.placeholder || ''}"
|
|
254
|
+
data-empty></span>
|
|
255
|
+
<span data-spinner hidden aria-hidden="true">
|
|
256
|
+
<icon-ui name="circle-notch"></icon-ui>
|
|
257
|
+
</span>
|
|
258
|
+
<div data-suggestions id="${suggestId}" role="listbox" hidden></div>
|
|
259
|
+
`;
|
|
260
|
+
this.#queryRefs();
|
|
261
|
+
this.#bindListeners();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
#queryRefs() {
|
|
265
|
+
this.#chipList = this.querySelector(':scope > [data-chip-list]');
|
|
266
|
+
this.#inputEl = this.querySelector(':scope > [data-inline-input]');
|
|
267
|
+
this.#spinnerEl = this.querySelector(':scope > [data-spinner]');
|
|
268
|
+
this.#suggestEl = this.querySelector(':scope > [data-suggestions]');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
#bindListeners() {
|
|
272
|
+
if (this.#inputEl) {
|
|
273
|
+
this.#inputEl.addEventListener('input', this.#onInputEvent);
|
|
274
|
+
this.#inputEl.addEventListener('keydown', this.#onKeydown);
|
|
275
|
+
this.#inputEl.addEventListener('paste', this.#onPaste);
|
|
276
|
+
}
|
|
277
|
+
if (this.#chipList) {
|
|
278
|
+
this.#chipList.addEventListener('remove', this.#onChipRemove);
|
|
279
|
+
}
|
|
280
|
+
if (this.#suggestEl) {
|
|
281
|
+
this.#suggestEl.addEventListener('click', this.#onSuggestionClick);
|
|
282
|
+
}
|
|
283
|
+
this.addEventListener('mousedown', this.#onHostMousedown);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
#teardownListeners() {
|
|
287
|
+
if (this.#inputEl) {
|
|
288
|
+
this.#inputEl.removeEventListener('input', this.#onInputEvent);
|
|
289
|
+
this.#inputEl.removeEventListener('keydown', this.#onKeydown);
|
|
290
|
+
this.#inputEl.removeEventListener('paste', this.#onPaste);
|
|
291
|
+
}
|
|
292
|
+
if (this.#chipList) {
|
|
293
|
+
this.#chipList.removeEventListener('remove', this.#onChipRemove);
|
|
294
|
+
}
|
|
295
|
+
if (this.#suggestEl) {
|
|
296
|
+
this.#suggestEl.removeEventListener('click', this.#onSuggestionClick);
|
|
297
|
+
}
|
|
298
|
+
this.removeEventListener('mousedown', this.#onHostMousedown);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── Render chips ──
|
|
302
|
+
|
|
303
|
+
#renderChips() {
|
|
304
|
+
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'));
|
|
310
|
+
for (let i = 0; i < this.#value.length; i++) {
|
|
311
|
+
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', '');
|
|
323
|
+
}
|
|
324
|
+
chip.setAttribute('data-index', String(i));
|
|
325
|
+
}
|
|
326
|
+
// Drop overflow chips.
|
|
327
|
+
for (let i = this.#value.length; i < existing.length; i++) {
|
|
328
|
+
existing[i].remove();
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
#renderSuggestions() {
|
|
333
|
+
if (!this.#suggestEl) return;
|
|
334
|
+
const typed = this.#inputEl ? (this.#inputEl.textContent || '').trim().toLowerCase() : '';
|
|
335
|
+
const list = this.#suggestions
|
|
336
|
+
.filter((s) => !this.#value.includes(s))
|
|
337
|
+
.filter((s) => !typed || s.toLowerCase().includes(typed));
|
|
338
|
+
this.#suggestEl.replaceChildren();
|
|
339
|
+
list.forEach((s, idx) => {
|
|
340
|
+
const item = document.createElement('div');
|
|
341
|
+
item.setAttribute('role', 'option');
|
|
342
|
+
item.setAttribute('id', `${this.#instanceId}-opt-${idx}`);
|
|
343
|
+
item.dataset.value = s;
|
|
344
|
+
item.textContent = s;
|
|
345
|
+
this.#suggestEl.appendChild(item);
|
|
346
|
+
});
|
|
347
|
+
const shouldShow = this.#suggestions.length > 0 && list.length > 0 && this.hasAttribute('editing');
|
|
348
|
+
this.#suggestEl.hidden = !shouldShow;
|
|
349
|
+
this.toggleAttribute('suggesting', shouldShow);
|
|
350
|
+
if (shouldShow) {
|
|
351
|
+
this.setAttribute('aria-expanded', 'true');
|
|
352
|
+
} else {
|
|
353
|
+
this.removeAttribute('aria-expanded');
|
|
354
|
+
}
|
|
355
|
+
// Reset active option when the rendered list changes.
|
|
356
|
+
this.#activeSuggestion = -1;
|
|
357
|
+
this.#inputEl?.removeAttribute('aria-activedescendant');
|
|
358
|
+
for (const node of this.#suggestEl.children) node.removeAttribute('data-active');
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ── State attrs ──
|
|
362
|
+
|
|
363
|
+
#syncStateAttrs() {
|
|
364
|
+
this.toggleAttribute('populated', this.#value.length > 0);
|
|
365
|
+
if (!this.#inputEl) return;
|
|
366
|
+
const empty = !(this.#inputEl.textContent || '').length;
|
|
367
|
+
this.#inputEl.toggleAttribute('data-empty', empty);
|
|
368
|
+
this.toggleAttribute('editing', !empty);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
#syncFormValue() {
|
|
372
|
+
// Empty array → empty FormData entry (per spec §11). Non-empty → JSON.
|
|
373
|
+
const payload = this.#value.length ? JSON.stringify(this.#value) : '';
|
|
374
|
+
super.syncValue(payload);
|
|
375
|
+
// Required-validation: empty array fails when [required].
|
|
376
|
+
if (this.required && this.#value.length === 0) {
|
|
377
|
+
this.internals.setValidity(
|
|
378
|
+
{ valueMissing: true },
|
|
379
|
+
this.getAttribute('data-msg-required') || 'Please add at least one tag.',
|
|
380
|
+
this,
|
|
381
|
+
);
|
|
382
|
+
} else if (this.min > 0 && this.#value.length < this.min) {
|
|
383
|
+
this.internals.setValidity(
|
|
384
|
+
{ tooShort: true },
|
|
385
|
+
this.getAttribute('data-msg-min')
|
|
386
|
+
|| `Please add at least ${this.min} tag${this.min === 1 ? '' : 's'}.`,
|
|
387
|
+
this,
|
|
388
|
+
);
|
|
389
|
+
} else {
|
|
390
|
+
this.internals.setValidity({});
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ── Input handling ──
|
|
395
|
+
|
|
396
|
+
#handleInput() {
|
|
397
|
+
if (this.#suppressInput) return;
|
|
398
|
+
const text = (this.#inputEl.textContent || '');
|
|
399
|
+
// Delimiter-driven commit (when the user types the delimiter inline,
|
|
400
|
+
// commit the substring before it and leave whatever's after as the new
|
|
401
|
+
// typed buffer). Skipped when delimiter is the `enter` sentinel.
|
|
402
|
+
if (this.delimiter && this.delimiter !== 'enter' && text.includes(this.delimiter)) {
|
|
403
|
+
const parts = text.split(this.delimiter);
|
|
404
|
+
const trailing = parts.pop();
|
|
405
|
+
let anyCommitted = false;
|
|
406
|
+
for (const fragment of parts) {
|
|
407
|
+
if (fragment.length) {
|
|
408
|
+
const committed = this.#commitToken(fragment, 'delimiter');
|
|
409
|
+
if (committed) anyCommitted = true;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
// Anything committed (or not), reset the contenteditable to the trailing
|
|
413
|
+
// remainder so subsequent keystrokes append after the delimiter.
|
|
414
|
+
this.#suppressInput = true;
|
|
415
|
+
this.#inputEl.textContent = trailing || '';
|
|
416
|
+
this.#suppressInput = false;
|
|
417
|
+
this.#placeCaretAtEnd();
|
|
418
|
+
// Fall through to fire `input` with the new query state.
|
|
419
|
+
this.#syncStateAttrs();
|
|
420
|
+
this.#renderSuggestions();
|
|
421
|
+
this.dispatchEvent(new CustomEvent('input', {
|
|
422
|
+
bubbles: true,
|
|
423
|
+
detail: { query: trailing || '' },
|
|
424
|
+
}));
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
this.#syncStateAttrs();
|
|
428
|
+
this.#renderSuggestions();
|
|
429
|
+
this.dispatchEvent(new CustomEvent('input', {
|
|
430
|
+
bubbles: true,
|
|
431
|
+
detail: { query: text },
|
|
432
|
+
}));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
#handleKeydown(e) {
|
|
436
|
+
const k = e.key;
|
|
437
|
+
if (this.disabled || this.readonly) return;
|
|
438
|
+
const text = this.#inputEl ? (this.#inputEl.textContent || '') : '';
|
|
439
|
+
if (k === 'Enter') {
|
|
440
|
+
e.preventDefault();
|
|
441
|
+
// If a suggestion is highlighted, commit it; otherwise commit typed.
|
|
442
|
+
if (this.#activeSuggestion >= 0) {
|
|
443
|
+
const opt = this.#suggestEl?.children[this.#activeSuggestion];
|
|
444
|
+
if (opt) {
|
|
445
|
+
this.#commitToken(opt.dataset.value || '', 'suggestion');
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (text) this.#commitToken(text, 'keyboard');
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (k === 'Backspace' && text.length === 0) {
|
|
453
|
+
if (this.#value.length > 0) {
|
|
454
|
+
e.preventDefault();
|
|
455
|
+
this.#removeByIndex(this.#value.length - 1, 'backspace');
|
|
456
|
+
}
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (k === 'ArrowDown') {
|
|
460
|
+
if (!this.hasAttribute('suggesting')) return;
|
|
461
|
+
e.preventDefault();
|
|
462
|
+
this.#moveActiveSuggestion(1);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (k === 'ArrowUp') {
|
|
466
|
+
if (!this.hasAttribute('suggesting')) return;
|
|
467
|
+
e.preventDefault();
|
|
468
|
+
this.#moveActiveSuggestion(-1);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
if (k === 'Escape') {
|
|
472
|
+
if (this.hasAttribute('suggesting')) {
|
|
473
|
+
e.preventDefault();
|
|
474
|
+
this.#closeSuggestions();
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
#handlePaste(e) {
|
|
480
|
+
if (this.disabled || this.readonly) return;
|
|
481
|
+
const raw = e.clipboardData?.getData('text/plain') || '';
|
|
482
|
+
if (!raw) return;
|
|
483
|
+
if (!this.pasteSplit) {
|
|
484
|
+
// No splitting — let the default contenteditable paste happen.
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
e.preventDefault();
|
|
488
|
+
const splitRE = new RegExp(`[${escapeRegExp(this.pasteSplit)}]+`);
|
|
489
|
+
const fragments = raw.split(splitRE).map((s) => s.trim()).filter(Boolean);
|
|
490
|
+
if (fragments.length === 0) return;
|
|
491
|
+
if (fragments.length === 1) {
|
|
492
|
+
// Single fragment: insert as plain text rather than auto-committing —
|
|
493
|
+
// pasting "foo" into the input should let the user keep typing.
|
|
494
|
+
document.execCommand('insertText', false, fragments[0]);
|
|
495
|
+
this.#syncStateAttrs();
|
|
496
|
+
this.#renderSuggestions();
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
// Multi-fragment paste — commit each, leave the input empty afterwards.
|
|
500
|
+
let lastSurplus = '';
|
|
501
|
+
for (let i = 0; i < fragments.length; i++) {
|
|
502
|
+
const f = fragments[i];
|
|
503
|
+
const ok = this.#commitToken(f, 'paste');
|
|
504
|
+
// If the LAST fragment was rejected (e.g. count cap), surface it as
|
|
505
|
+
// the new typed buffer so the user can correct it.
|
|
506
|
+
if (!ok && i === fragments.length - 1) lastSurplus = f;
|
|
507
|
+
}
|
|
508
|
+
if (lastSurplus) {
|
|
509
|
+
this.#suppressInput = true;
|
|
510
|
+
this.#inputEl.textContent = lastSurplus;
|
|
511
|
+
this.#suppressInput = false;
|
|
512
|
+
this.#placeCaretAtEnd();
|
|
513
|
+
this.#syncStateAttrs();
|
|
514
|
+
this.#renderSuggestions();
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
#handleChipRemove(e) {
|
|
519
|
+
const chip = e.target.closest('tag-ui[data-index]');
|
|
520
|
+
if (!chip) return;
|
|
521
|
+
// tag-ui's `remove` CustomEvent bubbles SYNCHRONOUSLY before tag-ui's
|
|
522
|
+
// own `this.remove()` runs (see tag/tag.class.js:59-63 — dispatch then
|
|
523
|
+
// remove). If we react inside the bubble (call `#renderChips()` now),
|
|
524
|
+
// the DOM still contains the about-to-be-removed chip and the
|
|
525
|
+
// positional reconcile in `#renderChips()` mutates the WRONG chip's
|
|
526
|
+
// text — visually erasing one or more adjacent chips. Pre-emptively
|
|
527
|
+
// 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.
|
|
530
|
+
e.stopPropagation();
|
|
531
|
+
const idx = Number(chip.dataset.index);
|
|
532
|
+
if (!Number.isInteger(idx)) return;
|
|
533
|
+
chip.remove();
|
|
534
|
+
this.#removeByIndex(idx, 'chip-click');
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
#handleHostMousedown(e) {
|
|
538
|
+
// Click anywhere on the host that isn't a chip's remove button focuses
|
|
539
|
+
// the inline input. Mirrors gmail-style "tap-on-recipient-row" UX.
|
|
540
|
+
if (this.disabled) return;
|
|
541
|
+
const onRemove = e.target.closest('[slot="dismiss"], tag-ui');
|
|
542
|
+
const onSuggestion = e.target.closest('[role="option"]');
|
|
543
|
+
if (onRemove || onSuggestion) return;
|
|
544
|
+
if (e.target === this.#inputEl) return;
|
|
545
|
+
e.preventDefault(); // don't shift focus from the input mid-type
|
|
546
|
+
this.#inputEl?.focus();
|
|
547
|
+
this.#placeCaretAtEnd();
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
#handleSuggestionClick(e) {
|
|
551
|
+
const opt = e.target.closest('[role="option"]');
|
|
552
|
+
if (!opt) return;
|
|
553
|
+
this.#commitToken(opt.dataset.value || '', 'suggestion');
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// ── Commit / remove ──
|
|
557
|
+
|
|
558
|
+
#applyTransform(raw) {
|
|
559
|
+
const t = this.transform;
|
|
560
|
+
if (t === 'lowercase') return raw.toLowerCase();
|
|
561
|
+
if (t === 'trim') return raw.trim();
|
|
562
|
+
if (t === 'strip-spaces') return raw.replace(/\s+/g, '');
|
|
563
|
+
return raw;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
#commitToken(rawInput, source) {
|
|
567
|
+
if (this.disabled || this.readonly) return false;
|
|
568
|
+
// Always strip surrounding whitespace before transform — typed strings
|
|
569
|
+
// from the contenteditable surface routinely carry trailing spaces.
|
|
570
|
+
let candidate = this.#applyTransform(rawInput.trim());
|
|
571
|
+
if (!candidate) {
|
|
572
|
+
// Empty after transform — silently drop without firing invalid.
|
|
573
|
+
this.#clearInputBuffer();
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// ── built-in validators ──
|
|
578
|
+
if (candidate.length < this.minLength) {
|
|
579
|
+
this.#fireInvalid(candidate, 'too-short');
|
|
580
|
+
return false;
|
|
581
|
+
}
|
|
582
|
+
if (this.maxLength > 0 && candidate.length > this.maxLength) {
|
|
583
|
+
this.#fireInvalid(candidate, 'too-long');
|
|
584
|
+
return false;
|
|
585
|
+
}
|
|
586
|
+
if (this.unique && this.#value.includes(candidate)) {
|
|
587
|
+
// Silent dedup per spec §A2UI rules — clear buffer, no invalid event.
|
|
588
|
+
this.#clearInputBuffer();
|
|
589
|
+
return false;
|
|
590
|
+
}
|
|
591
|
+
if (this.max > 0 && this.#value.length >= this.max) {
|
|
592
|
+
this.#fireInvalid(candidate, 'max');
|
|
593
|
+
return false;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// commit event (cancelable)
|
|
597
|
+
const commitEvent = new CustomEvent('commit', {
|
|
598
|
+
bubbles: true,
|
|
599
|
+
cancelable: true,
|
|
600
|
+
detail: { value: candidate, accepted: true },
|
|
601
|
+
});
|
|
602
|
+
const accepted = this.dispatchEvent(commitEvent);
|
|
603
|
+
if (!accepted) {
|
|
604
|
+
this.#fireInvalid(candidate, 'validator');
|
|
605
|
+
return false;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// ── consumer-supplied validateFn (sync or async) ──
|
|
609
|
+
if (this.#validateFn) {
|
|
610
|
+
try {
|
|
611
|
+
const result = this.#validateFn(candidate, this.#value.length);
|
|
612
|
+
if (result && typeof result.then === 'function') {
|
|
613
|
+
// Async — flip to [validating], resolve later.
|
|
614
|
+
this.#runAsyncValidation(candidate, result, source);
|
|
615
|
+
return false; // not yet committed
|
|
616
|
+
}
|
|
617
|
+
if (result === false) {
|
|
618
|
+
this.#fireInvalid(candidate, 'validator');
|
|
619
|
+
return false;
|
|
620
|
+
}
|
|
621
|
+
} catch (err) {
|
|
622
|
+
this.#fireInvalid(candidate, 'validator');
|
|
623
|
+
return false;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
this.#applyCommit(candidate, source);
|
|
628
|
+
return true;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
#runAsyncValidation(candidate, promise, source) {
|
|
632
|
+
const id = ++this.#pendingValidationId;
|
|
633
|
+
this.setAttribute('validating', '');
|
|
634
|
+
if (this.#spinnerEl) this.#spinnerEl.hidden = false;
|
|
635
|
+
promise.then(
|
|
636
|
+
(ok) => {
|
|
637
|
+
if (id !== this.#pendingValidationId || !this.isConnected) return;
|
|
638
|
+
this.removeAttribute('validating');
|
|
639
|
+
if (this.#spinnerEl) this.#spinnerEl.hidden = true;
|
|
640
|
+
if (ok === false) {
|
|
641
|
+
this.#fireInvalid(candidate, 'validator');
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
this.#applyCommit(candidate, source);
|
|
645
|
+
},
|
|
646
|
+
() => {
|
|
647
|
+
if (id !== this.#pendingValidationId || !this.isConnected) return;
|
|
648
|
+
this.removeAttribute('validating');
|
|
649
|
+
if (this.#spinnerEl) this.#spinnerEl.hidden = true;
|
|
650
|
+
this.#fireInvalid(candidate, 'validator');
|
|
651
|
+
},
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
#applyCommit(token, source) {
|
|
656
|
+
const before = this.#value.slice();
|
|
657
|
+
this.#value = [...this.#value, token];
|
|
658
|
+
this.#renderChips();
|
|
659
|
+
this.#clearInputBuffer();
|
|
660
|
+
this.#syncStateAttrs();
|
|
661
|
+
this.#syncFormValue();
|
|
662
|
+
this.#announceChange(before, this.#value, source);
|
|
663
|
+
// Keep focus on the inline input post-commit (spec §5 focus model).
|
|
664
|
+
if (source !== 'programmatic') this.#inputEl?.focus();
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
#removeByIndex(index, source) {
|
|
668
|
+
if (index < 0 || index >= this.#value.length) return;
|
|
669
|
+
const before = this.#value.slice();
|
|
670
|
+
const next = before.slice();
|
|
671
|
+
next.splice(index, 1);
|
|
672
|
+
this.#value = next;
|
|
673
|
+
this.#renderChips();
|
|
674
|
+
this.#syncStateAttrs();
|
|
675
|
+
this.#syncFormValue();
|
|
676
|
+
this.#announceChange(before, next, source);
|
|
677
|
+
if (source !== 'programmatic') this.#inputEl?.focus();
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
#announceChange(before, after, source) {
|
|
681
|
+
// Compute added / removed against `before` snapshot.
|
|
682
|
+
const beforeSet = new Set(before);
|
|
683
|
+
const afterSet = new Set(after);
|
|
684
|
+
const added = after.filter((t) => !beforeSet.has(t));
|
|
685
|
+
const removed = before.filter((t) => !afterSet.has(t));
|
|
686
|
+
if (added.length === 0 && removed.length === 0) return;
|
|
687
|
+
this.dispatchEvent(new CustomEvent('change', {
|
|
688
|
+
bubbles: true,
|
|
689
|
+
detail: { value: after.slice(), added, removed, source },
|
|
690
|
+
}));
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
#fireInvalid(value, reason) {
|
|
694
|
+
this.dispatchEvent(new CustomEvent('invalid', {
|
|
695
|
+
bubbles: true,
|
|
696
|
+
detail: { value, reason },
|
|
697
|
+
}));
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
#clearInputBuffer() {
|
|
701
|
+
if (!this.#inputEl) return;
|
|
702
|
+
this.#suppressInput = true;
|
|
703
|
+
this.#inputEl.textContent = '';
|
|
704
|
+
this.#suppressInput = false;
|
|
705
|
+
this.#inputEl.setAttribute('data-empty', '');
|
|
706
|
+
this.removeAttribute('editing');
|
|
707
|
+
this.#renderSuggestions();
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
#placeCaretAtEnd() {
|
|
711
|
+
if (!this.#inputEl || !this.#inputEl.isConnected) return;
|
|
712
|
+
try {
|
|
713
|
+
const sel = window.getSelection();
|
|
714
|
+
const range = document.createRange();
|
|
715
|
+
range.selectNodeContents(this.#inputEl);
|
|
716
|
+
range.collapse(false);
|
|
717
|
+
sel.removeAllRanges();
|
|
718
|
+
sel.addRange(range);
|
|
719
|
+
} catch { /* happy-dom may not support selection — ignore */ }
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// ── Suggestions navigation ──
|
|
723
|
+
|
|
724
|
+
#moveActiveSuggestion(dir) {
|
|
725
|
+
if (!this.#suggestEl) return;
|
|
726
|
+
const opts = Array.from(this.#suggestEl.querySelectorAll('[role="option"]'));
|
|
727
|
+
if (!opts.length) return;
|
|
728
|
+
if (this.#activeSuggestion < 0) {
|
|
729
|
+
this.#activeSuggestion = dir > 0 ? 0 : opts.length - 1;
|
|
730
|
+
} else {
|
|
731
|
+
this.#activeSuggestion = (this.#activeSuggestion + dir + opts.length) % opts.length;
|
|
732
|
+
}
|
|
733
|
+
for (const node of opts) node.removeAttribute('data-active');
|
|
734
|
+
const active = opts[this.#activeSuggestion];
|
|
735
|
+
active.setAttribute('data-active', '');
|
|
736
|
+
active.scrollIntoView({ block: 'nearest' });
|
|
737
|
+
this.#inputEl?.setAttribute('aria-activedescendant', active.id);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
#closeSuggestions() {
|
|
741
|
+
this.removeAttribute('suggesting');
|
|
742
|
+
if (this.#suggestEl) this.#suggestEl.hidden = true;
|
|
743
|
+
this.#activeSuggestion = -1;
|
|
744
|
+
this.#inputEl?.removeAttribute('aria-activedescendant');
|
|
745
|
+
this.removeAttribute('aria-expanded');
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// ── Value attribute parsing ──
|
|
749
|
+
|
|
750
|
+
#parseValueAttr(raw) {
|
|
751
|
+
if (raw == null) return null;
|
|
752
|
+
const s = String(raw).trim();
|
|
753
|
+
if (!s) return [];
|
|
754
|
+
try {
|
|
755
|
+
const parsed = JSON.parse(s);
|
|
756
|
+
if (Array.isArray(parsed)) return parsed.map((t) => String(t ?? ''));
|
|
757
|
+
} catch { /* fall through to comma-fallback */ }
|
|
758
|
+
// Comma-fallback: be charitable to consumers who pass "a,b,c" — but
|
|
759
|
+
// emit `invalid` so the A2UI authoring contract surfaces the smell.
|
|
760
|
+
if (s.includes(',')) {
|
|
761
|
+
this.#fireInvalid(s, 'validator');
|
|
762
|
+
return s.split(',').map((t) => t.trim()).filter(Boolean);
|
|
763
|
+
}
|
|
764
|
+
return [s];
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// ── Form value sync override ──
|
|
768
|
+
|
|
769
|
+
syncValue(val) {
|
|
770
|
+
// Subclasses that override `value` setter route through `#syncFormValue`,
|
|
771
|
+
// which is the canonical path. Direct calls from the base class
|
|
772
|
+
// (e.g. attributeChangedCallback for `value` attribute writes) land
|
|
773
|
+
// here — parse JSON, reroute through `set value`.
|
|
774
|
+
if (typeof val === 'string') {
|
|
775
|
+
const parsed = this.#parseValueAttr(val);
|
|
776
|
+
if (parsed) {
|
|
777
|
+
this.value = parsed;
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
super.syncValue(val);
|
|
782
|
+
}
|
|
783
|
+
}
|