@gcforms/tag-input 0.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.0.0] - 2025-05-14
9
+
10
+ ### Added
11
+
12
+ - Initial release
package/package.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "@gcforms/tag-input",
3
+ "version": "0.0.0",
4
+ "author": "Canadian Digital Service",
5
+ "license": "MIT",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "packageManager": "yarn@4.6.0",
10
+ "peerDependencies": {
11
+ "react": "^19.1.0",
12
+ "react-dom": "^19.1.0"
13
+ }
14
+ }
@@ -0,0 +1,269 @@
1
+ import { useRef, useState } from "react";
2
+ import { CancelIcon } from "./icons/CancelIcon";
3
+ import "./styles.css";
4
+ import { useTranslation } from "./i18n/useTranslation";
5
+ import { WarningIcon } from "./icons/WarningIcon";
6
+
7
+ const keys = {
8
+ ENTER: "Enter",
9
+ ESC: "Escape",
10
+ ARROW_LEFT: "ArrowLeft",
11
+ ARROW_RIGHT: "ArrowRight",
12
+ TAB: "Tab",
13
+ DELETE: "Delete",
14
+ BACKSPACE: "Backspace",
15
+ COMMA: ",",
16
+ SPACE: " ",
17
+ SEMICOLON: ";",
18
+ };
19
+
20
+ export const TagInput = ({
21
+ initialTags,
22
+ name = "tag-input",
23
+ id = "tag-input",
24
+ label = "Tags",
25
+ locale = "en",
26
+ placeholder,
27
+ description,
28
+ restrictDuplicates = true,
29
+ maxTags,
30
+ onTagAdd,
31
+ onTagRemove,
32
+ validateTag,
33
+ }: {
34
+ initialTags?: string[];
35
+ name?: string;
36
+ id?: string;
37
+ label?: string;
38
+ locale?: string;
39
+ placeholder?: string;
40
+ description?: string;
41
+ restrictDuplicates?: boolean;
42
+ maxTags?: number;
43
+ onTagAdd?: (tag: string) => void;
44
+ onTagRemove?: (tag: string) => void;
45
+ validateTag?: (tag: string) => {
46
+ isValid: boolean;
47
+ errors?: string[];
48
+ };
49
+ }) => {
50
+ const tagInputRef = useRef<HTMLInputElement>(null);
51
+ const [selectedTags, setSelectedTags] = useState<string[]>(initialTags || []);
52
+ const [selectedTagIndex, setSelectedTagIndex] = useState<number | null>(null);
53
+ const [errorMessages, setErrorMessages] = useState<string[]>([]);
54
+ const [ariaLiveRegionText, setAriaLiveRegionText] = useState<string | null>(null);
55
+ const { t } = useTranslation(locale);
56
+
57
+ const say = (phrase: string) => {
58
+ setAriaLiveRegionText(phrase);
59
+ };
60
+
61
+ const resetMessages = () => {
62
+ setErrorMessages([]);
63
+ };
64
+
65
+ const handleAddTag = (tag: string) => {
66
+ resetMessages();
67
+
68
+ if (maxTags && selectedTags.length >= maxTags) {
69
+ // Announce max tags reached
70
+ say(t("maxTagsReached", { maxTags: maxTags.toString() }));
71
+
72
+ // Display a validation error
73
+ setErrorMessages([t("maxTagsReached", { max: maxTags.toString() })]);
74
+ return;
75
+ }
76
+
77
+ if (validateTag) {
78
+ const { isValid, errors } = validateTag(tag);
79
+ if (!isValid) {
80
+ // Announce invalid tag
81
+ say(t("announceInvalidTag", { tag }));
82
+
83
+ // Display validation errors
84
+ setErrorMessages(errors || []);
85
+ return;
86
+ }
87
+ }
88
+
89
+ if (restrictDuplicates && selectedTags.includes(tag)) {
90
+ // Announce duplicate tag
91
+ say(t("announceDuplicateTag", { tag }));
92
+
93
+ // Highlight the duplicate tag momentarily
94
+ const duplicateTagIndex = selectedTags.indexOf(tag);
95
+ const duplicateTagElement = document.getElementById(`tag-${duplicateTagIndex}`);
96
+ if (duplicateTagElement) {
97
+ duplicateTagElement.classList.add("duplicate");
98
+ setTimeout(() => {
99
+ duplicateTagElement.classList.remove("duplicate");
100
+ }, 4000); // Remove the class after 4 seconds
101
+ }
102
+
103
+ // Display a validation error
104
+ setErrorMessages([t("duplicateTag", { tag })]);
105
+ return;
106
+ }
107
+
108
+ // Fire onTagAdd callback
109
+ if (onTagAdd) {
110
+ onTagAdd(tag);
111
+ }
112
+
113
+ // Announce tag added
114
+ say(t("announceTagAdded", { tag }));
115
+
116
+ // Add the tag to the selected tags
117
+ setSelectedTags((prevTags) => [...prevTags, tag]);
118
+ };
119
+
120
+ const handleRemoveTag = (index: number) => {
121
+ const tag = selectedTags[index];
122
+
123
+ // Fire onTagRemove callback
124
+ if (onTagRemove) {
125
+ onTagRemove(tag);
126
+ }
127
+
128
+ // Announce tag removed
129
+ say(t("announceTagRemoved", { tag }));
130
+
131
+ // Remove the tag from the selected tags
132
+ setSelectedTags((prevTags) => prevTags.filter((_, i) => i !== index));
133
+ };
134
+
135
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
136
+ resetMessages();
137
+
138
+ const { key } = event;
139
+ const acceptKeys = [keys.ENTER, keys.TAB, keys.COMMA];
140
+ const navigateKeys = [keys.ARROW_LEFT, keys.ARROW_RIGHT];
141
+
142
+ // Clear selection when entering text input
143
+ if (!navigateKeys.includes(key) && selectedTagIndex !== null) {
144
+ setSelectedTagIndex(null);
145
+ }
146
+
147
+ // Accept tag input
148
+ if (acceptKeys.includes(key)) {
149
+ const tag = event.currentTarget.value.trim();
150
+ if (tag) {
151
+ event.preventDefault();
152
+ handleAddTag(tag);
153
+ event.currentTarget.value = "";
154
+ }
155
+ }
156
+
157
+ // Delete selected tag
158
+ if (key === keys.DELETE && selectedTagIndex !== null) {
159
+ handleRemoveTag(selectedTagIndex);
160
+ setSelectedTagIndex(null);
161
+ return;
162
+ }
163
+
164
+ // Backspace key handling
165
+ if (key === keys.BACKSPACE) {
166
+ // If a tag is selected, delete it
167
+ if (selectedTagIndex !== null) {
168
+ handleRemoveTag(selectedTagIndex);
169
+ setSelectedTagIndex(null);
170
+ return;
171
+ }
172
+
173
+ // If the input is empty, delete the last tag
174
+ if (event.currentTarget.value === "") {
175
+ if (selectedTags.length) {
176
+ handleRemoveTag(selectedTags.length - 1);
177
+ event.currentTarget.value = "";
178
+ }
179
+ }
180
+ }
181
+
182
+ // Roving through tags with arrow keys
183
+ if (event.currentTarget.value === "") {
184
+ if (key === keys.ARROW_LEFT) {
185
+ if (selectedTags.length) {
186
+ let newTagIndex = 0;
187
+ if (selectedTagIndex === null) {
188
+ newTagIndex = selectedTags.length - 1;
189
+ } else if (selectedTagIndex > 0) {
190
+ newTagIndex = selectedTagIndex - 1;
191
+ }
192
+ setSelectedTagIndex(newTagIndex);
193
+ say(t("announceTagSelected", { tag: selectedTags[newTagIndex] }));
194
+ }
195
+ }
196
+
197
+ if (key === keys.ARROW_RIGHT) {
198
+ if (selectedTags.length) {
199
+ if (selectedTagIndex === null) {
200
+ setSelectedTagIndex(0);
201
+ } else if (selectedTagIndex < selectedTags.length - 1) {
202
+ setSelectedTagIndex(selectedTagIndex + 1);
203
+ say(t("announceTagSelected", { tag: selectedTags[selectedTagIndex + 1] }));
204
+ }
205
+ }
206
+ }
207
+ }
208
+ };
209
+
210
+ return (
211
+ <div className="gc-tag-input-container" onClick={() => tagInputRef.current?.focus()}>
212
+ <label htmlFor={id} className="gc-tag-input-label">
213
+ {label}
214
+ </label>
215
+ {description && <p className="gc-tag-input-description">{description}</p>}
216
+ <span id="input-instructions" aria-live="polite" className="visually-hidden">
217
+ {t("inputLabel", { tags: selectedTags.length.toString() })}
218
+ </span>
219
+ <div className="gc-tag-input">
220
+ {selectedTags.map((tag, index) => (
221
+ <div
222
+ key={`${tag}-${index}`}
223
+ id={`tag-${index}`}
224
+ className={`gc-tag ${selectedTagIndex === index ? "gc-selected-tag" : ""}`}
225
+ >
226
+ <div>{tag}</div>
227
+ <button type="button" onClick={() => handleRemoveTag(index)}>
228
+ <CancelIcon />
229
+ </button>
230
+ </div>
231
+ ))}
232
+
233
+ <input
234
+ aria-describedby="input-instructions"
235
+ data-testid="tag-input"
236
+ id={id}
237
+ name={name}
238
+ type="text"
239
+ placeholder={placeholder}
240
+ onKeyDown={handleKeyDown}
241
+ ref={tagInputRef}
242
+ />
243
+ <span
244
+ id="tag-input-live-region"
245
+ className="gc-visually-hidden"
246
+ role="alert"
247
+ aria-live="assertive"
248
+ aria-atomic="true"
249
+ >
250
+ {ariaLiveRegionText}
251
+ </span>
252
+ </div>
253
+ <div
254
+ role="alert"
255
+ aria-live="assertive"
256
+ className="gc-tag-input-error"
257
+ data-testid="tag-input-error"
258
+ >
259
+ {errorMessages.length > 0 &&
260
+ errorMessages.map((error, index) => (
261
+ <div key={`error-${index}`}>
262
+ <WarningIcon />
263
+ {error}
264
+ </div>
265
+ ))}
266
+ </div>
267
+ </div>
268
+ );
269
+ };
@@ -0,0 +1,10 @@
1
+ {
2
+ "announceDuplicateTag": "\"{tag}\" is a duplicate tag",
3
+ "announceInvalidTag": "Invalid tag: \"{tag}\"",
4
+ "announceTagAdded": "Tag \"{tag}\" added",
5
+ "announceTagRemoved": "Tag \"{tag}\" removed",
6
+ "announceTagSelected": "Tag \"{tag}\" selected",
7
+ "duplicateTag": "Tag already added",
8
+ "inputLabel": "{tags} tags. Use Enter, Tab, or a comma to create a tag. Use left and right arrow keys to navigate tags. Use Backspace or Delete to remove a tag.",
9
+ "maxTagsReached": "Maximum number of tags reached ({max})"
10
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "announceDuplicateTag": "\"{tag}\" est une balise dupliquée",
3
+ "announceInvalidTag": "Balise invalide : \"{tag}\"",
4
+ "announceTagAdded": "Balise \"{tag}\" ajoutée",
5
+ "announceTagRemoved": "Balise \"{tag}\" supprimée",
6
+ "announceTagSelected": "Tag \"{tag}\" sélectionnée",
7
+ "duplicateTag": "La balise a déjà été ajoutée",
8
+ "inputLabel": "Balises {tags}. Utilisez Entrée, la touche Tab ou une virgule pour créer une balise. Utilisez les touches fléchées gauche et droite pour naviguer dans les balises. Utilisez le retour en arrière ou Supprimer pour enlever une balise.",
9
+ "maxTagsReached": "Le nombre maximal de balises a été atteint ({max})"
10
+ }
@@ -0,0 +1,12 @@
1
+ import en from "./en.json";
2
+ import fr from "./fr.json";
3
+
4
+ const translations = {
5
+ en,
6
+ fr,
7
+ };
8
+
9
+ export default translations;
10
+ export type Language = keyof typeof translations;
11
+ export type TranslationKey = keyof typeof translations.en;
12
+ export type Translations = typeof translations.en;
@@ -0,0 +1,32 @@
1
+ import { useCallback } from "react";
2
+ import type { Translations } from "../i18n";
3
+ import type { TranslationKey } from "../i18n";
4
+ import translations, { Language } from "../i18n";
5
+
6
+ export const useTranslation = (initialLocale: string) => {
7
+ let locale = initialLocale as Language;
8
+
9
+ if (!translations[initialLocale as Language]) {
10
+ // eslint-disable-next-line no-console
11
+ console.warn(`The locale "${initialLocale}" is not supported. Defaulting to "en".`);
12
+ locale = "en";
13
+ }
14
+
15
+ const t = useCallback(
16
+ (key: TranslationKey, placeholders?: Record<string, string>): string => {
17
+ const langTranslations = translations[locale] as Translations;
18
+ let translation = langTranslations[key] || key;
19
+
20
+ if (placeholders) {
21
+ Object.entries(placeholders).forEach(([placeholder, value]) => {
22
+ translation = translation.replace(new RegExp(`{${placeholder}}`, "g"), value);
23
+ });
24
+ }
25
+
26
+ return translation;
27
+ },
28
+ [locale]
29
+ );
30
+
31
+ return { t, locale };
32
+ };
@@ -0,0 +1,15 @@
1
+ export const CancelIcon = ({ className, title }: { className?: string; title?: string }) => (
2
+ <svg
3
+ xmlns="http://www.w3.org/2000/svg"
4
+ height="24"
5
+ width="24"
6
+ className={className}
7
+ viewBox="0 0 24 24"
8
+ focusable="false"
9
+ aria-hidden={title ? false : true}
10
+ role={title ? "img" : "presentation"}
11
+ >
12
+ {title && <title>{title}</title>}
13
+ <path d="m8.4 17 3.6-3.6 3.6 3.6 1.4-1.4-3.6-3.6L17 8.4 15.6 7 12 10.6 8.4 7 7 8.4l3.6 3.6L7 15.6Zm3.6 5q-2.075 0-3.9-.788-1.825-.787-3.175-2.137-1.35-1.35-2.137-3.175Q2 14.075 2 12t.788-3.9q.787-1.825 2.137-3.175 1.35-1.35 3.175-2.138Q9.925 2 12 2t3.9.787q1.825.788 3.175 2.138 1.35 1.35 2.137 3.175Q22 9.925 22 12t-.788 3.9q-.787 1.825-2.137 3.175-1.35 1.35-3.175 2.137Q14.075 22 12 22Zm0-2q3.35 0 5.675-2.325Q20 15.35 20 12q0-3.35-2.325-5.675Q15.35 4 12 4 8.65 4 6.325 6.325 4 8.65 4 12q0 3.35 2.325 5.675Q8.65 20 12 20Zm0-8Z" />
14
+ </svg>
15
+ );
@@ -0,0 +1,16 @@
1
+ export const WarningIcon = ({ className, title }: { className?: string; title?: string }) => (
2
+ <svg
3
+ xmlns="http://www.w3.org/2000/svg"
4
+ height="24"
5
+ width="24"
6
+ className={className}
7
+ viewBox="0 96 960 960"
8
+ focusable="false"
9
+ aria-hidden={title ? false : true}
10
+ role={title ? "img" : "presentation"}
11
+ data-testid="WarningIcon"
12
+ >
13
+ {title && <title>{title}</title>}
14
+ <path d="m40 936 440-760 440 760H40Zm138-80h604L480 336 178 856Zm302-40q17 0 28.5-11.5T520 776q0-17-11.5-28.5T480 736q-17 0-28.5 11.5T440 776q0 17 11.5 28.5T480 816Zm-40-120h80V496h-80v200Zm40-100Z" />
15
+ </svg>
16
+ );
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { TagInput } from "./TagInput";
package/src/styles.css ADDED
@@ -0,0 +1,99 @@
1
+ :root {
2
+ --gc-tag-input-border-color: #4338ca;
3
+ --gc-tag-input-background-color: #eef2ff;
4
+ --gc-tag-input-text-color: #374151;
5
+ --gc-tag-border-color: #000000;
6
+ --gc-tag-duplicate-bg-color: #fef2f2;
7
+ --gc-tag-duplicate-text-color: #d3080c;
8
+ --gc-tag-selected-bg-color: #ffffff;
9
+ --gc-tag-selected-text-color: #1e293b;
10
+ --gc-error-text-color: #b91c1c;
11
+ }
12
+
13
+ .gc-tag-input {
14
+ display: flex;
15
+ padding: 0.5rem;
16
+ flex-wrap: wrap;
17
+ gap: 0.5rem;
18
+ border-width: 1px;
19
+ border-color: var(--gc-tag-border-color);
20
+ border-radius: 0.375rem;
21
+
22
+ .gc-tag {
23
+ display: flex;
24
+ flex-direction: row;
25
+ padding-left: 0.5rem;
26
+ padding-right: 0.5rem;
27
+ gap: 0.25rem;
28
+ align-items: center;
29
+ border-radius: 0.375rem;
30
+ border: 1px solid var(--gc-tag-input-border-color);
31
+ background-color: var(--gc-tag-input-background-color);
32
+ color: var(--gc-tag-input-text-color);
33
+ svg {
34
+ width: 1rem;
35
+ height: 1rem;
36
+ }
37
+ &.duplicate {
38
+ background-color: var(--gc-tag-duplicate-bg-color);
39
+ color: var(--gc-tag-duplicate-text-color);
40
+ border-color: var(--gc-tag-duplicate-text-color);
41
+ svg {
42
+ color: var(--gc-tag-duplicate-text-color);
43
+ fill: var(--gc-tag-duplicate-text-color);
44
+ }
45
+ }
46
+ &.gc-selected-tag {
47
+ background-color: var(--gc-tag-selected-bg-color);
48
+ color: var(--gc-tag-selected-text-color);
49
+ border-color: var(--gc-tag-selected-text-color);
50
+ svg {
51
+ color: var(--gc-tag-selected-text-color);
52
+ fill: var(--gc-tag-selected-text-color);
53
+ }
54
+ }
55
+ }
56
+
57
+ input {
58
+ display: inline-block;
59
+ padding-top: 0.25rem;
60
+ padding-bottom: 0.25rem;
61
+ padding-left: 0.5rem;
62
+ padding-right: 0.5rem;
63
+ border-style: none;
64
+ outline-style: none;
65
+ }
66
+ }
67
+
68
+ .gc-tag-input-label {
69
+ font-weight: 700;
70
+ margin-bottom: 0.5rem;
71
+ display: block;
72
+ }
73
+
74
+ .gc-tag-input-description {
75
+ margin-top: 0.25rem;
76
+ margin-bottom: 0.5rem;
77
+ }
78
+
79
+ .gc-tag-input-error {
80
+ font-weight: 700;
81
+ margin-top: 1rem;
82
+ color: var(--gc-error-text-color);
83
+ svg {
84
+ color: var(--gc-error-text-color);
85
+ fill: var(--gc-error-text-color);
86
+ display: inline-block;
87
+ margin-right: 0.25rem;
88
+ }
89
+ }
90
+
91
+ .gc-visually-hidden {
92
+ clip: rect(0 0 0 0);
93
+ clip-path: inset(50%);
94
+ height: 1px;
95
+ overflow: hidden;
96
+ position: absolute;
97
+ white-space: nowrap;
98
+ width: 1px;
99
+ }
@@ -0,0 +1,239 @@
1
+ "use client";
2
+ import React from "react";
3
+
4
+ import { TagInput } from "../TagInput";
5
+
6
+ describe("<TagInput />", () => {
7
+ it("renders without crashing", () => {
8
+ cy.mount(
9
+ <div>
10
+ <TagInput initialTags={[]} />
11
+ </div>
12
+ );
13
+ });
14
+
15
+ it("accepts initial tags", () => {
16
+ cy.mount(
17
+ <div>
18
+ <TagInput
19
+ initialTags={["Tag one", "Tag two", "Tag three"]}
20
+ onTagAdd={() => {}}
21
+ onTagRemove={() => {}}
22
+ />
23
+ </div>
24
+ );
25
+
26
+ cy.get(".gc-tag").should("have.length", 3);
27
+ cy.get(".gc-tag").should("contain", "Tag one");
28
+ cy.get(".gc-tag").should("contain", "Tag two");
29
+ cy.get(".gc-tag").should("contain", "Tag three");
30
+ });
31
+
32
+ it("sets the name attribute", () => {
33
+ cy.mount(
34
+ <div>
35
+ <TagInput initialTags={[]} name="test-name" />
36
+ </div>
37
+ );
38
+
39
+ cy.get("[data-testid='tag-input']").should("have.attr", "name", "test-name");
40
+ });
41
+
42
+ it("adds a custom label", () => {
43
+ cy.mount(
44
+ <div>
45
+ <TagInput initialTags={[]} label="Custom Label" />
46
+ </div>
47
+ );
48
+
49
+ cy.get(".gc-tag-input-label").should("contain", "Custom Label");
50
+ });
51
+
52
+ it("adds a custom description", () => {
53
+ cy.mount(
54
+ <div>
55
+ <TagInput initialTags={[]} description="Custom Description" />
56
+ </div>
57
+ );
58
+
59
+ cy.get(".gc-tag-input-description").should("contain", "Custom Description");
60
+ });
61
+
62
+ it("adds a tag", () => {
63
+ cy.mount(
64
+ <div>
65
+ <TagInput initialTags={[]} />
66
+ </div>
67
+ );
68
+
69
+ cy.get("[data-testid='tag-input']").type("New Tag{enter}");
70
+ cy.get("[data-testid='tag-input']").should("have.value", "");
71
+ cy.get(".gc-tag").should("contain", "New Tag");
72
+ });
73
+
74
+ it("announces that a tag was added", () => {
75
+ cy.mount(
76
+ <div>
77
+ <TagInput initialTags={[]} />
78
+ </div>
79
+ );
80
+ cy.get("[data-testid='tag-input']").type("New Tag{enter}");
81
+ cy.get("#tag-input-live-region").should("exist").and("contain", `Tag "New Tag" added`);
82
+ });
83
+
84
+ it("restricts duplicates", () => {
85
+ cy.mount(
86
+ <div>
87
+ <TagInput initialTags={["Tag 1"]} restrictDuplicates={true} />
88
+ </div>
89
+ );
90
+
91
+ cy.get("[data-testid='tag-input']").type("Tag 1{enter}");
92
+ cy.get("[data-testid='tag-input']").should("have.value", "");
93
+ cy.get(".gc-tag").should("have.length", 1);
94
+ });
95
+
96
+ it("announces that a duplicate tag was added", () => {
97
+ cy.mount(
98
+ <div>
99
+ <TagInput restrictDuplicates={true} />
100
+ </div>
101
+ );
102
+
103
+ cy.get("[data-testid='tag-input']").type("Tag 1{enter}Tag 1{enter}");
104
+ cy.get("#tag-input-live-region").should("exist").and("contain", `"Tag 1" is a duplicate tag`);
105
+ });
106
+
107
+ it("allows duplicates", () => {
108
+ cy.mount(
109
+ <div>
110
+ <TagInput initialTags={["Tag 1"]} restrictDuplicates={false} />
111
+ </div>
112
+ );
113
+
114
+ cy.get("[data-testid='tag-input']").type("Tag 1{enter}");
115
+ cy.get("[data-testid='tag-input']").should("have.value", "");
116
+ cy.get(".gc-tag").should("have.length", 2);
117
+ });
118
+
119
+ it("removes a tag", () => {
120
+ const onTagRemove = cy.stub().as("onTagRemove");
121
+
122
+ cy.mount(
123
+ <div>
124
+ <TagInput initialTags={["Tag 1", "Tag 2"]} onTagAdd={() => {}} onTagRemove={onTagRemove} />
125
+ </div>
126
+ );
127
+
128
+ cy.get(".gc-tag").should("contain", "Tag 2").should("contain", "Tag 1");
129
+ cy.get("#tag-0 button").click();
130
+ cy.get(".gc-tag").should("contain", "Tag 2").should("not.contain", "Tag 1");
131
+ cy.get("@onTagRemove").should("have.been.calledWith", "Tag 1");
132
+ });
133
+
134
+ it("removes a selected tag", () => {
135
+ cy.mount(
136
+ <div>
137
+ <TagInput initialTags={["one", "two", "three", "four", "five", "six"]} />
138
+ </div>
139
+ );
140
+
141
+ cy.get("[data-testid='tag-input']").type("{leftarrow}{leftarrow}{leftarrow}{leftarrow}{del}");
142
+ cy.get("#tag-input-live-region").should("exist").and("contain", `Tag "three" removed`);
143
+ cy.get(".gc-tag").should("not.contain", "three");
144
+ });
145
+
146
+ it("announces when a tag is removed", () => {
147
+ cy.mount(
148
+ <div>
149
+ <TagInput initialTags={["Tag 1"]} onTagAdd={() => {}} onTagRemove={() => {}} />
150
+ </div>
151
+ );
152
+
153
+ cy.get(".gc-tag").should("contain", "Tag 1");
154
+ cy.get(".gc-tag button").click();
155
+ cy.get("#tag-input-live-region").should("exist").and("contain", `Tag "Tag 1" removed`);
156
+ });
157
+
158
+ it("calls onTagAdd handler when adding a tag", () => {
159
+ const onTagAdd = cy.stub().as("onTagAdd");
160
+
161
+ cy.mount(
162
+ <div>
163
+ <TagInput initialTags={[]} onTagAdd={onTagAdd} onTagRemove={() => {}} />
164
+ </div>
165
+ );
166
+
167
+ cy.get("[data-testid='tag-input']").type("New Tag{enter}");
168
+ cy.get("[data-testid='tag-input']").should("have.value", "");
169
+ cy.get(".gc-tag").should("contain", "New Tag");
170
+ cy.get("@onTagAdd").should("have.been.calledWith", "New Tag");
171
+ });
172
+
173
+ it("calls onTagRemove handler when removing a tag", () => {
174
+ const onTagRemove = cy.stub().as("onTagRemove");
175
+
176
+ cy.mount(
177
+ <div>
178
+ <TagInput initialTags={["Tag one", "Tag two"]} onTagRemove={onTagRemove} />
179
+ </div>
180
+ );
181
+
182
+ cy.get(".gc-tag").should("contain", "Tag one");
183
+ cy.get(".gc-tag").first().find("button").click();
184
+ cy.get("[data-testid='tag-input']").should("not.contain", "Tag one");
185
+ cy.get("@onTagRemove").should("have.been.calledWith", "Tag one");
186
+ });
187
+
188
+ it("validates the tag according to the validation function", () => {
189
+ const validateTag = (tag: string) => {
190
+ const errors: string[] = [];
191
+
192
+ if (tag.length < 3) {
193
+ errors.push("Tag must be at least 3 characters long");
194
+ }
195
+
196
+ if (/\d/.test(tag)) {
197
+ errors.push("Tag must not include numbers");
198
+ }
199
+
200
+ if (tag.length > 10) {
201
+ errors.push("Tag must be at most 10 characters long");
202
+ }
203
+
204
+ return {
205
+ isValid: errors.length === 0,
206
+ errors: errors,
207
+ };
208
+ };
209
+
210
+ cy.mount(
211
+ <div>
212
+ <TagInput validateTag={validateTag} />
213
+ </div>
214
+ );
215
+
216
+ cy.get("[data-testid='tag-input']").type("ab{enter}");
217
+ cy.get("[data-testid='tag-input-error'] div").should("have.length", 1);
218
+ cy.get("[data-testid='tag-input-error']").should(
219
+ "contain",
220
+ "Tag must be at least 3 characters long"
221
+ );
222
+
223
+ cy.get("[data-testid='tag-input']").type("abcdefghijklmnopqrstuvwxy{enter}");
224
+ cy.get("[data-testid='tag-input-error'] div").should("have.length", 1);
225
+ cy.get("[data-testid='tag-input-error']").should(
226
+ "contain",
227
+ "Tag must be at most 10 characters long"
228
+ );
229
+
230
+ // Multiple errors
231
+ cy.get("[data-testid='tag-input']").type("T1{enter}");
232
+ cy.get("[data-testid='tag-input-error'] div").should("have.length", 2);
233
+ cy.get("[data-testid='tag-input-error']").should(
234
+ "contain",
235
+ "Tag must be at least 3 characters long"
236
+ );
237
+ cy.get("[data-testid='tag-input-error']").should("contain", "Tag must not include numbers");
238
+ });
239
+ });