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
@@ -0,0 +1,200 @@
1
+ import type { SuggestionsSource, SuggestionItem } from '../types.js';
2
+ import type { DropdownManager } from './dropdown.js';
3
+ import { getLabel, getValue } from '../utils/filter.js';
4
+
5
+ export class KeyboardManager {
6
+ private readonly input: HTMLInputElement;
7
+ private readonly onCommit: (value: string) => void;
8
+ private readonly onCommitRejected: (value: string) => void;
9
+ private readonly onRemoveLast: () => void;
10
+ private readonly clearInput: () => void;
11
+ private readonly hasChips: () => boolean;
12
+ private readonly getDropdown: () => DropdownManager;
13
+ private readonly getDelimiters: () => string[];
14
+ private readonly isEnforceSuggestions: () => boolean;
15
+ private readonly getSuggestionSource: () => SuggestionsSource | undefined;
16
+ private readonly getLoadedSuggestions: () => SuggestionItem[];
17
+
18
+ private readonly handleKeydown: (e: KeyboardEvent) => void;
19
+
20
+ constructor(
21
+ input: HTMLInputElement,
22
+ callbacks: {
23
+ onCommit: (value: string) => void;
24
+ onCommitRejected: (value: string) => void;
25
+ onRemoveLast: () => void;
26
+ clearInput: () => void;
27
+ hasChips: () => boolean;
28
+ getDropdown: () => DropdownManager;
29
+ getDelimiters: () => string[];
30
+ isEnforceSuggestions: () => boolean;
31
+ getSuggestionSource: () => SuggestionsSource | undefined;
32
+ getLoadedSuggestions: () => SuggestionItem[];
33
+ },
34
+ ) {
35
+ this.input = input;
36
+ this.onCommit = callbacks.onCommit;
37
+ this.onCommitRejected = callbacks.onCommitRejected;
38
+ this.onRemoveLast = callbacks.onRemoveLast;
39
+ this.clearInput = callbacks.clearInput;
40
+ this.hasChips = callbacks.hasChips;
41
+ this.getDropdown = callbacks.getDropdown;
42
+ this.getDelimiters = callbacks.getDelimiters;
43
+ this.isEnforceSuggestions = callbacks.isEnforceSuggestions;
44
+ this.getSuggestionSource = callbacks.getSuggestionSource;
45
+ this.getLoadedSuggestions = callbacks.getLoadedSuggestions;
46
+
47
+ this.handleKeydown = (e: KeyboardEvent) => this.routeKey(e);
48
+ this.input.addEventListener('keydown', this.handleKeydown);
49
+ }
50
+
51
+ private routeKey(e: KeyboardEvent): void {
52
+ const dropdown = this.getDropdown();
53
+ const delimiters = this.getDelimiters();
54
+
55
+ switch (e.key) {
56
+ case 'ArrowDown':
57
+ e.preventDefault();
58
+ this.handleArrowDown(dropdown);
59
+ break;
60
+
61
+ case 'ArrowUp':
62
+ e.preventDefault();
63
+ this.handleArrowUp(dropdown);
64
+ break;
65
+
66
+ case 'Home':
67
+ if (dropdown.isOpen) {
68
+ e.preventDefault();
69
+ dropdown.highlightHome();
70
+ }
71
+ break;
72
+
73
+ case 'End':
74
+ if (dropdown.isOpen) {
75
+ e.preventDefault();
76
+ dropdown.highlightEnd();
77
+ }
78
+ break;
79
+
80
+ case 'Enter':
81
+ this.handleEnter(dropdown, e);
82
+ break;
83
+
84
+ case 'Escape':
85
+ if (dropdown.isOpen) {
86
+ e.preventDefault();
87
+ dropdown.close();
88
+ }
89
+ break;
90
+
91
+ case 'Tab':
92
+ // never preventDefault() for Tab
93
+ this.handleTab(dropdown);
94
+ break;
95
+
96
+ case 'Backspace':
97
+ this.handleBackspace(e);
98
+ break;
99
+
100
+ default:
101
+ // delimiter key handling — excludes Enter and Tab (handled above)
102
+ if (e.key !== 'Enter' && e.key !== 'Tab' && delimiters.includes(e.key)) {
103
+ if (this.input.value) {
104
+ e.preventDefault();
105
+ this.commit(this.input.value);
106
+ }
107
+ }
108
+ }
109
+ }
110
+
111
+ private handleArrowDown(dropdown: DropdownManager): void {
112
+ if (dropdown.isOpen) {
113
+ dropdown.highlightNext();
114
+ return;
115
+ }
116
+
117
+ const source = this.getSuggestionSource();
118
+ const inputEmpty = !this.input.value;
119
+
120
+ if (inputEmpty && source && 'query' in source) {
121
+ // dynamic mode + empty input: do nothing
122
+ return;
123
+ }
124
+
125
+ // static/pre-fetched with empty input, or any mode with text → open + highlight first
126
+ if (inputEmpty && source) {
127
+ dropdown.filter('');
128
+ }
129
+ if (!dropdown.isOpen) dropdown.open();
130
+ dropdown.highlightFirst();
131
+ }
132
+
133
+ private handleArrowUp(dropdown: DropdownManager): void {
134
+ if (!dropdown.isOpen) return;
135
+ dropdown.highlightPrev();
136
+ }
137
+
138
+ private handleEnter(dropdown: DropdownManager, e: KeyboardEvent): void {
139
+ if (!dropdown.isOpen) {
140
+ // closed: treat as delimiter
141
+ e.preventDefault();
142
+ if (this.input.value) this.commit(this.input.value);
143
+ return;
144
+ }
145
+
146
+ e.preventDefault();
147
+ const selected = dropdown.selectActive();
148
+
149
+ if (selected !== null) {
150
+ // active suggestion selected
151
+ dropdown.close();
152
+ this.onCommit(getValue(selected));
153
+ this.clearInput();
154
+ } else {
155
+ // no active item — free-text commit
156
+ dropdown.close();
157
+ if (this.input.value) this.commit(this.input.value);
158
+ }
159
+ }
160
+
161
+ private handleTab(dropdown: DropdownManager): void {
162
+ const delimiters = this.getDelimiters();
163
+ if (delimiters.includes('Tab') && this.input.value) {
164
+ // commit and let browser move focus; blur fires on cleared input → no double-commit
165
+ this.commit(this.input.value);
166
+ }
167
+ // else: let browser handle Tab; blur-commit handles any pending text
168
+ dropdown.close();
169
+ }
170
+
171
+ private handleBackspace(e: KeyboardEvent): void {
172
+ if (this.input.value) return; // browser handles character deletion
173
+ if (this.hasChips()) {
174
+ e.preventDefault();
175
+ this.onRemoveLast();
176
+ }
177
+ }
178
+
179
+ private commit(rawValue: string): void {
180
+ if (this.isEnforceSuggestions()) {
181
+ const lower = rawValue.toLowerCase();
182
+ const match = this.getLoadedSuggestions().find(
183
+ (item) => getLabel(item).toLowerCase() === lower,
184
+ );
185
+ if (!match) {
186
+ this.onCommitRejected(rawValue);
187
+ return;
188
+ }
189
+ this.onCommit(getValue(match));
190
+ } else {
191
+ this.onCommit(rawValue);
192
+ }
193
+ this.clearInput();
194
+ this.getDropdown().close();
195
+ }
196
+
197
+ destroy(): void {
198
+ this.input.removeEventListener('keydown', this.handleKeydown);
199
+ }
200
+ }
@@ -0,0 +1,78 @@
1
+ import type { TagData, SuggestionItem } from '../types.js';
2
+ import { getLabel, getValue } from '../utils/filter.js';
3
+
4
+ export type AddTagResult =
5
+ | { ok: true; tag: TagData }
6
+ | { ok: false; reason: 'duplicate' | 'max-reached' };
7
+
8
+ export class SelectionManager {
9
+ private tags: TagData[] = [];
10
+ private nextId = 0;
11
+ private readonly base: string;
12
+ private maxTags: number | undefined;
13
+
14
+ constructor(base: string, maxTags?: number) {
15
+ this.base = base;
16
+ this.maxTags = maxTags;
17
+ }
18
+
19
+ addTag(value: string, label?: string): AddTagResult {
20
+ if (this.maxTags !== undefined && this.tags.length >= this.maxTags) {
21
+ return { ok: false, reason: 'max-reached' };
22
+ }
23
+
24
+ const isDuplicate = this.tags.some((t) => t.value.toLowerCase() === value.toLowerCase());
25
+ if (isDuplicate) {
26
+ return { ok: false, reason: 'duplicate' };
27
+ }
28
+
29
+ const resolvedLabel = label ?? value;
30
+ const tag: TagData = {
31
+ id: `${this.base}-tag-${this.nextId++}`,
32
+ label: resolvedLabel,
33
+ value,
34
+ };
35
+ this.tags.push(tag);
36
+ return { ok: true, tag };
37
+ }
38
+
39
+ removeTag(id: string): TagData | null {
40
+ const index = this.tags.findIndex((t) => t.id === id);
41
+ if (index === -1) return null;
42
+ const [removed] = this.tags.splice(index, 1);
43
+ return removed ?? null;
44
+ }
45
+
46
+ clearTags(): TagData[] {
47
+ const cleared = [...this.tags];
48
+ this.tags = [];
49
+ this.nextId = 0;
50
+ return cleared;
51
+ }
52
+
53
+ getTags(): TagData[] {
54
+ return [...this.tags];
55
+ }
56
+
57
+ addTags(items: SuggestionItem[]): TagData[] {
58
+ const added: TagData[] = [];
59
+ for (const item of items) {
60
+ const result = this.addTag(getValue(item), getLabel(item));
61
+ if (result.ok) added.push(result.tag);
62
+ }
63
+ return added;
64
+ }
65
+
66
+ setTags(items: SuggestionItem[]): TagData[] {
67
+ this.clearTags();
68
+ return this.addTags(items);
69
+ }
70
+
71
+ isTagged(value: string): boolean {
72
+ return this.tags.some((t) => t.value.toLowerCase() === value.toLowerCase());
73
+ }
74
+
75
+ setMaxTags(maxTags: number | undefined): void {
76
+ this.maxTags = maxTags;
77
+ }
78
+ }
@@ -0,0 +1,80 @@
1
+ import type { TagData, I18nStrings } from '../types.js';
2
+ import { t } from '../i18n.js';
3
+
4
+ export function createTagsContainer(): HTMLDivElement {
5
+ const div = document.createElement('div');
6
+ div.className = 'taga11y__tags';
7
+ return div;
8
+ }
9
+
10
+ export function createTagList(
11
+ strings: I18nStrings,
12
+ locale: string,
13
+ ): HTMLUListElement {
14
+ const ul = document.createElement('ul');
15
+ ul.className = 'taga11y__tag-list';
16
+ ul.setAttribute('aria-label', t(strings, locale, 'a11y.selectedTags'));
17
+ return ul;
18
+ }
19
+
20
+ function createChip(
21
+ tag: TagData,
22
+ onRemove: (id: string) => void,
23
+ strings: I18nStrings,
24
+ locale: string,
25
+ afterRemove?: () => void,
26
+ ): HTMLLIElement {
27
+ const li = document.createElement('li');
28
+ li.className = 'taga11y__tag';
29
+ li.dataset['tagId'] = tag.id;
30
+
31
+ const label = document.createElement('span');
32
+ label.className = 'taga11y__tag-label';
33
+ label.textContent = tag.label;
34
+
35
+ const btn = document.createElement('button');
36
+ btn.className = 'taga11y__tag-remove';
37
+ btn.type = 'button';
38
+ btn.setAttribute('aria-label', t(strings, locale, 'a11y.removeTag', { label: tag.label }));
39
+ btn.textContent = '×';
40
+ btn.addEventListener('click', () => {
41
+ onRemove(tag.id);
42
+ afterRemove?.();
43
+ });
44
+
45
+ li.appendChild(label);
46
+ li.appendChild(btn);
47
+ return li;
48
+ }
49
+
50
+ export function createErrorChip(text: string): HTMLLIElement {
51
+ const li = document.createElement('li');
52
+ li.className = 'taga11y__tag taga11y__tag--error';
53
+
54
+ const label = document.createElement('span');
55
+ label.className = 'taga11y__tag-label';
56
+ label.textContent = text;
57
+
58
+ li.appendChild(label);
59
+ return li;
60
+ }
61
+
62
+ export function updateChips(
63
+ tagList: HTMLUListElement,
64
+ tags: TagData[],
65
+ onRemove: (id: string) => void,
66
+ strings: I18nStrings,
67
+ locale: string,
68
+ errorChip?: HTMLLIElement,
69
+ afterRemove?: () => void,
70
+ ): void {
71
+ Array.from(tagList.children).forEach((child) => child.remove());
72
+
73
+ for (const tag of tags) {
74
+ tagList.appendChild(createChip(tag, onRemove, strings, locale, afterRemove));
75
+ }
76
+
77
+ if (errorChip) {
78
+ tagList.appendChild(errorChip);
79
+ }
80
+ }
@@ -0,0 +1,49 @@
1
+ import type { SuggestionItem } from '../types.js';
2
+ import type { DerivedIds } from '../utils/id.js';
3
+ import { getLabel } from '../utils/filter.js';
4
+
5
+ export function createListbox(ids: DerivedIds): HTMLUListElement {
6
+ const ul = document.createElement('ul');
7
+ ul.className = 'taga11y__listbox';
8
+ ul.setAttribute('role', 'listbox');
9
+ ul.id = ids.listbox;
10
+ ul.hidden = true;
11
+ return ul;
12
+ }
13
+
14
+ function renderOptions(
15
+ listbox: HTMLUListElement,
16
+ items: SuggestionItem[],
17
+ ids: DerivedIds,
18
+ ): void {
19
+ listbox.innerHTML = '';
20
+ items.forEach((item, n) => {
21
+ const li = document.createElement('li');
22
+ li.className = 'taga11y__option';
23
+ li.setAttribute('role', 'option');
24
+ li.id = ids.option(n);
25
+ li.setAttribute('aria-selected', 'false');
26
+ li.textContent = getLabel(item);
27
+ listbox.appendChild(li);
28
+ });
29
+ }
30
+
31
+ export function updateListbox(
32
+ listbox: HTMLUListElement,
33
+ loading: HTMLLIElement,
34
+ input: HTMLInputElement,
35
+ items: SuggestionItem[],
36
+ isLoading: boolean,
37
+ ids: DerivedIds,
38
+ ): void {
39
+ listbox.querySelectorAll('.taga11y__option').forEach((el) => el.remove());
40
+ input.removeAttribute('aria-activedescendant');
41
+
42
+ if (isLoading) {
43
+ loading.hidden = false;
44
+ return;
45
+ }
46
+
47
+ loading.hidden = true;
48
+ renderOptions(listbox, items, ids);
49
+ }
@@ -0,0 +1,154 @@
1
+ import type { DerivedIds } from '../utils/id.js';
2
+ import type { I18nStrings } from '../types.js';
3
+ import { createTagsContainer, createTagList } from './chips.js';
4
+ import { createListbox } from './listbox.js';
5
+ import { t } from '../i18n.js';
6
+
7
+ export interface DomElements {
8
+ wrapper: HTMLDivElement;
9
+ tagsContainer: HTMLDivElement;
10
+ tagList: HTMLUListElement;
11
+ listbox: HTMLUListElement;
12
+ loading: HTMLLIElement;
13
+ error: HTMLDivElement;
14
+ announcer: HTMLDivElement;
15
+ hiddenInput: HTMLInputElement;
16
+ }
17
+
18
+ function createLoadingIndicator(
19
+ strings: I18nStrings,
20
+ locale: string,
21
+ ): HTMLLIElement {
22
+ const li = document.createElement('li');
23
+ li.className = 'taga11y__loading';
24
+ li.setAttribute('aria-hidden', 'true');
25
+ li.hidden = true;
26
+
27
+ const spinner = document.createElement('span');
28
+ spinner.className = 'taga11y__loading-spinner';
29
+
30
+ const text = document.createElement('span');
31
+ text.className = 'taga11y__loading-text';
32
+ text.textContent = t(strings, locale, 'ui.loading');
33
+
34
+ li.appendChild(spinner);
35
+ li.appendChild(text);
36
+ return li;
37
+ }
38
+
39
+ function createErrorRegion(ids: DerivedIds): HTMLDivElement {
40
+ const div = document.createElement('div');
41
+ div.className = 'taga11y__error';
42
+ if (!ariaNotifySupported()) {
43
+ div.setAttribute('role', 'alert');
44
+ }
45
+ div.id = ids.error;
46
+ div.hidden = true;
47
+ return div;
48
+ }
49
+
50
+ function createAnnouncer(ids: DerivedIds): HTMLDivElement {
51
+ const div = document.createElement('div');
52
+ div.className = 'taga11y__announcer';
53
+ div.setAttribute('aria-live', 'polite');
54
+ div.setAttribute('aria-atomic', 'true');
55
+ div.id = ids.announcer;
56
+ return div;
57
+ }
58
+
59
+ export function setupCombobox(input: HTMLInputElement, ids: DerivedIds): void {
60
+ if (!input.id) {
61
+ input.id = ids.base;
62
+ }
63
+ input.classList.add('taga11y__input');
64
+ input.setAttribute('role', 'combobox');
65
+ input.setAttribute('aria-autocomplete', 'list');
66
+ input.setAttribute('aria-haspopup', 'listbox');
67
+ input.setAttribute('aria-expanded', 'false');
68
+ input.setAttribute('aria-controls', ids.listbox);
69
+ input.removeAttribute('aria-activedescendant');
70
+ input.setAttribute('autocomplete', 'off');
71
+ input.removeAttribute('name');
72
+ }
73
+
74
+ export function createHiddenInput(name: string, initialValue: string): HTMLInputElement {
75
+ const input = document.createElement('input');
76
+ input.type = 'hidden';
77
+ input.className = 'taga11y__hidden';
78
+ input.name = name;
79
+ input.value = initialValue;
80
+ return input;
81
+ }
82
+
83
+ export function ariaNotifySupported(): boolean {
84
+ return typeof document.ariaNotify === 'function';
85
+ }
86
+
87
+ export function announce(
88
+ region: HTMLDivElement,
89
+ message: string,
90
+ priority: 'normal' | 'high' = 'normal',
91
+ ): void {
92
+ if (ariaNotifySupported()) {
93
+ document.ariaNotify!(message, { priority });
94
+ return;
95
+ }
96
+ region.textContent = '';
97
+ setTimeout(() => {
98
+ region.textContent = message;
99
+ }, 0);
100
+ }
101
+
102
+ export function render(
103
+ input: HTMLInputElement,
104
+ ids: DerivedIds,
105
+ opts: {
106
+ label?: string;
107
+ name: string;
108
+ initialValue: string;
109
+ strings: I18nStrings;
110
+ locale: string;
111
+ },
112
+ ): DomElements {
113
+ const wrapper = document.createElement('div');
114
+ wrapper.className = 'taga11y';
115
+
116
+ if (opts.label) {
117
+ const labelEl = document.createElement('label');
118
+ labelEl.className = 'taga11y__label';
119
+ labelEl.htmlFor = input.id;
120
+ labelEl.textContent = opts.label;
121
+ wrapper.appendChild(labelEl);
122
+ }
123
+
124
+ const tagsContainer = createTagsContainer();
125
+ const tagList = createTagList(opts.strings, opts.locale);
126
+ tagsContainer.appendChild(tagList);
127
+ tagsContainer.appendChild(input);
128
+ wrapper.appendChild(tagsContainer);
129
+
130
+ const listbox = createListbox(ids);
131
+ const loading = createLoadingIndicator(opts.strings, opts.locale);
132
+ listbox.appendChild(loading);
133
+ wrapper.appendChild(listbox);
134
+
135
+ const error = createErrorRegion(ids);
136
+ wrapper.appendChild(error);
137
+
138
+ const announcer = createAnnouncer(ids);
139
+ wrapper.appendChild(announcer);
140
+
141
+ const hiddenInput = createHiddenInput(opts.name, opts.initialValue);
142
+ wrapper.appendChild(hiddenInput);
143
+
144
+ return {
145
+ wrapper,
146
+ tagsContainer,
147
+ tagList,
148
+ listbox,
149
+ loading,
150
+ error,
151
+ announcer,
152
+ hiddenInput,
153
+ };
154
+ }
package/src/i18n.ts ADDED
@@ -0,0 +1,131 @@
1
+ import type { I18nStrings, I18nKey, PluralForms } from './types.js';
2
+
3
+ /**
4
+ * Built-in English defaults. Eleven plain strings plus the plural-shaped
5
+ * `a11y.resultsCount`. These are the fallback for any key not supplied
6
+ * via the `i18n.strings` option.
7
+ */
8
+ export const EN: I18nStrings = {
9
+ 'a11y.tagAdded': 'Tag added: {label}',
10
+ 'a11y.tagRemoved': 'Tag removed: {label}',
11
+ 'a11y.tagsCleared': 'Tags cleared',
12
+ 'a11y.noResults': 'No results',
13
+ 'a11y.resultsCount': {
14
+ one: '{n} result available',
15
+ other: '{n} results available',
16
+ },
17
+ 'a11y.tagsSummary': {
18
+ one: '{n} tag selected: {labels}',
19
+ other: '{n} tags selected: {labels}',
20
+ },
21
+ 'a11y.selectedTags': 'Selected tags',
22
+ 'a11y.removeTag': 'Remove {label}',
23
+ 'ui.loading': 'Loading suggestions…',
24
+ 'error.duplicate': 'Already added: {label}',
25
+ 'error.maxReached': 'Maximum tags reached',
26
+ 'error.notInList': 'Not in the list',
27
+ 'error.loadError': 'Failed to load suggestions',
28
+ };
29
+
30
+ const KEYS = Object.keys(EN) as I18nKey[];
31
+
32
+ /**
33
+ * Merges a partial string map over the English defaults at the slot level
34
+ * (a supplied `a11y.resultsCount` wholly replaces the English default for
35
+ * that slot — there is no per-category merge). When `provided` is given,
36
+ * emits a single `console.warn` listing every key that fell back to
37
+ * English. The plural slot counts as satisfied when present (it must
38
+ * carry `other`); absent optional CLDR categories are not warned.
39
+ *
40
+ * @param provided - Partial overrides, or `undefined` for pure English.
41
+ */
42
+ export function resolveStrings(provided?: Partial<I18nStrings>): I18nStrings {
43
+ if (!provided) return { ...EN };
44
+
45
+ const resolved = { ...EN, ...provided } as I18nStrings;
46
+
47
+ const missing = KEYS.filter((k) => !(k in provided));
48
+ if (missing.length > 0) {
49
+ // Dev signal only — never user-visible.
50
+ console.warn(
51
+ `taga11y: i18n strings missing ${missing.length} key(s), falling back to English: ${missing.join(', ')}`,
52
+ );
53
+ }
54
+
55
+ return resolved;
56
+ }
57
+
58
+ function isPluralForms(value: string | PluralForms): value is PluralForms {
59
+ return typeof value === 'object' && value !== null;
60
+ }
61
+
62
+ function interpolate(template: string, vars?: Record<string, string | number>): string {
63
+ if (!vars) return template;
64
+ return template.replace(/\{(\w+)\}/g, (match, name: string) =>
65
+ name in vars ? String(vars[name]) : match,
66
+ );
67
+ }
68
+
69
+ /**
70
+ * Resolves a string slot for the given locale and interpolation vars.
71
+ *
72
+ * For the plural-shaped slot, when `vars.n` is present the CLDR category
73
+ * is selected via `Intl.PluralRules(locale).select(Number(vars.n))`,
74
+ * falling back to the required `other` form if that category is absent.
75
+ * Plain-string slots ignore `locale` and resolve placeholders directly.
76
+ *
77
+ * @param strings - Resolved string map (post `resolveStrings`).
78
+ * @param locale - Active widget locale (BCP 47).
79
+ * @param key - Dot-key slot to resolve.
80
+ * @param vars - Optional `{placeholder}` substitutions.
81
+ */
82
+ export function t(
83
+ strings: I18nStrings,
84
+ locale: string,
85
+ key: I18nKey,
86
+ vars?: Record<string, string | number>,
87
+ ): string {
88
+ const value = strings[key];
89
+
90
+ if (isPluralForms(value)) {
91
+ if (vars && 'n' in vars) {
92
+ const category = new Intl.PluralRules(locale, { type: 'cardinal' }).select(
93
+ Number(vars.n),
94
+ );
95
+ const template = value[category as keyof PluralForms] ?? value.other;
96
+ return interpolate(template, vars);
97
+ }
98
+ return interpolate(value.other, vars);
99
+ }
100
+
101
+ return interpolate(value, vars);
102
+ }
103
+
104
+ /** Primary subtag of a BCP 47 tag (`"pt-BR"` → `"pt"`), lowercased. */
105
+ function primarySubtag(tag: string): string {
106
+ return tag.split('-')[0]!.toLowerCase();
107
+ }
108
+
109
+ /**
110
+ * Stamps `lang` on the wrapper when the widget locale differs from the
111
+ * document's primary language subtag (or the document has no `lang`).
112
+ * Stamps `dir` only when explicitly provided — direction otherwise
113
+ * cascades from the parent hierarchy.
114
+ *
115
+ * @param wrapper - The widget wrapper element (already in the DOM).
116
+ * @param locale - Active widget locale.
117
+ * @param dir - Explicit direction, or `undefined` to inherit.
118
+ */
119
+ export function applyLocale(
120
+ wrapper: HTMLElement,
121
+ locale: string,
122
+ dir?: 'ltr' | 'rtl',
123
+ ): void {
124
+ const docLang = document.documentElement.getAttribute('lang');
125
+ if (!docLang || primarySubtag(docLang) !== primarySubtag(locale)) {
126
+ wrapper.setAttribute('lang', locale);
127
+ }
128
+ if (dir) {
129
+ wrapper.setAttribute('dir', dir);
130
+ }
131
+ }