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/types.ts ADDED
@@ -0,0 +1,224 @@
1
+ /**
2
+ * A single suggestion item. Can be a plain string (label equals value)
3
+ * or an object with a display `label` and a form `value`.
4
+ */
5
+ export type SuggestionItem = string | { label: string; value: string };
6
+
7
+ /**
8
+ * Static suggestion source — a plain array of suggestions evaluated once
9
+ * at initialisation and filtered in memory thereafter.
10
+ */
11
+ export type StaticSource = SuggestionItem[];
12
+
13
+ /**
14
+ * Pre-fetched suggestion source — a single async call that returns all
15
+ * suggestions upfront. Results are cached and filtered in memory for
16
+ * every subsequent filter cycle.
17
+ */
18
+ export type PrefetchSource = { once: () => Promise<SuggestionItem[]> };
19
+
20
+ /**
21
+ * Dynamic suggestion source — an async callback invoked on every keystroke
22
+ * (debounced). Receives the current input text and an `AbortSignal` that
23
+ * fires when a newer request supersedes this one.
24
+ */
25
+ export type DynamicSource = {
26
+ query: (input: string, signal: AbortSignal) => Promise<SuggestionItem[]>;
27
+ };
28
+
29
+ /**
30
+ * The shape accepted by the `suggestions` option. One of:
31
+ * - `StaticSource` — plain array
32
+ * - `PrefetchSource` — `{ once }` object
33
+ * - `DynamicSource` — `{ query }` object
34
+ *
35
+ * Defining both `once` and `query` on the same object throws.
36
+ */
37
+ export type SuggestionsSource = StaticSource | PrefetchSource | DynamicSource;
38
+
39
+ /**
40
+ * A single selected tag as stored internally and emitted in event payloads.
41
+ */
42
+ export interface TagData {
43
+ /** Unique internal identifier (e.g. `"taga11y-tags-tag-0"`). */
44
+ id: string;
45
+ /** Human-readable label displayed in the chip. */
46
+ label: string;
47
+ /** Form value committed to the hidden input. */
48
+ value: string;
49
+ }
50
+
51
+ /**
52
+ * CLDR plural-forms value. `other` is always required (every locale has an
53
+ * `other` category; it is the universal fallback). The remaining categories
54
+ * are optional and fall back to `other` at resolution time.
55
+ */
56
+ export type PluralForms = { other: string } & Partial<
57
+ Record<'zero' | 'one' | 'two' | 'few' | 'many', string>
58
+ >;
59
+
60
+ /**
61
+ * Typed map of every user-facing string slot. Eleven slots are plain
62
+ * strings; `a11y.resultsCount` and `a11y.tagsSummary` are
63
+ * {@link PluralForms} objects resolved per-locale via `Intl.PluralRules`.
64
+ * All values are plain JSON.
65
+ */
66
+ export interface I18nStrings {
67
+ 'a11y.tagAdded': string;
68
+ 'a11y.tagRemoved': string;
69
+ 'a11y.tagsCleared': string;
70
+ 'a11y.noResults': string;
71
+ 'a11y.resultsCount': PluralForms;
72
+ 'a11y.tagsSummary': PluralForms;
73
+ 'a11y.selectedTags': string;
74
+ 'a11y.removeTag': string;
75
+ 'ui.loading': string;
76
+ 'error.duplicate': string;
77
+ 'error.maxReached': string;
78
+ 'error.notInList': string;
79
+ 'error.loadError': string;
80
+ }
81
+
82
+ /** Dot-key identifying a single {@link I18nStrings} slot. */
83
+ export type I18nKey = keyof I18nStrings;
84
+
85
+ /**
86
+ * Internationalisation option. Init-only — not accepted by `settings()`.
87
+ * Runtime locale change requires `destroy()` + `new Taga11y()`.
88
+ */
89
+ export interface I18nOptions {
90
+ /** BCP 47 locale tag, e.g. `"pt"`, `"ar"`, `"pt-BR"`. */
91
+ locale: string;
92
+ /**
93
+ * Explicit text direction stamped on the wrapper. Omit to inherit
94
+ * direction from the parent cascade (the common case).
95
+ */
96
+ dir?: 'ltr' | 'rtl';
97
+ /**
98
+ * Partial override of the built-in English strings. Missing keys fall
99
+ * back to English; a supplied `a11y.resultsCount` wholly replaces the
100
+ * English default for that slot and must include `other`.
101
+ */
102
+ strings?: Partial<I18nStrings>;
103
+ }
104
+
105
+ export interface Taga11yOptions {
106
+ /**
107
+ * Suggestion source: static array, pre-fetched async, or dynamic callback.
108
+ * Omit for free-text mode with no suggestions.
109
+ */
110
+ suggestions?: SuggestionsSource;
111
+
112
+ /** Maximum number of tags the user can select. Undefined means no limit. */
113
+ maxTags?: number;
114
+
115
+ /**
116
+ * Maximum number of suggestions rendered in the dropdown at once.
117
+ * Defaults to 10. `0` shows no suggestions; to show all matches pass a
118
+ * large number (e.g. `9999999`). Negative values are clamped to 0.
119
+ * Live-updatable via `settings()`; a new value takes effect on the next
120
+ * filter and does not re-render an already-open dropdown.
121
+ */
122
+ maxSuggestions?: number;
123
+
124
+ /**
125
+ * Characters that trigger tag commit when typed. Defaults to `[, Enter]`.
126
+ * Accepts a single character/string or an array.
127
+ */
128
+ delimiter?: string | string[];
129
+
130
+ /**
131
+ * When true, only suggestions from the source can be committed.
132
+ * Free-text entry is rejected with an error.
133
+ */
134
+ enforceSuggestions?: boolean;
135
+
136
+ /**
137
+ * Name attribute for the hidden form input. Defaults to the original
138
+ * input's `name` attribute.
139
+ */
140
+ name?: string;
141
+
142
+ /** Optional visible label rendered above the component. */
143
+ label?: string;
144
+
145
+ /** When true, the component is rendered in a disabled state. */
146
+ disabled?: boolean;
147
+
148
+ /**
149
+ * Forced theme override. `null` (default) follows the OS preference.
150
+ * Use `"dark"` or `"light"` to override regardless of OS setting.
151
+ */
152
+ theme?: 'dark' | 'light' | null;
153
+
154
+ /**
155
+ * Custom serializer for the hidden input value. Receives the current
156
+ * tag array and returns a string. Defaults to comma-joining values.
157
+ */
158
+ serialize?: (tags: TagData[]) => string;
159
+
160
+ /**
161
+ * Custom deserializer for the original input's `value` on init and on
162
+ * form reset. Pairs with `serialize` to enable round-tripping a custom
163
+ * format. Returns the same `SuggestionItem` shape `suggestions` accepts:
164
+ * plain strings resolve labels from loaded suggestions (falling back to
165
+ * label === value); `{ label, value }` objects are used as-is. Defaults
166
+ * to splitting on `","`, trimming, and dropping empties.
167
+ */
168
+ deserialize?: (raw: string) => SuggestionItem[];
169
+
170
+ /**
171
+ * Debounce delay in milliseconds for dynamic suggestion requests.
172
+ * Default: 200. Only applies to dynamic (`{ query }`) mode.
173
+ */
174
+ debounceMs?: number;
175
+
176
+ /**
177
+ * Internationalisation: locale, optional direction, and partial string
178
+ * overrides. Init-only — ignored by `settings()`. Omit for built-in
179
+ * English with no `lang`/`dir` on the wrapper.
180
+ */
181
+ i18n?: I18nOptions;
182
+ }
183
+
184
+ /** Detail payload for the `taga11y:add` event. */
185
+ export interface Taga11yAddDetail {
186
+ tag: TagData;
187
+ }
188
+
189
+ /** Detail payload for the `taga11y:remove` event. */
190
+ export interface Taga11yRemoveDetail {
191
+ tag: TagData;
192
+ }
193
+
194
+ /** Detail payload for the `taga11y:clear` event — the tags that were cleared. */
195
+ export interface Taga11yClearDetail {
196
+ tags: TagData[];
197
+ }
198
+
199
+ /** Detail payload for the `taga11y:change` event — the current tag set. */
200
+ export interface Taga11yChangeDetail {
201
+ tags: TagData[];
202
+ }
203
+
204
+ /**
205
+ * Detail payload for the `taga11y:paste` event. `added` lists committed tags
206
+ * in commit order; `skipped` lists trimmed chunk strings that were rejected.
207
+ */
208
+ export interface Taga11yPasteDetail {
209
+ added: TagData[];
210
+ skipped: string[];
211
+ }
212
+
213
+ /** Detail payload for the `taga11y:destroy` event (no fields). */
214
+ export type Taga11yDestroyDetail = Record<string, never>;
215
+
216
+ /** Maps each taga11y custom event name to its `CustomEvent` detail type. */
217
+ export interface Taga11yEventMap {
218
+ 'taga11y:add': Taga11yAddDetail;
219
+ 'taga11y:remove': Taga11yRemoveDetail;
220
+ 'taga11y:clear': Taga11yClearDetail;
221
+ 'taga11y:change': Taga11yChangeDetail;
222
+ 'taga11y:paste': Taga11yPasteDetail;
223
+ 'taga11y:destroy': Taga11yDestroyDetail;
224
+ }
@@ -0,0 +1,27 @@
1
+ export type DebouncedFn<T extends unknown[]> = ((...args: T) => void) & {
2
+ cancel: () => void;
3
+ };
4
+
5
+ export function debounce<T extends unknown[]>(
6
+ fn: (...args: T) => void,
7
+ ms: number,
8
+ ): DebouncedFn<T> {
9
+ let timer: ReturnType<typeof setTimeout> | undefined;
10
+
11
+ const debounced = (...args: T): void => {
12
+ if (timer !== undefined) clearTimeout(timer);
13
+ timer = setTimeout(() => {
14
+ timer = undefined;
15
+ fn(...args);
16
+ }, ms);
17
+ };
18
+
19
+ debounced.cancel = (): void => {
20
+ if (timer !== undefined) {
21
+ clearTimeout(timer);
22
+ timer = undefined;
23
+ }
24
+ };
25
+
26
+ return debounced;
27
+ }
@@ -0,0 +1,14 @@
1
+ import type { SuggestionItem } from '../types.js';
2
+
3
+ export function getLabel(item: SuggestionItem): string {
4
+ return typeof item === 'string' ? item : item.label;
5
+ }
6
+
7
+ export function getValue(item: SuggestionItem): string {
8
+ return typeof item === 'string' ? item : item.value;
9
+ }
10
+
11
+ export function filter(items: SuggestionItem[], query: string): SuggestionItem[] {
12
+ const lower = query.toLowerCase();
13
+ return items.filter((item) => getLabel(item).toLowerCase().includes(lower));
14
+ }
@@ -0,0 +1,30 @@
1
+ function randomSuffix(): string {
2
+ const buf = new Uint8Array(4);
3
+ crypto.getRandomValues(buf);
4
+ return Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join('');
5
+ }
6
+
7
+ export function deriveBaseId(input: HTMLInputElement): string {
8
+ const existingId = input.id.trim();
9
+ return existingId ? `taga11y-${existingId}` : `taga11y-${randomSuffix()}`;
10
+ }
11
+
12
+ export interface DerivedIds {
13
+ base: string;
14
+ label: string;
15
+ listbox: string;
16
+ option: (n: number) => string;
17
+ error: string;
18
+ announcer: string;
19
+ }
20
+
21
+ export function buildIds(base: string): DerivedIds {
22
+ return {
23
+ base,
24
+ label: `${base}-label`,
25
+ listbox: `${base}-listbox`,
26
+ option: (n: number) => `${base}-option-${n}`,
27
+ error: `${base}-error`,
28
+ announcer: `${base}-announcer`,
29
+ };
30
+ }
@@ -0,0 +1 @@
1
+ /// <reference types="vite/client" />
@@ -0,0 +1,145 @@
1
+ import {
2
+ defineComponent,
3
+ h,
4
+ onBeforeUnmount,
5
+ onMounted,
6
+ ref,
7
+ shallowRef,
8
+ watch,
9
+ type PropType,
10
+ } from 'vue';
11
+ import {
12
+ Taga11y,
13
+ type Taga11yOptions,
14
+ type SuggestionsSource,
15
+ type SuggestionItem,
16
+ type TagData,
17
+ } from 'taga11y';
18
+
19
+ const OPTION_KEYS = [
20
+ 'suggestions',
21
+ 'maxTags',
22
+ 'delimiter',
23
+ 'enforceSuggestions',
24
+ 'name',
25
+ 'label',
26
+ 'disabled',
27
+ 'theme',
28
+ 'serialize',
29
+ 'deserialize',
30
+ 'debounceMs',
31
+ 'i18n',
32
+ ] as const satisfies readonly (keyof Taga11yOptions)[];
33
+
34
+ function valuesEqual(a: readonly string[], b: readonly string[]): boolean {
35
+ return a.length === b.length && a.every((v, i) => v === b[i]);
36
+ }
37
+
38
+ /**
39
+ * Accessibility-first tagging input for Vue 3.
40
+ *
41
+ * A render-function component (no SFC) wrapping the vanilla {@link Taga11y}
42
+ * class. Supports `v-model` (controlled) and `defaultValue` (uncontrolled);
43
+ * emits every core event; exposes the instance via `expose()`. Mounts
44
+ * client-only so the document-touching constructor is SSR-safe.
45
+ */
46
+ export const Taga11yInput = defineComponent({
47
+ name: 'Taga11yInput',
48
+ props: {
49
+ /** Controlled tag values (v-model). When provided, the parent owns state. */
50
+ modelValue: { type: Array as PropType<string[]>, default: undefined },
51
+ /** Uncontrolled initial tag values. */
52
+ defaultValue: { type: Array as PropType<string[]>, default: undefined },
53
+ suggestions: { type: [Array, Object] as PropType<SuggestionsSource>, default: undefined },
54
+ maxTags: { type: Number, default: undefined },
55
+ delimiter: { type: [String, Array] as PropType<string | string[]>, default: undefined },
56
+ enforceSuggestions: { type: Boolean, default: undefined },
57
+ name: { type: String, default: undefined },
58
+ label: { type: String, default: undefined },
59
+ disabled: { type: Boolean, default: undefined },
60
+ theme: { type: String as PropType<'dark' | 'light' | null>, default: undefined },
61
+ serialize: { type: Function as PropType<(tags: TagData[]) => string>, default: undefined },
62
+ deserialize: {
63
+ type: Function as PropType<(raw: string) => SuggestionItem[]>,
64
+ default: undefined,
65
+ },
66
+ debounceMs: { type: Number, default: undefined },
67
+ i18n: { type: Object as PropType<Taga11yOptions['i18n']>, default: undefined },
68
+ },
69
+ emits: ['update:modelValue', 'add', 'remove', 'clear', 'change', 'paste', 'destroy'],
70
+ setup(props, { emit, expose }) {
71
+ const elRef = ref<HTMLInputElement | null>(null);
72
+ const instance = shallowRef<Taga11y | null>(null);
73
+
74
+ function buildOptions(): Taga11yOptions {
75
+ const options: Taga11yOptions = {};
76
+ for (const key of OPTION_KEYS) {
77
+ const value = props[key];
78
+ if (value !== undefined) (options as Record<string, unknown>)[key] = value;
79
+ }
80
+ return options;
81
+ }
82
+
83
+ onMounted(() => {
84
+ const el = elRef.value;
85
+ if (!el) return;
86
+ const inst = new Taga11y(el, buildOptions());
87
+ instance.value = inst;
88
+
89
+ // Apply initial value before listeners attach → no spurious change emit.
90
+ const initial = props.modelValue ?? props.defaultValue;
91
+ if (initial && initial.length > 0) inst.setTags(initial);
92
+
93
+ inst.on('taga11y:change', (event) => {
94
+ emit('change', event.detail);
95
+ emit(
96
+ 'update:modelValue',
97
+ event.detail.tags.map((t) => t.value),
98
+ );
99
+ });
100
+ inst.on('taga11y:add', (event) => emit('add', event.detail));
101
+ inst.on('taga11y:remove', (event) => emit('remove', event.detail));
102
+ inst.on('taga11y:clear', (event) => emit('clear', event.detail));
103
+ inst.on('taga11y:paste', (event) => emit('paste', event.detail));
104
+ inst.on('taga11y:destroy', (event) => emit('destroy', event.detail));
105
+ });
106
+
107
+ onBeforeUnmount(() => {
108
+ instance.value?.destroy(); // fires taga11y:destroy → emits 'destroy'
109
+ instance.value = null;
110
+ });
111
+
112
+ // Controlled value reconciliation, guarded against feedback loops.
113
+ watch(
114
+ () => props.modelValue,
115
+ (val) => {
116
+ const inst = instance.value;
117
+ if (val === undefined || !inst) return;
118
+ const current = inst.getTags().map((t) => t.value);
119
+ if (!valuesEqual(current, val)) inst.setTags(val);
120
+ },
121
+ );
122
+
123
+ // Forward changed option props via settings() without reconstructing.
124
+ const prev: Partial<Record<keyof Taga11yOptions, unknown>> = {};
125
+ for (const key of OPTION_KEYS) prev[key] = props[key];
126
+ watch(
127
+ () => OPTION_KEYS.map((key) => props[key]),
128
+ () => {
129
+ const inst = instance.value;
130
+ if (!inst) return;
131
+ const changed: Partial<Taga11yOptions> = {};
132
+ for (const key of OPTION_KEYS) {
133
+ if (key === 'i18n') continue; // init-only; settings() warns + ignores
134
+ if (props[key] !== prev[key]) (changed as Record<string, unknown>)[key] = props[key];
135
+ prev[key] = props[key];
136
+ }
137
+ if (Object.keys(changed).length > 0) inst.settings(changed);
138
+ },
139
+ );
140
+
141
+ expose({ instance });
142
+
143
+ return () => h('input', { ref: elRef });
144
+ },
145
+ });