@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 +12 -0
- package/package.json +14 -0
- package/src/TagInput.tsx +269 -0
- package/src/i18n/en.json +10 -0
- package/src/i18n/fr.json +10 -0
- package/src/i18n/index.tsx +12 -0
- package/src/i18n/useTranslation.tsx +32 -0
- package/src/icons/CancelIcon.tsx +15 -0
- package/src/icons/WarningIcon.tsx +16 -0
- package/src/index.ts +1 -0
- package/src/styles.css +99 -0
- package/src/tests/TagInput.cy.tsx +239 -0
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
|
+
}
|
package/src/TagInput.tsx
ADDED
|
@@ -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
|
+
};
|
package/src/i18n/en.json
ADDED
|
@@ -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
|
+
}
|
package/src/i18n/fr.json
ADDED
|
@@ -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
|
+
});
|