taga11y 1.0.0

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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +309 -0
  3. package/dist/a11y.d.ts +8 -0
  4. package/dist/a11y.d.ts.map +1 -0
  5. package/dist/core/dropdown.d.ts +63 -0
  6. package/dist/core/dropdown.d.ts.map +1 -0
  7. package/dist/core/input.d.ts +42 -0
  8. package/dist/core/input.d.ts.map +1 -0
  9. package/dist/core/keyboard.d.ts +37 -0
  10. package/dist/core/keyboard.d.ts.map +1 -0
  11. package/dist/core/selection.d.ts +24 -0
  12. package/dist/core/selection.d.ts.map +1 -0
  13. package/dist/dom/chips.d.ts +6 -0
  14. package/dist/dom/chips.d.ts.map +1 -0
  15. package/dist/dom/listbox.d.ts +5 -0
  16. package/dist/dom/listbox.d.ts.map +1 -0
  17. package/dist/dom/render.d.ts +24 -0
  18. package/dist/dom/render.d.ts.map +1 -0
  19. package/dist/i18n.d.ts +44 -0
  20. package/dist/i18n.d.ts.map +1 -0
  21. package/dist/index.d.ts +212 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/locales/ar.json +28 -0
  24. package/dist/locales/pt.json +21 -0
  25. package/dist/react/index.d.ts +46 -0
  26. package/dist/react/index.d.ts.map +1 -0
  27. package/dist/react.cjs +1 -0
  28. package/dist/react.mjs +83 -0
  29. package/dist/taga11y.cjs +1 -0
  30. package/dist/taga11y.css +2 -0
  31. package/dist/taga11y.iife.js +1 -0
  32. package/dist/taga11y.mjs +829 -0
  33. package/dist/types.d.ts +201 -0
  34. package/dist/types.d.ts.map +1 -0
  35. package/dist/utils/debounce.d.ts +5 -0
  36. package/dist/utils/debounce.d.ts.map +1 -0
  37. package/dist/utils/filter.d.ts +5 -0
  38. package/dist/utils/filter.d.ts.map +1 -0
  39. package/dist/utils/id.d.ts +11 -0
  40. package/dist/utils/id.d.ts.map +1 -0
  41. package/dist/vue/index.d.ts +155 -0
  42. package/dist/vue/index.d.ts.map +1 -0
  43. package/dist/vue.cjs +1 -0
  44. package/dist/vue.mjs +127 -0
  45. package/package.json +111 -0
  46. package/src/a11y.ts +85 -0
  47. package/src/aria-notify.d.ts +9 -0
  48. package/src/core/dropdown.ts +331 -0
  49. package/src/core/input.ts +172 -0
  50. package/src/core/keyboard.ts +200 -0
  51. package/src/core/selection.ts +78 -0
  52. package/src/dom/chips.ts +80 -0
  53. package/src/dom/listbox.ts +49 -0
  54. package/src/dom/render.ts +154 -0
  55. package/src/i18n.ts +131 -0
  56. package/src/index.ts +900 -0
  57. package/src/react/index.tsx +223 -0
  58. package/src/styles/index.css +367 -0
  59. package/src/types.ts +224 -0
  60. package/src/utils/debounce.ts +27 -0
  61. package/src/utils/filter.ts +14 -0
  62. package/src/utils/id.ts +30 -0
  63. package/src/vite-env.d.ts +1 -0
  64. package/src/vue/index.ts +145 -0
package/src/a11y.ts ADDED
@@ -0,0 +1,85 @@
1
+ import { announce, ariaNotifySupported } from './dom/render.js';
2
+ import { t } from './i18n.js';
3
+ import type { I18nStrings, TagData } from './types.js';
4
+
5
+ export function showError(
6
+ error: HTMLDivElement,
7
+ message: string,
8
+ errorChip?: HTMLElement,
9
+ ): void {
10
+ error.textContent = message;
11
+ error.hidden = false;
12
+ // role="alert" announces the set above; when absent (ariaNotify supported), notify instead.
13
+ if (ariaNotifySupported()) {
14
+ announce(error, message, 'high');
15
+ }
16
+ setTimeout(() => {
17
+ hideError(error);
18
+ errorChip?.remove();
19
+ }, 3000);
20
+ }
21
+
22
+ function hideError(error: HTMLDivElement): void {
23
+ error.textContent = '';
24
+ error.hidden = true;
25
+ }
26
+
27
+ export function announceTagAdded(
28
+ announcer: HTMLDivElement,
29
+ strings: I18nStrings,
30
+ locale: string,
31
+ label: string,
32
+ ): void {
33
+ announce(announcer, t(strings, locale, 'a11y.tagAdded', { label }));
34
+ }
35
+
36
+ export function announceTagRemoved(
37
+ announcer: HTMLDivElement,
38
+ strings: I18nStrings,
39
+ locale: string,
40
+ label: string,
41
+ ): void {
42
+ announce(announcer, t(strings, locale, 'a11y.tagRemoved', { label }));
43
+ }
44
+
45
+ export function announceTagsCleared(
46
+ announcer: HTMLDivElement,
47
+ strings: I18nStrings,
48
+ locale: string,
49
+ ): void {
50
+ announce(announcer, t(strings, locale, 'a11y.tagsCleared'));
51
+ }
52
+
53
+ export function announceTagsSummary(
54
+ announcer: HTMLDivElement,
55
+ strings: I18nStrings,
56
+ locale: string,
57
+ tags: TagData[],
58
+ ): void {
59
+ if (tags.length === 0) return;
60
+ const labels = new Intl.ListFormat(locale, {
61
+ type: 'conjunction',
62
+ style: 'long',
63
+ }).format(tags.map((tag) => tag.label));
64
+ announce(
65
+ announcer,
66
+ t(strings, locale, 'a11y.tagsSummary', { n: tags.length, labels }),
67
+ );
68
+ }
69
+
70
+ export function announceRejection(
71
+ error: HTMLDivElement,
72
+ strings: I18nStrings,
73
+ locale: string,
74
+ reason: 'duplicate' | 'max-reached' | 'not-in-list',
75
+ label: string,
76
+ errorChip?: HTMLElement,
77
+ ): void {
78
+ const message =
79
+ reason === 'duplicate'
80
+ ? t(strings, locale, 'error.duplicate', { label })
81
+ : reason === 'max-reached'
82
+ ? t(strings, locale, 'error.maxReached')
83
+ : t(strings, locale, 'error.notInList');
84
+ showError(error, message, errorChip);
85
+ }
@@ -0,0 +1,9 @@
1
+ // Ambient declaration for the standards-track Document.ariaNotify() API
2
+ // (ARIA ARIANotifyMixin), not yet present in TypeScript's lib.dom.d.ts.
3
+ // Optional so feature detection typechecks and its absence (e.g. Safari) is valid.
4
+ interface Document {
5
+ ariaNotify?(
6
+ message: string,
7
+ options?: { priority?: 'normal' | 'high' },
8
+ ): void;
9
+ }
@@ -0,0 +1,331 @@
1
+ import type { SuggestionItem, SuggestionsSource, I18nStrings } from '../types.js';
2
+ import type { DerivedIds } from '../utils/id.js';
3
+ import { filter as filterItems, getValue } from '../utils/filter.js';
4
+ import { debounce, type DebouncedFn } from '../utils/debounce.js';
5
+ import { updateListbox } from '../dom/listbox.js';
6
+ import { t } from '../i18n.js';
7
+
8
+ export class DropdownManager {
9
+ private readonly listbox: HTMLUListElement;
10
+ private readonly input: HTMLInputElement;
11
+ private readonly loading: HTMLLIElement;
12
+ private readonly wrapper: HTMLElement;
13
+ private readonly ids: DerivedIds;
14
+ private readonly strings: I18nStrings;
15
+ private readonly locale: string;
16
+ private maxSuggestions: number;
17
+ private source: SuggestionsSource | undefined;
18
+ private readonly getSelectedValues: () => string[];
19
+ private readonly onAnnounce: (message: string) => void;
20
+ private readonly onLoadError: (message: string) => void;
21
+ private readonly onOptionSelect: ((item: SuggestionItem) => void) | undefined;
22
+
23
+ isOpen = false;
24
+ private activeIndex = -1;
25
+ filteredSuggestions: SuggestionItem[] = [];
26
+ private isLoading = false;
27
+ private cachedItems: SuggestionItem[] | null = null;
28
+ private _isPrefetching = false;
29
+ private abortController: AbortController | null = null;
30
+ private debouncedQuery: DebouncedFn<[string]> | null = null;
31
+ private readonly resizeObserver: ResizeObserver;
32
+
33
+ constructor(
34
+ elements: {
35
+ listbox: HTMLUListElement;
36
+ input: HTMLInputElement;
37
+ loading: HTMLLIElement;
38
+ wrapper: HTMLElement;
39
+ },
40
+ options: {
41
+ suggestions?: SuggestionsSource;
42
+ debounceMs: number;
43
+ maxSuggestions: number;
44
+ getSelectedValues: () => string[];
45
+ ids: DerivedIds;
46
+ strings: I18nStrings;
47
+ locale: string;
48
+ onAnnounce?: (message: string) => void;
49
+ onLoadError?: (message: string) => void;
50
+ onOptionSelect?: (item: SuggestionItem) => void;
51
+ },
52
+ ) {
53
+ this.listbox = elements.listbox;
54
+ this.input = elements.input;
55
+ this.loading = elements.loading;
56
+ this.wrapper = elements.wrapper;
57
+ this.ids = options.ids;
58
+ this.strings = options.strings;
59
+ this.locale = options.locale;
60
+ this.maxSuggestions = options.maxSuggestions;
61
+ this.source = options.suggestions;
62
+ this.getSelectedValues = options.getSelectedValues;
63
+ this.onAnnounce = options.onAnnounce ?? (() => undefined);
64
+ this.onLoadError = options.onLoadError ?? (() => undefined);
65
+ this.onOptionSelect = options.onOptionSelect;
66
+
67
+ if (this.source && 'query' in this.source) {
68
+ const src = this.source;
69
+ this.debouncedQuery = debounce((query: string) => {
70
+ void this.executeDynamicQuery(src, query);
71
+ }, options.debounceMs);
72
+ }
73
+
74
+ if (this.source && 'once' in this.source) {
75
+ void this.startPrefetch(this.source);
76
+ }
77
+
78
+ this.resizeObserver = new ResizeObserver(() => {
79
+ // CSS (width:100% on .taga11y__listbox) keeps the listbox aligned;
80
+ // this hook exists for future JS-side overrides if needed.
81
+ });
82
+ this.resizeObserver.observe(this.wrapper);
83
+
84
+ this.listbox.addEventListener('mousedown', (e) => {
85
+ const target = (e.target as Element).closest('[role="option"]');
86
+ if (!target) return;
87
+ e.preventDefault();
88
+ const options = Array.from(this.listbox.querySelectorAll<HTMLElement>('[role="option"]'));
89
+ const index = options.indexOf(target as HTMLElement);
90
+ if (index === -1) return;
91
+ const item = this.filteredSuggestions[index];
92
+ if (item !== undefined) this.onOptionSelect?.(item);
93
+ });
94
+ }
95
+
96
+ private async startPrefetch(src: { once: () => Promise<SuggestionItem[]> }): Promise<void> {
97
+ this._isPrefetching = true;
98
+ try {
99
+ this.cachedItems = await src.once();
100
+ this._isPrefetching = false;
101
+ if (this.isLoading) {
102
+ // User opened dropdown while fetch was in flight — now render results.
103
+ this.setLoading(false);
104
+ this.filter(this.input.value);
105
+ }
106
+ } catch {
107
+ this._isPrefetching = false;
108
+ if (this.isLoading) this.setLoading(false);
109
+ this.onLoadError(t(this.strings, this.locale, 'error.loadError'));
110
+ }
111
+ }
112
+
113
+ private async executeDynamicQuery(
114
+ src: { query: (input: string, signal: AbortSignal) => Promise<SuggestionItem[]> },
115
+ query: string,
116
+ ): Promise<void> {
117
+ this.abortController?.abort();
118
+ this.abortController = new AbortController();
119
+ const { signal } = this.abortController;
120
+
121
+ this.setLoading(true);
122
+
123
+ try {
124
+ const results = await src.query(query, signal);
125
+ if (signal.aborted) return;
126
+
127
+ const selected = this.getSelectedValues();
128
+ const filtered = filterItems(results, query)
129
+ .filter((item) => !selected.includes(getValue(item)))
130
+ .slice(0, this.maxSuggestions);
131
+
132
+ this.filteredSuggestions = filtered;
133
+ this.setLoading(false);
134
+
135
+ if (filtered.length === 0) {
136
+ this.close();
137
+ this.onAnnounce(t(this.strings, this.locale, 'a11y.noResults'));
138
+ } else {
139
+ updateListbox(this.listbox, this.loading, this.input, filtered, false, this.ids);
140
+ if (!this.isOpen) this.open();
141
+ this.onAnnounce(
142
+ t(this.strings, this.locale, 'a11y.resultsCount', { n: filtered.length }),
143
+ );
144
+ this.highlightFirst();
145
+ }
146
+ } catch (err) {
147
+ if (err instanceof Error && err.name === 'AbortError') return;
148
+ this.setLoading(false);
149
+ this.close();
150
+ this.onLoadError(t(this.strings, this.locale, 'error.loadError'));
151
+ }
152
+ }
153
+
154
+ open(): void {
155
+ this.listbox.hidden = false;
156
+ this.input.setAttribute('aria-expanded', 'true');
157
+ this.isOpen = true;
158
+ }
159
+
160
+ close(): void {
161
+ // Cancel any pending debounce + in-flight query so they can't reopen
162
+ // the dropdown asynchronously after this close gesture.
163
+ this.debouncedQuery?.cancel();
164
+ this.abortController?.abort();
165
+ this.listbox.hidden = true;
166
+ this.input.setAttribute('aria-expanded', 'false');
167
+ this.input.removeAttribute('aria-activedescendant');
168
+ this.activeIndex = -1;
169
+ this.loading.hidden = true;
170
+ this.isLoading = false;
171
+ this.isOpen = false;
172
+ }
173
+
174
+ toggle(): void {
175
+ if (this.isOpen) {
176
+ this.close();
177
+ } else {
178
+ this.open();
179
+ }
180
+ }
181
+
182
+ filter(query: string): void {
183
+ if (!this.source) return;
184
+
185
+ const trimmed = query.trim();
186
+
187
+ if ('query' in this.source) {
188
+ // Dynamic mode: only trigger on non-empty query
189
+ if (!trimmed) return;
190
+ this.debouncedQuery!(trimmed);
191
+ return;
192
+ }
193
+
194
+ if ('once' in this.source && this.cachedItems === null && this._isPrefetching) {
195
+ // Prefetch still in flight — show loading indicator and wait for completion.
196
+ this.setLoading(true);
197
+ return;
198
+ }
199
+
200
+ const baseItems: SuggestionItem[] = 'once' in this.source
201
+ ? (this.cachedItems ?? [])
202
+ : this.source;
203
+
204
+ const selected = this.getSelectedValues();
205
+ const filtered = filterItems(baseItems, trimmed)
206
+ .filter((item) => !selected.includes(getValue(item)))
207
+ .slice(0, this.maxSuggestions);
208
+
209
+ this.filteredSuggestions = filtered;
210
+ updateListbox(this.listbox, this.loading, this.input, filtered, false, this.ids);
211
+
212
+ if (filtered.length === 0) {
213
+ this.close();
214
+ this.onAnnounce(t(this.strings, this.locale, 'a11y.noResults'));
215
+ } else {
216
+ if (!this.isOpen) this.open();
217
+ this.onAnnounce(
218
+ t(this.strings, this.locale, 'a11y.resultsCount', { n: filtered.length }),
219
+ );
220
+ this.highlightFirst();
221
+ }
222
+ }
223
+
224
+ setActiveIndex(index: number): void {
225
+ const options = Array.from(this.listbox.querySelectorAll<HTMLElement>('[role="option"]'));
226
+
227
+ options.forEach((opt) => opt.setAttribute('aria-selected', 'false'));
228
+
229
+ if (index < 0 || index >= options.length) {
230
+ this.activeIndex = -1;
231
+ this.input.removeAttribute('aria-activedescendant');
232
+ return;
233
+ }
234
+
235
+ this.activeIndex = index;
236
+ const activeOpt = options[index];
237
+ if (activeOpt) {
238
+ activeOpt.setAttribute('aria-selected', 'true');
239
+ this.input.setAttribute('aria-activedescendant', activeOpt.id);
240
+ }
241
+ }
242
+
243
+ highlightFirst(): void {
244
+ this.setActiveIndex(0);
245
+ }
246
+
247
+ highlightPrev(): void {
248
+ const count = this.filteredSuggestions.length;
249
+ if (count === 0) return;
250
+ const prev = this.activeIndex <= 0 ? count - 1 : this.activeIndex - 1;
251
+ this.setActiveIndex(prev);
252
+ }
253
+
254
+ highlightNext(): void {
255
+ const count = this.filteredSuggestions.length;
256
+ if (count === 0) return;
257
+ const next = this.activeIndex >= count - 1 ? 0 : this.activeIndex + 1;
258
+ this.setActiveIndex(next);
259
+ }
260
+
261
+ highlightHome(): void {
262
+ this.setActiveIndex(0);
263
+ }
264
+
265
+ highlightEnd(): void {
266
+ this.setActiveIndex(this.filteredSuggestions.length - 1);
267
+ }
268
+
269
+ selectActive(): SuggestionItem | null {
270
+ if (this.activeIndex < 0) return null;
271
+ return this.filteredSuggestions[this.activeIndex] ?? null;
272
+ }
273
+
274
+ setLoading(value: boolean): void {
275
+ this.isLoading = value;
276
+ updateListbox(this.listbox, this.loading, this.input, this.filteredSuggestions, value, this.ids);
277
+ if (value) {
278
+ if (!this.isOpen) this.open();
279
+ this.onAnnounce(t(this.strings, this.locale, 'ui.loading'));
280
+ }
281
+ }
282
+
283
+ setSource(source: SuggestionsSource | undefined, debounceMs: number): void {
284
+ this.debouncedQuery?.cancel();
285
+ this.abortController?.abort();
286
+ this.abortController = null;
287
+ this.cachedItems = null;
288
+ this._isPrefetching = false;
289
+ this.source = source;
290
+
291
+ if (source && 'query' in source) {
292
+ const src = source;
293
+ this.debouncedQuery = debounce((query: string) => {
294
+ void this.executeDynamicQuery(src, query);
295
+ }, debounceMs);
296
+ } else {
297
+ this.debouncedQuery = null;
298
+ }
299
+
300
+ if (source && 'once' in source) {
301
+ void this.startPrefetch(source);
302
+ }
303
+ }
304
+
305
+ setDebounceMs(debounceMs: number): void {
306
+ if (!this.source || !('query' in this.source)) return;
307
+ const src = this.source;
308
+ this.debouncedQuery?.cancel();
309
+ this.debouncedQuery = debounce((query: string) => {
310
+ void this.executeDynamicQuery(src, query);
311
+ }, debounceMs);
312
+ }
313
+
314
+ setMaxSuggestions(maxSuggestions: number): void {
315
+ // Lazy: the new cap governs the next filter; an open dropdown is untouched.
316
+ this.maxSuggestions = maxSuggestions;
317
+ }
318
+
319
+ getAllSuggestions(): SuggestionItem[] {
320
+ if (!this.source) return [];
321
+ if (Array.isArray(this.source)) return this.source;
322
+ if ('once' in this.source) return this.cachedItems ?? [];
323
+ return this.filteredSuggestions;
324
+ }
325
+
326
+ destroy(): void {
327
+ this.debouncedQuery?.cancel();
328
+ this.abortController?.abort();
329
+ this.resizeObserver.disconnect();
330
+ }
331
+ }
@@ -0,0 +1,172 @@
1
+ import type { SuggestionItem } from '../types.js';
2
+ import { getLabel, getValue } from '../utils/filter.js';
3
+
4
+ export class InputManager {
5
+ private readonly input: HTMLInputElement;
6
+ private readonly onInput: (value: string) => void;
7
+ private readonly onBlurCommit: (value: string) => void;
8
+ private readonly onBlurClose: () => void;
9
+ private readonly onFocus: (() => void) | undefined;
10
+ private readonly getDelimiters: () => string[];
11
+ private readonly isEnforceSuggestions: () => boolean;
12
+ private readonly getLoadedSuggestions: () => SuggestionItem[];
13
+ private readonly onCommit: (value: string) => void;
14
+ private readonly onCommitRejected: (value: string) => void;
15
+ private readonly onPaste: (chunks: string[]) => void;
16
+ private readonly onCloseDropdown: () => void;
17
+
18
+ private currentValue = '';
19
+ private blurTimer: ReturnType<typeof setTimeout> | undefined;
20
+
21
+ private readonly handleInput: () => void;
22
+ private readonly handleFocus: () => void;
23
+ private readonly handleBlur: () => void;
24
+ private readonly handlePaste: (e: ClipboardEvent) => void;
25
+
26
+ constructor(
27
+ input: HTMLInputElement,
28
+ callbacks: {
29
+ onInput: (value: string) => void;
30
+ onBlurCommit: (value: string) => void;
31
+ onBlurClose: () => void;
32
+ onFocus?: () => void;
33
+ getDelimiters?: () => string[];
34
+ isEnforceSuggestions?: () => boolean;
35
+ getLoadedSuggestions?: () => SuggestionItem[];
36
+ onCommit?: (value: string) => void;
37
+ onCommitRejected?: (value: string) => void;
38
+ onPaste?: (chunks: string[]) => void;
39
+ onCloseDropdown?: () => void;
40
+ },
41
+ ) {
42
+ this.input = input;
43
+ this.onInput = callbacks.onInput;
44
+ this.onBlurCommit = callbacks.onBlurCommit;
45
+ this.onBlurClose = callbacks.onBlurClose;
46
+ this.onFocus = callbacks.onFocus;
47
+ this.getDelimiters = callbacks.getDelimiters ?? (() => []);
48
+ this.isEnforceSuggestions = callbacks.isEnforceSuggestions ?? (() => false);
49
+ this.getLoadedSuggestions = callbacks.getLoadedSuggestions ?? (() => []);
50
+ this.onCommit = callbacks.onCommit ?? (() => undefined);
51
+ this.onCommitRejected = callbacks.onCommitRejected ?? (() => undefined);
52
+ this.onPaste = callbacks.onPaste ?? (() => undefined);
53
+ this.onCloseDropdown = callbacks.onCloseDropdown ?? (() => undefined);
54
+
55
+ this.handleInput = () => {
56
+ this.currentValue = this.input.value;
57
+ const charDelims = this.getCharDelimiters();
58
+ if (charDelims.length > 0 && this.containsAny(this.currentValue, charDelims)) {
59
+ this.extractAndCommit(charDelims);
60
+ return;
61
+ }
62
+ this.onInput(this.currentValue);
63
+ };
64
+
65
+ this.handleFocus = () => {
66
+ this.onFocus?.();
67
+ };
68
+
69
+ this.handleBlur = () => {
70
+ clearTimeout(this.blurTimer);
71
+ this.blurTimer = setTimeout(() => {
72
+ if (this.currentValue) {
73
+ this.onBlurCommit(this.currentValue);
74
+ }
75
+ this.onBlurClose();
76
+ }, 150);
77
+ };
78
+
79
+ this.handlePaste = (e: ClipboardEvent) => {
80
+ const text = e.clipboardData?.getData('text') ?? '';
81
+ if (!text) return;
82
+ const charDelims = this.getCharDelimiters();
83
+ if (charDelims.length === 0 || !this.containsAny(text, charDelims)) return;
84
+ e.preventDefault();
85
+ const pattern = new RegExp(`[${escapeForCharClass(charDelims.join(''))}]`);
86
+ const chunks = text.split(pattern).map((p) => p.trim()).filter((p) => p !== '');
87
+ this.input.value = '';
88
+ this.currentValue = '';
89
+ this.onPaste(chunks);
90
+ this.onCloseDropdown();
91
+ };
92
+
93
+ this.input.addEventListener('input', this.handleInput);
94
+ this.input.addEventListener('focus', this.handleFocus);
95
+ this.input.addEventListener('blur', this.handleBlur);
96
+ this.input.addEventListener('paste', this.handlePaste);
97
+ }
98
+
99
+ clearValue(): void {
100
+ this.input.value = '';
101
+ this.currentValue = '';
102
+ }
103
+
104
+ getValue(): string {
105
+ return this.currentValue;
106
+ }
107
+
108
+ destroy(): void {
109
+ clearTimeout(this.blurTimer);
110
+ this.input.removeEventListener('input', this.handleInput);
111
+ this.input.removeEventListener('focus', this.handleFocus);
112
+ this.input.removeEventListener('blur', this.handleBlur);
113
+ this.input.removeEventListener('paste', this.handlePaste);
114
+ }
115
+
116
+ private getCharDelimiters(): string[] {
117
+ return this.getDelimiters().filter((d) => d.length === 1);
118
+ }
119
+
120
+ private containsAny(value: string, chars: string[]): boolean {
121
+ for (const c of chars) {
122
+ if (value.indexOf(c) !== -1) return true;
123
+ }
124
+ return false;
125
+ }
126
+
127
+ private extractAndCommit(charDelims: string[]): void {
128
+ // Trim each split chunk independently, then treat the LAST chunk as the
129
+ // editable remainder (or terminating signal if it trims to empty). This
130
+ // correctly handles soft keyboards that autospace after punctuation —
131
+ // e.g. FUTO inserts ", " (comma + space) for a single comma tap, which
132
+ // means `input.value === "test, "` ends with a space, not a delimiter.
133
+ // The old "endsWithDelim = last char is delimiter" check missed this.
134
+ const value = this.currentValue;
135
+ const pattern = new RegExp(`[${escapeForCharClass(charDelims.join(''))}]`);
136
+ const trimmedParts = value.split(pattern).map((p) => p.trim());
137
+ const remainder = trimmedParts[trimmedParts.length - 1] ?? '';
138
+ const commits = trimmedParts.slice(0, -1).filter((p) => p !== '');
139
+
140
+ for (const chunk of commits) {
141
+ this.commitChunk(chunk);
142
+ }
143
+
144
+ this.input.value = remainder;
145
+ this.currentValue = remainder;
146
+ if (remainder) {
147
+ this.onInput(remainder);
148
+ } else {
149
+ this.onCloseDropdown();
150
+ }
151
+ }
152
+
153
+ private commitChunk(rawValue: string): void {
154
+ if (this.isEnforceSuggestions()) {
155
+ const lower = rawValue.toLowerCase();
156
+ const match = this.getLoadedSuggestions().find(
157
+ (item) => getLabel(item).toLowerCase() === lower,
158
+ );
159
+ if (!match) {
160
+ this.onCommitRejected(rawValue);
161
+ return;
162
+ }
163
+ this.onCommit(getValue(match));
164
+ } else {
165
+ this.onCommit(rawValue);
166
+ }
167
+ }
168
+ }
169
+
170
+ function escapeForCharClass(chars: string): string {
171
+ return chars.replace(/[\\\]^-]/g, (m) => `\\${m}`);
172
+ }