@sproutsocial/seeds-react-token-input 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.
@@ -0,0 +1,46 @@
1
+ import React from "react";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { VisuallyHidden } from "@sproutsocial/seeds-react-visually-hidden";
4
+ import type { TypeTokenInputProps } from "./";
5
+
6
+ function usePrevious(value: TypeTokenInputProps["tokens"]) {
7
+ const ref = useRef<TypeTokenInputProps["tokens"]>();
8
+
9
+ useEffect(() => {
10
+ ref.current = value;
11
+ });
12
+
13
+ return ref.current;
14
+ }
15
+
16
+ export const TokenScreenReaderStatus = ({
17
+ tokens,
18
+ }: {
19
+ tokens: TypeTokenInputProps["tokens"];
20
+ }) => {
21
+ const prevTokens = usePrevious(tokens);
22
+ const [tokenStatus, setTokenStatus] = useState("");
23
+
24
+ // TODO: Use callbacks so consumers can pass localized messaging to the screen reader
25
+ useEffect(() => {
26
+ if (prevTokens && tokens) {
27
+ if (prevTokens.length > tokens.length) {
28
+ setTokenStatus(
29
+ `${
30
+ prevTokens.filter((item) => tokens.indexOf(item) === -1)[0]?.value
31
+ } has been removed`
32
+ );
33
+ }
34
+
35
+ if (prevTokens.length < tokens.length) {
36
+ setTokenStatus(`${tokens[tokens.length - 1]?.value} has been added.`);
37
+ }
38
+ }
39
+ }, [prevTokens, tokens, tokenStatus]);
40
+
41
+ return (
42
+ <VisuallyHidden as="div" role="status">
43
+ {tokenStatus}
44
+ </VisuallyHidden>
45
+ );
46
+ };
@@ -0,0 +1,672 @@
1
+ /* eslint-disable testing-library/no-render-in-setup */
2
+ import React from "react";
3
+ import {
4
+ render,
5
+ screen,
6
+ type UserEvent,
7
+ } from "@sproutsocial/seeds-react-testing-library";
8
+ import TokenInput from "../TokenInput";
9
+
10
+ describe("When clicking on...", () => {
11
+ describe("...a token", () => {
12
+ test("the token should be removed", async () => {
13
+ const mockOnAdd = jest.fn();
14
+ const mockOnRemove = jest.fn();
15
+ const { user } = render(
16
+ <TokenInput
17
+ id="0"
18
+ name="token-input"
19
+ tokens={[
20
+ {
21
+ id: "0",
22
+ value: "you",
23
+ },
24
+ {
25
+ id: "1",
26
+ value: "are",
27
+ },
28
+ {
29
+ id: "2",
30
+ value: "beautiful",
31
+ },
32
+ ]}
33
+ onAddToken={mockOnAdd}
34
+ onRemoveToken={mockOnRemove}
35
+ />
36
+ );
37
+ const token = screen.getByText("are");
38
+ await user.click(token);
39
+ const firstArgs = mockOnRemove.mock.calls[0];
40
+ expect(mockOnRemove).toBeCalledTimes(1);
41
+ expect(firstArgs).toEqual(["1"]);
42
+ });
43
+ });
44
+ });
45
+
46
+ describe("When deleting...", () => {
47
+ describe("...in an empty input", () => {
48
+ it("should do nothing", async () => {
49
+ const mockCallback = jest.fn();
50
+ const { user } = render(
51
+ <TokenInput
52
+ id="0"
53
+ name="token-input"
54
+ placeholder="Enter text"
55
+ onRemoveToken={mockCallback}
56
+ />
57
+ );
58
+ const input = screen.getByPlaceholderText("Enter text");
59
+ await user.type(input, "{backspace}");
60
+ expect(mockCallback).toHaveBeenCalledTimes(0);
61
+ });
62
+ });
63
+
64
+ describe("...in an input with text", () => {
65
+ it("should delete the last character of text", async () => {
66
+ const { user } = render(
67
+ <TokenInput id="0" name="token-input" placeholder="Enter text" />
68
+ );
69
+ const input = screen.getByPlaceholderText("Enter text");
70
+ await user.type(input, "Hello World");
71
+ await user.type(input, "{backspace}");
72
+ expect(input).toHaveValue("Hello Worl");
73
+ });
74
+ });
75
+
76
+ describe("...in an input with at least one token and no text", () => {
77
+ it("should delete the last token", async () => {
78
+ const mockCallback = jest.fn();
79
+ const { user } = render(
80
+ <TokenInput
81
+ id="0"
82
+ name="token-input"
83
+ tokens={[
84
+ {
85
+ id: "0",
86
+ value: "you",
87
+ },
88
+ {
89
+ id: "1",
90
+ value: "are",
91
+ },
92
+ {
93
+ id: "2",
94
+ value: "beautiful",
95
+ },
96
+ ]}
97
+ placeholder="Enter text"
98
+ onRemoveToken={mockCallback}
99
+ />
100
+ );
101
+ const input = screen.getByPlaceholderText("Enter text");
102
+ await user.type(input, "{backspace}");
103
+ expect(mockCallback).toHaveBeenCalledTimes(1);
104
+ const result = mockCallback.mock.calls;
105
+ expect(result[0][0]).toBe("2");
106
+ });
107
+ });
108
+
109
+ describe("...in an input with at least one token and text", () => {
110
+ it("should delete all the text and then the token", async () => {
111
+ const mockCallback = jest.fn();
112
+ const { user } = render(
113
+ <TokenInput
114
+ id="0"
115
+ name="token-input"
116
+ tokens={[
117
+ {
118
+ id: "0",
119
+ value: "you",
120
+ },
121
+ {
122
+ id: "1",
123
+ value: "are",
124
+ },
125
+ {
126
+ id: "2",
127
+ value: "beautiful",
128
+ },
129
+ ]}
130
+ placeholder="Enter text"
131
+ onRemoveToken={mockCallback}
132
+ />
133
+ );
134
+ const input = screen.getByPlaceholderText(
135
+ "Enter text"
136
+ ) as HTMLInputElement;
137
+ await user.type(input, "william");
138
+ input.setSelectionRange(0, input.value.length);
139
+ await user.keyboard("{backspace}");
140
+ expect(input).toHaveValue("");
141
+ expect(mockCallback).toHaveBeenCalledTimes(0);
142
+ const result = mockCallback.mock.calls;
143
+ await user.keyboard("{backspace}");
144
+ expect(result[0][0]).toBe("2");
145
+ });
146
+ });
147
+ });
148
+
149
+ describe("When tabbing...", () => {
150
+ describe("...the tokens", () => {
151
+ it("should be focused", async () => {
152
+ const { user } = render(
153
+ <TokenInput
154
+ id="0"
155
+ placeholder="Please enter a value..."
156
+ name="token-input"
157
+ tokens={[
158
+ {
159
+ id: "0",
160
+ value: "you",
161
+ },
162
+ {
163
+ id: "1",
164
+ value: "are",
165
+ },
166
+ {
167
+ id: "2",
168
+ value: "beautiful",
169
+ },
170
+ ]}
171
+ />
172
+ );
173
+ const input = screen.getByPlaceholderText("Please enter a value...");
174
+ await user.click(input);
175
+ await user.tab({
176
+ shift: true,
177
+ });
178
+ expect(screen.getByText("beautiful").closest("button")).toHaveFocus();
179
+ await user.tab({
180
+ shift: true,
181
+ });
182
+ expect(screen.getByText("are").closest("button")).toHaveFocus();
183
+ await user.tab();
184
+ expect(screen.getByText("beautiful").closest("button")).toHaveFocus();
185
+ });
186
+ });
187
+ });
188
+
189
+ describe("When inputting...", () => {
190
+ describe("...simple text into an empty input", () => {
191
+ it("should add characters to the input", async () => {
192
+ const { user } = render(
193
+ <TokenInput id="0" name="token-input" placeholder="Enter text" />
194
+ );
195
+ const input = screen.getByPlaceholderText("Enter text");
196
+ await user.type(input, "Hello world!");
197
+ expect(input).toHaveValue("Hello world!");
198
+ });
199
+ });
200
+
201
+ describe("...a delimiter into an empty input", () => {
202
+ it("should add a value, if it's printable", async () => {
203
+ const { user } = render(
204
+ <TokenInput
205
+ id="0"
206
+ name="token-input"
207
+ delimiters={[".", "Enter"]}
208
+ placeholder="Enter text"
209
+ />
210
+ );
211
+ const input = screen.getByPlaceholderText("Enter text");
212
+ await user.type(input, ".");
213
+ expect(input).toHaveValue(".");
214
+ });
215
+ });
216
+
217
+ describe("...a delimiter into an input with a value", () => {
218
+ it("should add a token rather than a value", async () => {
219
+ const mockCallback = jest.fn();
220
+ const { user } = render(
221
+ <TokenInput
222
+ id="0"
223
+ name="token-input"
224
+ value=","
225
+ placeholder="Enter text"
226
+ onAddToken={mockCallback}
227
+ />
228
+ );
229
+ const input = screen.getByPlaceholderText("Enter text");
230
+ await user.type(input, ",");
231
+ expect(mockCallback).toHaveBeenCalledTimes(1);
232
+ });
233
+ });
234
+ });
235
+
236
+ describe("When pasting...", () => {
237
+ let mockHandleAdd: jest.Mock<any, any, any>;
238
+ let mockHandleRemove:
239
+ | jest.Mock<any, any, any>
240
+ | ((tokenId: string) => void)
241
+ | undefined;
242
+ let mockHandleChange:
243
+ | jest.Mock<any, any, any>
244
+ | ((e: React.SyntheticEvent<HTMLInputElement>, value: string) => void)
245
+ | undefined;
246
+ let mockHandlePaste:
247
+ | jest.Mock<any, any, any>
248
+ | ((e: React.ClipboardEvent<HTMLInputElement>, value: string) => void)
249
+ | undefined;
250
+ let tokenInput;
251
+ let input: HTMLElement;
252
+ let user: UserEvent;
253
+
254
+ describe("...with no tokens...", () => {
255
+ beforeEach(() => {
256
+ // TokenInput is a stateless component, so the value managed in state is cleared in the wrapper.
257
+ mockHandleAdd = jest.fn();
258
+ mockHandleRemove = jest.fn();
259
+ mockHandleChange = jest.fn();
260
+ mockHandlePaste = jest.fn();
261
+ tokenInput = (
262
+ <TokenInput
263
+ id="0"
264
+ name="token-input"
265
+ placeholder="Enter text"
266
+ onAddToken={mockHandleAdd}
267
+ onRemoveToken={mockHandleRemove}
268
+ onChange={mockHandleChange}
269
+ onPaste={mockHandlePaste}
270
+ />
271
+ );
272
+ const { user: userEvents } = render(tokenInput);
273
+ user = userEvents;
274
+ input = screen.getByPlaceholderText("Enter text") as HTMLInputElement;
275
+ });
276
+
277
+ describe("...simple text into an empty input", () => {
278
+ it("should insert the simple text", async () => {
279
+ expect(input).toHaveValue("");
280
+ await user.click(input);
281
+ await user.paste("Hello world");
282
+ expect(mockHandleAdd).toHaveBeenCalledTimes(0);
283
+ expect(mockHandleRemove).toHaveBeenCalledTimes(0);
284
+ expect(mockHandleChange).toHaveBeenCalledTimes(1);
285
+ if (jest.isMockFunction(mockHandleChange)) {
286
+ expect(mockHandleChange.mock.calls[0][1]).toBe("Hello world");
287
+ }
288
+ });
289
+ });
290
+
291
+ describe("...a delimiter into an empty input", () => {
292
+ it("shouldn't add anything... with a ','", async () => {
293
+ await user.click(input);
294
+ await user.paste(",");
295
+ expect(mockHandleAdd).toHaveBeenCalledTimes(0);
296
+ expect(mockHandleRemove).toHaveBeenCalledTimes(0);
297
+ });
298
+ it("shouldn't add anything... with an 'new-line'", async () => {
299
+ await user.click(input);
300
+ await user.paste("\n");
301
+ expect(mockHandleAdd).toHaveBeenCalledTimes(0);
302
+ expect(mockHandleRemove).toHaveBeenCalledTimes(0);
303
+ });
304
+ });
305
+
306
+ describe("...delimited text into an empty input", () => {
307
+ it("should add tokens", async () => {
308
+ expect(input).toHaveValue("");
309
+ await user.click(input);
310
+ await user.paste("one, two, three");
311
+ expect(mockHandleRemove).toHaveBeenCalledTimes(0);
312
+ expect(mockHandleAdd).toHaveBeenCalledTimes(3);
313
+ expect(mockHandlePaste).toHaveBeenCalledTimes(1);
314
+ if (jest.isMockFunction(mockHandlePaste)) {
315
+ expect(mockHandlePaste.mock.calls[0][1]).toBe("one, two, three");
316
+ }
317
+ // onChange seems to not be called with delimited text value, possibly because of the preventDefault used
318
+ // expect(mockHandleChange).toHaveBeenCalledTimes(1);
319
+ // expect(mockHandleChange.mock.calls[0][1]).toBe("one, two, three");
320
+ if (jest.isMockFunction(mockHandleAdd)) {
321
+ const tokenSpec1 = (mockHandleAdd as jest.Mock).mock.calls[0][0];
322
+ const tokenSpec2 = mockHandleAdd.mock.calls[1][0];
323
+ const tokenSpec3 = mockHandleAdd.mock.calls[2][0];
324
+ expect(tokenSpec1).toEqual(
325
+ expect.objectContaining({
326
+ id: expect.any(String),
327
+ value: "one",
328
+ })
329
+ );
330
+ expect(tokenSpec2).toEqual(
331
+ expect.objectContaining({
332
+ id: expect.any(String),
333
+ value: "two",
334
+ })
335
+ );
336
+ expect(tokenSpec3).toEqual(
337
+ expect.objectContaining({
338
+ id: expect.any(String),
339
+ value: "three",
340
+ })
341
+ );
342
+ }
343
+ });
344
+ });
345
+
346
+ describe("...simple text into an input with text", () => {
347
+ it("should simply paste the text", async () => {
348
+ const preText = "4321!";
349
+ const pasteText = "hello world";
350
+ await user.type(input, preText);
351
+ await user.click(input);
352
+ await user.paste(pasteText);
353
+ expect(mockHandleAdd).toHaveBeenCalledTimes(0);
354
+ expect(mockHandleRemove).toHaveBeenCalledTimes(0);
355
+ expect(mockHandleChange).toHaveBeenCalledTimes(6);
356
+ });
357
+ });
358
+ });
359
+
360
+ describe("...with text...", () => {
361
+ beforeEach(() => {
362
+ // TokenInput is a stateless component, so the value managed in state is cleared in the wrapper.
363
+ mockHandleAdd = jest.fn();
364
+ mockHandleRemove = jest.fn();
365
+ tokenInput = (
366
+ <TokenInput
367
+ id="0"
368
+ name="token-input"
369
+ placeholder="Enter text"
370
+ value="Pre Text!"
371
+ onAddToken={mockHandleAdd}
372
+ onRemoveToken={mockHandleRemove}
373
+ />
374
+ );
375
+ const { user: userEvents } = render(tokenInput);
376
+ user = userEvents;
377
+ input = screen.getByPlaceholderText("Enter text");
378
+ });
379
+
380
+ describe("...delimited text into an input with text", () => {
381
+ it("should make new tokens from the pasted text", async () => {
382
+ await user.click(input);
383
+ await user.paste("this, that");
384
+ expect(mockHandleRemove).toHaveBeenCalledTimes(0);
385
+ expect(mockHandleAdd).toHaveBeenCalledTimes(2);
386
+ const tokenSpec1 = mockHandleAdd.mock.calls[0][0];
387
+ const tokenSpec2 = mockHandleAdd.mock.calls[1][0];
388
+ expect(tokenSpec1).toEqual(
389
+ expect.objectContaining({
390
+ id: expect.any(String),
391
+ value: "this",
392
+ })
393
+ );
394
+ expect(tokenSpec2).toEqual(
395
+ expect.objectContaining({
396
+ id: expect.any(String),
397
+ value: "that",
398
+ })
399
+ );
400
+ });
401
+ });
402
+ });
403
+
404
+ describe("...with tokens...", () => {
405
+ beforeEach(() => {
406
+ // TokenInput is a stateless component, so the value managed in state is cleared in the wrapper.
407
+ mockHandleAdd = jest.fn();
408
+ mockHandleRemove = jest.fn();
409
+ tokenInput = (
410
+ <TokenInput
411
+ id="0"
412
+ name="token-input"
413
+ placeholder="Enter text"
414
+ tokens={[
415
+ {
416
+ id: "1",
417
+ value: "one",
418
+ },
419
+ {
420
+ id: "2",
421
+ value: "two",
422
+ },
423
+ {
424
+ id: "3",
425
+ value: "three",
426
+ },
427
+ ]}
428
+ onAddToken={mockHandleAdd}
429
+ onRemoveToken={mockHandleRemove}
430
+ />
431
+ );
432
+ const { user: userEvents } = render(tokenInput);
433
+ user = userEvents;
434
+ input = screen.getByPlaceholderText("Enter text");
435
+ });
436
+
437
+ describe("...simple text after one or more tokens", () => {
438
+ it("simply paste the text", async () => {
439
+ await user.click(input);
440
+ await user.paste("Hello world");
441
+ expect(mockHandleRemove).toHaveBeenCalledTimes(0);
442
+ expect(mockHandleAdd).toHaveBeenCalledTimes(0); // expect(mockHandleChange.mock.calls[0][1]).toBe("Hello world");
443
+ });
444
+ });
445
+
446
+ describe("...delimited text after one or more tokens", () => {
447
+ it("should add multiple new tokens", async () => {
448
+ await user.click(input);
449
+ await user.paste("this, that");
450
+ expect(mockHandleRemove).toHaveBeenCalledTimes(0);
451
+ expect(mockHandleAdd).toHaveBeenCalledTimes(2);
452
+ // expect(mockHandleChange.mock.calls[0][1]).toBe("this, that");
453
+ const tokenSpec1 = mockHandleAdd.mock.calls[0][0];
454
+ const tokenSpec2 = mockHandleAdd.mock.calls[1][0];
455
+ expect(tokenSpec1).toEqual(
456
+ expect.objectContaining({
457
+ id: expect.any(String),
458
+ value: "this",
459
+ })
460
+ );
461
+ expect(tokenSpec2).toEqual(
462
+ expect.objectContaining({
463
+ id: expect.any(String),
464
+ value: "that",
465
+ })
466
+ );
467
+ });
468
+ });
469
+ });
470
+ });
471
+
472
+ describe("When rendering...", () => {
473
+ it("should render disabled status correctly", () => {
474
+ render(
475
+ <TokenInput
476
+ id="0"
477
+ placeholder="Please enter a value..."
478
+ name="token-input"
479
+ tokens={[
480
+ {
481
+ id: "0",
482
+ value: "you",
483
+ },
484
+ {
485
+ id: "1",
486
+ value: "are",
487
+ },
488
+ {
489
+ id: "2",
490
+ value: "beautiful",
491
+ },
492
+ ]}
493
+ disabled
494
+ />
495
+ );
496
+ expect(
497
+ screen.getByDataQaLabel({
498
+ "input-isdisabled": true,
499
+ })
500
+ ).toBeTruthy();
501
+ });
502
+
503
+ it("should render before and after elements", () => {
504
+ render(
505
+ <TokenInput
506
+ elemAfter={<p>After</p>}
507
+ elemBefore={<p>Before</p>}
508
+ id="name"
509
+ name="name"
510
+ value="User"
511
+ />
512
+ );
513
+ expect(screen.getByText("Before")).toBeInTheDocument();
514
+ expect(screen.getByText("After")).toBeInTheDocument();
515
+ });
516
+
517
+ describe("...the isInvalid prop", () => {
518
+ it.each([true, "foobar", 1])(
519
+ "should correctly set aria-invalid to true for truthy values: %p",
520
+ (truthyValue) => {
521
+ render(
522
+ <TokenInput id="name" name="name" isInvalid={Boolean(truthyValue)} />
523
+ );
524
+ expect(
525
+ screen.getByDataQaLabel({
526
+ input: "name",
527
+ })
528
+ ).toHaveAttribute("aria-invalid", "true");
529
+ }
530
+ );
531
+
532
+ it.each([false, null, undefined, 0])(
533
+ "should correctly set aria-invalid to false for falsy values: %p",
534
+ (truthyValue) => {
535
+ render(
536
+ <TokenInput id="name" name="name" isInvalid={Boolean(truthyValue)} />
537
+ );
538
+ expect(
539
+ screen.getByDataQaLabel({
540
+ input: "name",
541
+ })
542
+ ).toHaveAttribute("aria-invalid", "false");
543
+ }
544
+ );
545
+ });
546
+
547
+ describe("...tokens", () => {
548
+ it("should render tokens in container", () => {
549
+ render(
550
+ <TokenInput
551
+ id="name"
552
+ name="name"
553
+ value="User"
554
+ tokens={[
555
+ {
556
+ id: "0",
557
+ value: "han",
558
+ },
559
+ {
560
+ id: "1",
561
+ value: "solo",
562
+ },
563
+ ]}
564
+ />
565
+ );
566
+ expect(screen.getByText("han")).toBeInTheDocument();
567
+ expect(screen.getByText("solo")).toBeInTheDocument();
568
+ });
569
+
570
+ it("should render tokens with icons", () => {
571
+ render(
572
+ <TokenInput
573
+ id="name"
574
+ iconName="lock-outline"
575
+ name="name"
576
+ value="User"
577
+ tokens={[
578
+ {
579
+ id: "0",
580
+ value: "han",
581
+ },
582
+ {
583
+ id: "1",
584
+ value: "solo",
585
+ },
586
+ ]}
587
+ />
588
+ );
589
+ expect(
590
+ screen.getAllByDataQaLabel({
591
+ icon: "lock-outline",
592
+ }).length
593
+ ).toBe(2);
594
+ });
595
+
596
+ it("should render tokens with individual icons", () => {
597
+ render(
598
+ <TokenInput
599
+ id="name"
600
+ name="name"
601
+ value="User"
602
+ tokens={[
603
+ {
604
+ id: "0",
605
+ value: "han",
606
+ },
607
+ {
608
+ id: "1",
609
+ iconName: "sun-outline",
610
+ value: "solo",
611
+ },
612
+ {
613
+ id: "2",
614
+ iconName: "sun-outline",
615
+ value: "darth",
616
+ },
617
+ {
618
+ id: "3",
619
+ value: "vader",
620
+ },
621
+ ]}
622
+ />
623
+ );
624
+ expect(
625
+ screen.getAllByDataQaLabel({
626
+ icon: "sun-outline",
627
+ }).length
628
+ ).toBe(2);
629
+ });
630
+
631
+ it("should render tokens with individual icons and fallback icon", () => {
632
+ render(
633
+ <TokenInput
634
+ id="name"
635
+ iconName="lock-outline"
636
+ name="name"
637
+ value="User"
638
+ tokens={[
639
+ {
640
+ id: "0",
641
+ value: "han",
642
+ },
643
+ {
644
+ id: "1",
645
+ iconName: "sun-outline",
646
+ value: "solo",
647
+ },
648
+ {
649
+ id: "2",
650
+ iconName: "sun-outline",
651
+ value: "darth",
652
+ },
653
+ {
654
+ id: "3",
655
+ value: "vader",
656
+ },
657
+ ]}
658
+ />
659
+ );
660
+ expect(
661
+ screen.getAllByDataQaLabel({
662
+ icon: "lock-outline",
663
+ }).length
664
+ ).toBe(2);
665
+ expect(
666
+ screen.getAllByDataQaLabel({
667
+ icon: "sun-outline",
668
+ }).length
669
+ ).toBe(2);
670
+ });
671
+ });
672
+ });