@automattic/vip-design-system 2.19.0 → 2.20.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.
@@ -13,6 +13,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
13
13
  */
14
14
  import { FormAutocompleteMultiselectBadge } from './FormAutocompleteMultiselectBadge';
15
15
  import { FormAutocompleteMultiselectButton } from './FormAutocompleteMultiselectButton';
16
+ import { FormAutocompleteMultiselectInlineChip } from './FormAutocompleteMultiselectInlineChip';
16
17
  import { FormSelectArrow } from './FormSelectArrow';
17
18
  import { FormSelectContent } from './FormSelectContent';
18
19
  import { FormSelectLoading } from './FormSelectLoading';
@@ -22,6 +23,14 @@ import { Validation } from '../Form';
22
23
  import { baseControlBorderStyle, inputBaseText } from '../Form/Input.styles';
23
24
  import { Label } from '../Form/Label';
24
25
 
26
+ const escapeHtml = str =>
27
+ String( str )
28
+ .replace( /&/g, '&' )
29
+ .replace( /</g, '&lt;' )
30
+ .replace( />/g, '&gt;' )
31
+ .replace( /"/g, '&quot;' )
32
+ .replace( /'/g, '&#039;' );
33
+
25
34
  const baseBorderTextColors = {
26
35
  ...baseControlBorderStyle,
27
36
  backgroundColor: 'layer.2',
@@ -96,7 +105,33 @@ const searchIconStyles = {
96
105
  },
97
106
  };
98
107
 
99
- const DefaultArrow = config => <FormSelectArrow classNames={ config.className } />;
108
+ const inlineChipsContainerStyles = {
109
+ ...defaultStyles,
110
+ display: 'flex',
111
+ flexWrap: 'wrap',
112
+ alignItems: 'center',
113
+ p: 1,
114
+ pr: 0,
115
+ position: 'relative',
116
+ '& .autocomplete__input': {
117
+ ...defaultStyles[ '& .autocomplete__input' ],
118
+ lineHeight: '24px',
119
+ minHeight: '24px',
120
+ },
121
+ '&:focus-within': theme => theme.outline,
122
+ '& .autocomplete__wrapper': {
123
+ position: 'static',
124
+ width: '100%',
125
+ lineHeight: '24px',
126
+ minHeight: '24px',
127
+ '& .autocomplete__dropdown-arrow-down': {
128
+ top: 'unset',
129
+ bottom: '6px',
130
+ },
131
+ },
132
+ };
133
+
134
+ const DefaultArrow = config => <FormSelectArrow className={ config.className } />;
100
135
 
101
136
  const AddSelectionStatus = ( { status } ) => {
102
137
  return (
@@ -156,10 +191,12 @@ const FormAutocompleteMultiselect = React.forwardRef(
156
191
  listType = 'button',
157
192
  initialValue = [],
158
193
  allowCustom = false,
194
+ variant,
159
195
  ...props
160
196
  },
161
197
  forwardRef
162
198
  ) => {
199
+ const isInlineChips = variant === 'inline-chips';
163
200
  const OPTION_ACTION = {
164
201
  ADD: 'add',
165
202
  REMOVE: 'remove',
@@ -175,6 +212,7 @@ const FormAutocompleteMultiselect = React.forwardRef(
175
212
  option: null,
176
213
  index: -1,
177
214
  } );
215
+ const justSelectedRef = React.useRef( false );
178
216
  let debounceTimeout;
179
217
  forwardRef = forwardRef || React.createRef();
180
218
 
@@ -283,11 +321,44 @@ const FormAutocompleteMultiselect = React.forwardRef(
283
321
  data = handleTypeChange( query );
284
322
  }
285
323
  const optionForDisplay = data?.map( option => optionLabel( option ) );
286
- populateResults(
287
- optionForDisplay.filter( option => ! selectedOptions.includes( option ) )
288
- );
324
+ if ( isInlineChips ) {
325
+ populateResults( optionForDisplay );
326
+ } else {
327
+ populateResults(
328
+ optionForDisplay.filter( option => ! selectedOptions.includes( option ) )
329
+ );
330
+ }
331
+ },
332
+ [ autoFilter, isDirty, isInlineChips, onInputChange, options, selectedOptions ]
333
+ );
334
+
335
+ const onValueChangeInlineChips = useCallback(
336
+ inputValue => {
337
+ if ( ! inputValue ) {
338
+ return;
339
+ }
340
+ justSelectedRef.current = true;
341
+ if ( selectedOptions.includes( inputValue ) ) {
342
+ unselectValue( inputValue, selectedOptions.indexOf( inputValue ) );
343
+ } else {
344
+ setCurrentOption( { action: OPTION_ACTION.ADD, option: inputValue } );
345
+ setSelectedOptions( [ ...selectedOptions, inputValue ] );
346
+ }
289
347
  },
290
- [ autoFilter, isDirty, onInputChange, options, selectedOptions ]
348
+ [ selectedOptions, unselectValue ]
349
+ );
350
+
351
+ const inlineChipsTemplates = useMemo(
352
+ () => ( {
353
+ suggestion: suggestion => {
354
+ const isSelected = selectedOptions.includes( suggestion );
355
+ const check = isSelected ? '&#10003;' : '';
356
+ return `<span style="display:flex;align-items:center;gap:8px"><span style="width:16px;flex-shrink:0">${ check }</span>${ escapeHtml(
357
+ suggestion
358
+ ) }</span>`;
359
+ },
360
+ } ),
361
+ [ selectedOptions ]
291
362
  );
292
363
 
293
364
  useEffect( () => {
@@ -332,7 +403,16 @@ const FormAutocompleteMultiselect = React.forwardRef(
332
403
  selectedOptions,
333
404
  selectedOptions.map( option => option?.label || option )
334
405
  );
335
- resetInputState();
406
+ if ( isInlineChips && justSelectedRef.current && forwardRef?.current ) {
407
+ justSelectedRef.current = false;
408
+ forwardRef.current.setState( {
409
+ ...forwardRef.current.state,
410
+ query: '',
411
+ menuOpen: true,
412
+ } );
413
+ } else {
414
+ resetInputState();
415
+ }
336
416
  }, [ selectedOptions ] );
337
417
 
338
418
  // Update the select status for screen readers
@@ -340,15 +420,65 @@ const FormAutocompleteMultiselect = React.forwardRef(
340
420
  if ( currentOption.action === OPTION_ACTION.ADD ) {
341
421
  setAddStatus( `${ currentOption.option } added to the list.` );
342
422
  setCurrentOption( { action: OPTION_ACTION.NONE, option: null } );
343
- } else if ( currentOption.index === selectedOptions.length && selectedOptions.length > 0 ) {
344
- // Move focus to the first selected item, if the last element is removed and there are other elements in the list
345
- global.document.querySelector( '.vip-button-component' )?.focus();
346
- } else if ( selectedOptions.length === 0 ) {
347
- // Move focus to the input field if the last element is removed and there are no other elements in the list
348
- global.document.querySelector( '.autocomplete__input' )?.focus();
423
+ } else if ( currentOption.action === OPTION_ACTION.REMOVE ) {
424
+ setAddStatus( `${ currentOption.option } removed from the list.` );
425
+ if ( isInlineChips ) {
426
+ global.document.querySelector( `#${ forLabel }` )?.focus();
427
+ } else if ( currentOption.index === selectedOptions.length && selectedOptions.length > 0 ) {
428
+ global.document.querySelector( '.vip-button-component' )?.focus();
429
+ } else if ( selectedOptions.length === 0 ) {
430
+ global.document.querySelector( '.autocomplete__input' )?.focus();
431
+ }
432
+ setCurrentOption( { action: OPTION_ACTION.NONE, option: null } );
349
433
  }
350
434
  }, [ currentOption ] );
351
435
 
436
+ if ( isInlineChips ) {
437
+ return (
438
+ <div className={ classNames( 'vip-form-autocomplete-component', className ) }>
439
+ { label && <SelectLabel /> }
440
+ <div sx={ inlineChipsContainerStyles }>
441
+ { selectedOptions.map( ( option, idx ) => (
442
+ <FormAutocompleteMultiselectInlineChip
443
+ key={ option }
444
+ index={ idx }
445
+ option={ option }
446
+ unselectValue={ unselectValue }
447
+ />
448
+ ) ) }
449
+ <div sx={ { flex: '1 1 120px', minWidth: '120px' } }>
450
+ <Autocomplete
451
+ id={ forLabel }
452
+ aria-busy={ loading }
453
+ showAllValues={ true }
454
+ ref={ forwardRef }
455
+ source={ source || suggest }
456
+ defaultValue={ value }
457
+ displayMenu={ displayMenu }
458
+ onConfirm={ onValueChangeInlineChips }
459
+ tNoResults={ noOptionsMessage }
460
+ required={ required }
461
+ dropdownArrow={ dropdownArrow }
462
+ confirmOnBlur={ false }
463
+ templates={ inlineChipsTemplates }
464
+ { ...props }
465
+ placeholder={ selectedOptions.length > 0 ? '' : props.placeholder || '' }
466
+ />
467
+ </div>
468
+ { addStatus && <AddSelectionStatus status={ addStatus } /> }
469
+ { loading && <FormSelectLoading sx={ { right: 7 } } /> }
470
+ </div>
471
+ { hasError && errorMessage && (
472
+ <Flex sx={ { mt: 2 } }>
473
+ <Validation isValid={ false } describedId={ forLabel }>
474
+ { errorMessage }
475
+ </Validation>
476
+ </Flex>
477
+ ) }
478
+ </div>
479
+ );
480
+ }
481
+
352
482
  return (
353
483
  <div className={ classNames( 'vip-form-autocomplete-component', className ) }>
354
484
  { label && ! isInline && <SelectLabel /> }
@@ -435,6 +565,7 @@ FormAutocompleteMultiselect.propTypes = {
435
565
  dropdownArrow: PropTypes.node,
436
566
  initialValue: PropTypes.array,
437
567
  allowCustom: PropTypes.bool,
568
+ variant: PropTypes.string,
438
569
  };
439
570
 
440
571
  FormAutocompleteMultiselect.displayName = 'FormAutocompleteMultiselect';
@@ -92,9 +92,29 @@ export namespace WithStaticData {
92
92
  }
93
93
  export { args_5 as args };
94
94
  }
95
- export namespace WithDynamicData {
96
- export function render_6(): import("react").JSX.Element;
95
+ export namespace InlineChips {
96
+ export function render_6(props: any): import("react").JSX.Element;
97
97
  export { render_6 as render };
98
+ export namespace args_6 {
99
+ let label_3: string;
100
+ export { label_3 as label };
101
+ export let options: {
102
+ value: string;
103
+ label: string;
104
+ }[];
105
+ export let variant: string;
106
+ let showAllValues_2: boolean;
107
+ export { showAllValues_2 as showAllValues };
108
+ let placeholder_3: string;
109
+ export { placeholder_3 as placeholder };
110
+ let initialValue_1: string[];
111
+ export { initialValue_1 as initialValue };
112
+ }
113
+ export { args_6 as args };
114
+ }
115
+ export namespace WithDynamicData {
116
+ export function render_7(): import("react").JSX.Element;
117
+ export { render_7 as render };
98
118
  }
99
119
  declare const shortOptions: {
100
120
  value: string;
@@ -134,6 +134,27 @@ export const WithStaticData = {
134
134
  },
135
135
  };
136
136
 
137
+ export const InlineChips = {
138
+ render: props => <DefaultComponent { ...props } width={ 500 } />,
139
+ args: {
140
+ label: 'Post Categories',
141
+ options: [
142
+ { value: 'breaking-news', label: 'Breaking News' },
143
+ { value: 'world-news', label: 'World News' },
144
+ { value: 'us-news', label: 'U.S. News' },
145
+ { value: 'climate-environment', label: 'Climate & Environment' },
146
+ { value: 'obituaries', label: 'Obituaries' },
147
+ { value: 'technology', label: 'Technology' },
148
+ { value: 'entertainment', label: 'Entertainment' },
149
+ { value: 'real-estate', label: 'Real Estate' },
150
+ ],
151
+ variant: 'inline-chips',
152
+ showAllValues: true,
153
+ placeholder: 'Search categories...',
154
+ initialValue: [ 'Breaking News' ],
155
+ },
156
+ };
157
+
137
158
  export const WithDynamicData = {
138
159
  render: () => {
139
160
  const [ selectedValues, setSelectedValues ] = useState( [] );
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * External dependencies
3
3
  */
4
- import { render, screen } from '@testing-library/react';
4
+ import { fireEvent, render, screen } from '@testing-library/react';
5
5
  import { axe } from 'jest-axe';
6
6
 
7
7
  /**
@@ -37,3 +37,69 @@ describe( '<FormAutocompleteMultiselect />', () => {
37
37
  await expect( await axe( container ) ).toHaveNoViolations();
38
38
  } );
39
39
  } );
40
+
41
+ describe( '<FormAutocompleteMultiselect variant="inline-chips" />', () => {
42
+ it( 'renders the inline-chips variant', async () => {
43
+ const { container } = render(
44
+ <FormAutocompleteMultiselect
45
+ forLabel="my_inline_chips"
46
+ label="Categories"
47
+ options={ options }
48
+ variant="inline-chips"
49
+ showAllValues
50
+ />
51
+ );
52
+ expect( screen.getByLabelText( 'Categories' ) ).toBeInTheDocument();
53
+ await expect( await axe( container ) ).toHaveNoViolations();
54
+ } );
55
+
56
+ it( 'renders initial values as inline chips', () => {
57
+ render(
58
+ <FormAutocompleteMultiselect
59
+ forLabel="my_inline_chips_init"
60
+ label="Categories"
61
+ options={ options }
62
+ variant="inline-chips"
63
+ showAllValues
64
+ initialValue={ [ 'Chocolate', 'Vanilla' ] }
65
+ />
66
+ );
67
+ expect( screen.getByText( 'Chocolate' ) ).toBeInTheDocument();
68
+ expect( screen.getByText( 'Vanilla' ) ).toBeInTheDocument();
69
+ expect( screen.getByRole( 'button', { name: 'Remove Chocolate' } ) ).toBeInTheDocument();
70
+ expect( screen.getByRole( 'button', { name: 'Remove Vanilla' } ) ).toBeInTheDocument();
71
+ } );
72
+
73
+ it( 'removes a chip when the close button is clicked', () => {
74
+ render(
75
+ <FormAutocompleteMultiselect
76
+ forLabel="my_inline_chips_remove"
77
+ label="Categories"
78
+ options={ options }
79
+ variant="inline-chips"
80
+ showAllValues
81
+ initialValue={ [ 'Chocolate', 'Vanilla' ] }
82
+ />
83
+ );
84
+ expect( screen.getByText( 'Chocolate' ) ).toBeInTheDocument();
85
+ fireEvent.click( screen.getByRole( 'button', { name: 'Remove Chocolate' } ) );
86
+ expect( screen.queryByText( 'Chocolate' ) ).not.toBeInTheDocument();
87
+ expect( screen.getByText( 'Vanilla' ) ).toBeInTheDocument();
88
+ } );
89
+
90
+ it( 'announces removal to screen readers', () => {
91
+ const { container } = render(
92
+ <FormAutocompleteMultiselect
93
+ forLabel="my_inline_chips_a11y"
94
+ label="Categories"
95
+ options={ options }
96
+ variant="inline-chips"
97
+ showAllValues
98
+ initialValue={ [ 'Chocolate', 'Vanilla' ] }
99
+ />
100
+ );
101
+ fireEvent.click( screen.getByRole( 'button', { name: 'Remove Chocolate' } ) );
102
+ const statusEl = container.querySelector( '#vip-autocompletemultiselect-status' );
103
+ expect( statusEl ).toHaveTextContent( 'Chocolate removed from the list.' );
104
+ } );
105
+ } );
@@ -0,0 +1,7 @@
1
+ /** @jsxImportSource theme-ui */
2
+ declare const FormAutocompleteMultiselectInlineChip: ({ index, option, unselectValue, }: {
3
+ index: number;
4
+ option: string;
5
+ unselectValue: (option: string, index: number) => void;
6
+ }) => import("react").JSX.Element;
7
+ export { FormAutocompleteMultiselectInlineChip };
@@ -0,0 +1,62 @@
1
+ /** @jsxImportSource theme-ui */
2
+
3
+ /**
4
+ * External dependencies
5
+ */
6
+ import { MdClose } from 'react-icons/md';
7
+ import { jsx as _jsx, jsxs as _jsxs } from "theme-ui/jsx-runtime";
8
+ var FormAutocompleteMultiselectInlineChip = function FormAutocompleteMultiselectInlineChip(_ref) {
9
+ var index = _ref.index,
10
+ option = _ref.option,
11
+ unselectValue = _ref.unselectValue;
12
+ return _jsxs("span", {
13
+ sx: {
14
+ display: 'inline-flex',
15
+ alignItems: 'center',
16
+ gap: 1,
17
+ px: 2,
18
+ py: '2px',
19
+ m: 1,
20
+ bg: 'layer.1',
21
+ borderRadius: 1,
22
+ fontSize: 1,
23
+ lineHeight: '16px',
24
+ whiteSpace: 'nowrap',
25
+ maxWidth: '100%'
26
+ },
27
+ children: [_jsx("span", {
28
+ sx: {
29
+ overflow: 'hidden',
30
+ textOverflow: 'ellipsis',
31
+ whiteSpace: 'nowrap'
32
+ },
33
+ children: option
34
+ }), _jsx("button", {
35
+ type: "button",
36
+ "aria-label": "Remove " + option,
37
+ onClick: function onClick(e) {
38
+ e.preventDefault();
39
+ e.stopPropagation();
40
+ unselectValue(option, index);
41
+ },
42
+ sx: {
43
+ display: 'inline-flex',
44
+ alignItems: 'center',
45
+ justifyContent: 'center',
46
+ p: 0,
47
+ border: 'none',
48
+ bg: 'transparent',
49
+ cursor: 'pointer',
50
+ color: 'text',
51
+ lineHeight: 0,
52
+ '&:hover': {
53
+ opacity: 0.7
54
+ }
55
+ },
56
+ children: _jsx(MdClose, {
57
+ size: 14
58
+ })
59
+ })]
60
+ });
61
+ };
62
+ export { FormAutocompleteMultiselectInlineChip };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/vip-design-system",
3
- "version": "2.19.0",
3
+ "version": "2.20.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/Automattic/vip-design-system"
@@ -13,6 +13,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
13
13
  */
14
14
  import { FormAutocompleteMultiselectBadge } from './FormAutocompleteMultiselectBadge';
15
15
  import { FormAutocompleteMultiselectButton } from './FormAutocompleteMultiselectButton';
16
+ import { FormAutocompleteMultiselectInlineChip } from './FormAutocompleteMultiselectInlineChip';
16
17
  import { FormSelectArrow } from './FormSelectArrow';
17
18
  import { FormSelectContent } from './FormSelectContent';
18
19
  import { FormSelectLoading } from './FormSelectLoading';
@@ -22,6 +23,14 @@ import { Validation } from '../Form';
22
23
  import { baseControlBorderStyle, inputBaseText } from '../Form/Input.styles';
23
24
  import { Label } from '../Form/Label';
24
25
 
26
+ const escapeHtml = str =>
27
+ String( str )
28
+ .replace( /&/g, '&amp;' )
29
+ .replace( /</g, '&lt;' )
30
+ .replace( />/g, '&gt;' )
31
+ .replace( /"/g, '&quot;' )
32
+ .replace( /'/g, '&#039;' );
33
+
25
34
  const baseBorderTextColors = {
26
35
  ...baseControlBorderStyle,
27
36
  backgroundColor: 'layer.2',
@@ -96,7 +105,33 @@ const searchIconStyles = {
96
105
  },
97
106
  };
98
107
 
99
- const DefaultArrow = config => <FormSelectArrow classNames={ config.className } />;
108
+ const inlineChipsContainerStyles = {
109
+ ...defaultStyles,
110
+ display: 'flex',
111
+ flexWrap: 'wrap',
112
+ alignItems: 'center',
113
+ p: 1,
114
+ pr: 0,
115
+ position: 'relative',
116
+ '& .autocomplete__input': {
117
+ ...defaultStyles[ '& .autocomplete__input' ],
118
+ lineHeight: '24px',
119
+ minHeight: '24px',
120
+ },
121
+ '&:focus-within': theme => theme.outline,
122
+ '& .autocomplete__wrapper': {
123
+ position: 'static',
124
+ width: '100%',
125
+ lineHeight: '24px',
126
+ minHeight: '24px',
127
+ '& .autocomplete__dropdown-arrow-down': {
128
+ top: 'unset',
129
+ bottom: '6px',
130
+ },
131
+ },
132
+ };
133
+
134
+ const DefaultArrow = config => <FormSelectArrow className={ config.className } />;
100
135
 
101
136
  const AddSelectionStatus = ( { status } ) => {
102
137
  return (
@@ -156,10 +191,12 @@ const FormAutocompleteMultiselect = React.forwardRef(
156
191
  listType = 'button',
157
192
  initialValue = [],
158
193
  allowCustom = false,
194
+ variant,
159
195
  ...props
160
196
  },
161
197
  forwardRef
162
198
  ) => {
199
+ const isInlineChips = variant === 'inline-chips';
163
200
  const OPTION_ACTION = {
164
201
  ADD: 'add',
165
202
  REMOVE: 'remove',
@@ -175,6 +212,7 @@ const FormAutocompleteMultiselect = React.forwardRef(
175
212
  option: null,
176
213
  index: -1,
177
214
  } );
215
+ const justSelectedRef = React.useRef( false );
178
216
  let debounceTimeout;
179
217
  forwardRef = forwardRef || React.createRef();
180
218
 
@@ -283,11 +321,44 @@ const FormAutocompleteMultiselect = React.forwardRef(
283
321
  data = handleTypeChange( query );
284
322
  }
285
323
  const optionForDisplay = data?.map( option => optionLabel( option ) );
286
- populateResults(
287
- optionForDisplay.filter( option => ! selectedOptions.includes( option ) )
288
- );
324
+ if ( isInlineChips ) {
325
+ populateResults( optionForDisplay );
326
+ } else {
327
+ populateResults(
328
+ optionForDisplay.filter( option => ! selectedOptions.includes( option ) )
329
+ );
330
+ }
331
+ },
332
+ [ autoFilter, isDirty, isInlineChips, onInputChange, options, selectedOptions ]
333
+ );
334
+
335
+ const onValueChangeInlineChips = useCallback(
336
+ inputValue => {
337
+ if ( ! inputValue ) {
338
+ return;
339
+ }
340
+ justSelectedRef.current = true;
341
+ if ( selectedOptions.includes( inputValue ) ) {
342
+ unselectValue( inputValue, selectedOptions.indexOf( inputValue ) );
343
+ } else {
344
+ setCurrentOption( { action: OPTION_ACTION.ADD, option: inputValue } );
345
+ setSelectedOptions( [ ...selectedOptions, inputValue ] );
346
+ }
289
347
  },
290
- [ autoFilter, isDirty, onInputChange, options, selectedOptions ]
348
+ [ selectedOptions, unselectValue ]
349
+ );
350
+
351
+ const inlineChipsTemplates = useMemo(
352
+ () => ( {
353
+ suggestion: suggestion => {
354
+ const isSelected = selectedOptions.includes( suggestion );
355
+ const check = isSelected ? '&#10003;' : '';
356
+ return `<span style="display:flex;align-items:center;gap:8px"><span style="width:16px;flex-shrink:0">${ check }</span>${ escapeHtml(
357
+ suggestion
358
+ ) }</span>`;
359
+ },
360
+ } ),
361
+ [ selectedOptions ]
291
362
  );
292
363
 
293
364
  useEffect( () => {
@@ -332,7 +403,16 @@ const FormAutocompleteMultiselect = React.forwardRef(
332
403
  selectedOptions,
333
404
  selectedOptions.map( option => option?.label || option )
334
405
  );
335
- resetInputState();
406
+ if ( isInlineChips && justSelectedRef.current && forwardRef?.current ) {
407
+ justSelectedRef.current = false;
408
+ forwardRef.current.setState( {
409
+ ...forwardRef.current.state,
410
+ query: '',
411
+ menuOpen: true,
412
+ } );
413
+ } else {
414
+ resetInputState();
415
+ }
336
416
  }, [ selectedOptions ] );
337
417
 
338
418
  // Update the select status for screen readers
@@ -340,15 +420,65 @@ const FormAutocompleteMultiselect = React.forwardRef(
340
420
  if ( currentOption.action === OPTION_ACTION.ADD ) {
341
421
  setAddStatus( `${ currentOption.option } added to the list.` );
342
422
  setCurrentOption( { action: OPTION_ACTION.NONE, option: null } );
343
- } else if ( currentOption.index === selectedOptions.length && selectedOptions.length > 0 ) {
344
- // Move focus to the first selected item, if the last element is removed and there are other elements in the list
345
- global.document.querySelector( '.vip-button-component' )?.focus();
346
- } else if ( selectedOptions.length === 0 ) {
347
- // Move focus to the input field if the last element is removed and there are no other elements in the list
348
- global.document.querySelector( '.autocomplete__input' )?.focus();
423
+ } else if ( currentOption.action === OPTION_ACTION.REMOVE ) {
424
+ setAddStatus( `${ currentOption.option } removed from the list.` );
425
+ if ( isInlineChips ) {
426
+ global.document.querySelector( `#${ forLabel }` )?.focus();
427
+ } else if ( currentOption.index === selectedOptions.length && selectedOptions.length > 0 ) {
428
+ global.document.querySelector( '.vip-button-component' )?.focus();
429
+ } else if ( selectedOptions.length === 0 ) {
430
+ global.document.querySelector( '.autocomplete__input' )?.focus();
431
+ }
432
+ setCurrentOption( { action: OPTION_ACTION.NONE, option: null } );
349
433
  }
350
434
  }, [ currentOption ] );
351
435
 
436
+ if ( isInlineChips ) {
437
+ return (
438
+ <div className={ classNames( 'vip-form-autocomplete-component', className ) }>
439
+ { label && <SelectLabel /> }
440
+ <div sx={ inlineChipsContainerStyles }>
441
+ { selectedOptions.map( ( option, idx ) => (
442
+ <FormAutocompleteMultiselectInlineChip
443
+ key={ option }
444
+ index={ idx }
445
+ option={ option }
446
+ unselectValue={ unselectValue }
447
+ />
448
+ ) ) }
449
+ <div sx={ { flex: '1 1 120px', minWidth: '120px' } }>
450
+ <Autocomplete
451
+ id={ forLabel }
452
+ aria-busy={ loading }
453
+ showAllValues={ true }
454
+ ref={ forwardRef }
455
+ source={ source || suggest }
456
+ defaultValue={ value }
457
+ displayMenu={ displayMenu }
458
+ onConfirm={ onValueChangeInlineChips }
459
+ tNoResults={ noOptionsMessage }
460
+ required={ required }
461
+ dropdownArrow={ dropdownArrow }
462
+ confirmOnBlur={ false }
463
+ templates={ inlineChipsTemplates }
464
+ { ...props }
465
+ placeholder={ selectedOptions.length > 0 ? '' : props.placeholder || '' }
466
+ />
467
+ </div>
468
+ { addStatus && <AddSelectionStatus status={ addStatus } /> }
469
+ { loading && <FormSelectLoading sx={ { right: 7 } } /> }
470
+ </div>
471
+ { hasError && errorMessage && (
472
+ <Flex sx={ { mt: 2 } }>
473
+ <Validation isValid={ false } describedId={ forLabel }>
474
+ { errorMessage }
475
+ </Validation>
476
+ </Flex>
477
+ ) }
478
+ </div>
479
+ );
480
+ }
481
+
352
482
  return (
353
483
  <div className={ classNames( 'vip-form-autocomplete-component', className ) }>
354
484
  { label && ! isInline && <SelectLabel /> }
@@ -435,6 +565,7 @@ FormAutocompleteMultiselect.propTypes = {
435
565
  dropdownArrow: PropTypes.node,
436
566
  initialValue: PropTypes.array,
437
567
  allowCustom: PropTypes.bool,
568
+ variant: PropTypes.string,
438
569
  };
439
570
 
440
571
  FormAutocompleteMultiselect.displayName = 'FormAutocompleteMultiselect';
@@ -134,6 +134,27 @@ export const WithStaticData = {
134
134
  },
135
135
  };
136
136
 
137
+ export const InlineChips = {
138
+ render: props => <DefaultComponent { ...props } width={ 500 } />,
139
+ args: {
140
+ label: 'Post Categories',
141
+ options: [
142
+ { value: 'breaking-news', label: 'Breaking News' },
143
+ { value: 'world-news', label: 'World News' },
144
+ { value: 'us-news', label: 'U.S. News' },
145
+ { value: 'climate-environment', label: 'Climate & Environment' },
146
+ { value: 'obituaries', label: 'Obituaries' },
147
+ { value: 'technology', label: 'Technology' },
148
+ { value: 'entertainment', label: 'Entertainment' },
149
+ { value: 'real-estate', label: 'Real Estate' },
150
+ ],
151
+ variant: 'inline-chips',
152
+ showAllValues: true,
153
+ placeholder: 'Search categories...',
154
+ initialValue: [ 'Breaking News' ],
155
+ },
156
+ };
157
+
137
158
  export const WithDynamicData = {
138
159
  render: () => {
139
160
  const [ selectedValues, setSelectedValues ] = useState( [] );
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * External dependencies
3
3
  */
4
- import { render, screen } from '@testing-library/react';
4
+ import { fireEvent, render, screen } from '@testing-library/react';
5
5
  import { axe } from 'jest-axe';
6
6
 
7
7
  /**
@@ -37,3 +37,69 @@ describe( '<FormAutocompleteMultiselect />', () => {
37
37
  await expect( await axe( container ) ).toHaveNoViolations();
38
38
  } );
39
39
  } );
40
+
41
+ describe( '<FormAutocompleteMultiselect variant="inline-chips" />', () => {
42
+ it( 'renders the inline-chips variant', async () => {
43
+ const { container } = render(
44
+ <FormAutocompleteMultiselect
45
+ forLabel="my_inline_chips"
46
+ label="Categories"
47
+ options={ options }
48
+ variant="inline-chips"
49
+ showAllValues
50
+ />
51
+ );
52
+ expect( screen.getByLabelText( 'Categories' ) ).toBeInTheDocument();
53
+ await expect( await axe( container ) ).toHaveNoViolations();
54
+ } );
55
+
56
+ it( 'renders initial values as inline chips', () => {
57
+ render(
58
+ <FormAutocompleteMultiselect
59
+ forLabel="my_inline_chips_init"
60
+ label="Categories"
61
+ options={ options }
62
+ variant="inline-chips"
63
+ showAllValues
64
+ initialValue={ [ 'Chocolate', 'Vanilla' ] }
65
+ />
66
+ );
67
+ expect( screen.getByText( 'Chocolate' ) ).toBeInTheDocument();
68
+ expect( screen.getByText( 'Vanilla' ) ).toBeInTheDocument();
69
+ expect( screen.getByRole( 'button', { name: 'Remove Chocolate' } ) ).toBeInTheDocument();
70
+ expect( screen.getByRole( 'button', { name: 'Remove Vanilla' } ) ).toBeInTheDocument();
71
+ } );
72
+
73
+ it( 'removes a chip when the close button is clicked', () => {
74
+ render(
75
+ <FormAutocompleteMultiselect
76
+ forLabel="my_inline_chips_remove"
77
+ label="Categories"
78
+ options={ options }
79
+ variant="inline-chips"
80
+ showAllValues
81
+ initialValue={ [ 'Chocolate', 'Vanilla' ] }
82
+ />
83
+ );
84
+ expect( screen.getByText( 'Chocolate' ) ).toBeInTheDocument();
85
+ fireEvent.click( screen.getByRole( 'button', { name: 'Remove Chocolate' } ) );
86
+ expect( screen.queryByText( 'Chocolate' ) ).not.toBeInTheDocument();
87
+ expect( screen.getByText( 'Vanilla' ) ).toBeInTheDocument();
88
+ } );
89
+
90
+ it( 'announces removal to screen readers', () => {
91
+ const { container } = render(
92
+ <FormAutocompleteMultiselect
93
+ forLabel="my_inline_chips_a11y"
94
+ label="Categories"
95
+ options={ options }
96
+ variant="inline-chips"
97
+ showAllValues
98
+ initialValue={ [ 'Chocolate', 'Vanilla' ] }
99
+ />
100
+ );
101
+ fireEvent.click( screen.getByRole( 'button', { name: 'Remove Chocolate' } ) );
102
+ const statusEl = container.querySelector( '#vip-autocompletemultiselect-status' );
103
+ expect( statusEl ).toHaveTextContent( 'Chocolate removed from the list.' );
104
+ } );
105
+ } );
@@ -0,0 +1,72 @@
1
+ /** @jsxImportSource theme-ui */
2
+
3
+ /**
4
+ * External dependencies
5
+ */
6
+ import { MdClose } from 'react-icons/md';
7
+
8
+ const FormAutocompleteMultiselectInlineChip = ( {
9
+ index,
10
+ option,
11
+ unselectValue,
12
+ }: {
13
+ index: number;
14
+ option: string;
15
+ unselectValue: ( option: string, index: number ) => void;
16
+ } ) => {
17
+ return (
18
+ <span
19
+ sx={ {
20
+ display: 'inline-flex',
21
+ alignItems: 'center',
22
+ gap: 1,
23
+ px: 2,
24
+ py: '2px',
25
+ m: 1,
26
+ bg: 'layer.1',
27
+ borderRadius: 1,
28
+ fontSize: 1,
29
+ lineHeight: '16px',
30
+ whiteSpace: 'nowrap',
31
+ maxWidth: '100%',
32
+ } }
33
+ >
34
+ <span
35
+ sx={ {
36
+ overflow: 'hidden',
37
+ textOverflow: 'ellipsis',
38
+ whiteSpace: 'nowrap',
39
+ } }
40
+ >
41
+ { option }
42
+ </span>
43
+ <button
44
+ type="button"
45
+ aria-label={ `Remove ${ option }` }
46
+ onClick={ e => {
47
+ e.preventDefault();
48
+ e.stopPropagation();
49
+ unselectValue( option, index );
50
+ } }
51
+ sx={ {
52
+ display: 'inline-flex',
53
+ alignItems: 'center',
54
+ justifyContent: 'center',
55
+ p: 0,
56
+ border: 'none',
57
+ bg: 'transparent',
58
+ cursor: 'pointer',
59
+ color: 'text',
60
+ lineHeight: 0,
61
+ '&:hover': {
62
+ opacity: 0.7,
63
+ },
64
+ } }
65
+ >
66
+ <MdClose size={ 14 } />
67
+ </button>
68
+ </span>
69
+ );
70
+ };
71
+
72
+ export { FormAutocompleteMultiselectInlineChip };