Browse Source

Add new ComboBox React component based on downshift

Thibaud Colas 2 năm trước cách đây
mục cha
commit
3a7e489cdf

+ 1 - 0
client/scss/core.scss

@@ -101,6 +101,7 @@ These are classes for components.
 @import '../src/components/Transition/Transition';
 @import '../src/components/LoadingSpinner/LoadingSpinner';
 @import '../src/components/PublicationStatus/PublicationStatus';
+@import '../src/components/ComboBox/ComboBox';
 @import '../src/components/PageExplorer/PageExplorer';
 @import '../src/components/CommentApp/main';
 

+ 141 - 0
client/src/components/ComboBox/ComboBox.scss

@@ -0,0 +1,141 @@
+// Ensure consistent spacing across the whole component.
+// With the scrolling and show/hide of the field, correct spacing is critical.
+$spacing: theme('spacing.[2.5]');
+$spacing-sm: theme('spacing.5');
+
+.w-combobox {
+  width: min(400px, 80vw);
+  background: $color-white;
+  color: $color-input-text;
+  border-radius: theme('borderRadius.DEFAULT');
+  font-size: theme('fontSize.18');
+  box-shadow: theme('boxShadow.md');
+  outline: 10px solid transparent;
+}
+
+.w-combobox__field {
+  padding: $spacing;
+  padding-bottom: 0;
+
+  @include media-breakpoint-up(sm) {
+    padding: $spacing-sm;
+    padding-bottom: 0;
+  }
+}
+
+.w-combobox [role='combobox'] {
+  margin-bottom: $spacing-sm;
+
+  &[disabled] {
+    display: none;
+  }
+}
+
+.w-combobox__menu {
+  max-height: min(480px, 70vh);
+  overflow-y: scroll;
+}
+
+.w-combobox__optgroup {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  grid-auto-flow: column;
+  gap: theme('spacing.[0.5]');
+  padding: $spacing;
+  padding-top: 0;
+
+  @include media-breakpoint-up(sm) {
+    width: 400px;
+    padding: $spacing-sm;
+    padding-top: 0;
+  }
+}
+
+.w-combobox__optgroup-label {
+  @apply w-label-3;
+  grid-column: 1 / span 2;
+  margin-bottom: $spacing;
+  font-size: 1rem;
+  font-weight: 700;
+
+  @include media-breakpoint-up(sm) {
+    margin-bottom: $spacing-sm;
+  }
+
+  @media (forced-colors: active) {
+    color: GrayText;
+  }
+}
+
+.w-combobox__option {
+  display: grid;
+  grid-template-columns: theme('spacing.8') 1fr;
+  align-items: center;
+  padding: theme('spacing.1');
+  border: 1px solid transparent;
+  font-size: 0.875rem;
+  line-height: theme('lineHeight.tight');
+  border-radius: theme('borderRadius.sm');
+
+  &[aria-selected='true'] {
+    border-color: currentColor;
+    background: transparent;
+    cursor: pointer;
+
+    @media (forced-colors: active) {
+      background: Highlight;
+      color: HighlightText;
+    }
+  }
+}
+
+.w-combobox__option--col1 {
+  grid-column: 1 / span 1;
+}
+
+.w-combobox__option--col2 {
+  grid-column: 2 / span 1;
+}
+
+.w-combobox__option-icon {
+  color: theme('colors.grey.200');
+  height: theme('spacing.4');
+
+  .icon {
+    width: theme('spacing.4');
+    height: theme('spacing.4');
+  }
+
+  // Give more width to icons with wide visuals.
+  .icon-h1,
+  .icon-h2,
+  .icon-h3,
+  .icon-h4,
+  .icon-h5,
+  .icon-h6 {
+    width: theme('spacing.6');
+  }
+
+  // Explicitly override the selected color for SVG support.
+  [aria-selected='true'] & {
+    @media (forced-colors: active) {
+      color: inherit;
+    }
+  }
+}
+
+.w-combobox__option-text {
+  // Force to CanvasText even when highlighted, because the extra div
+  // makes WHCM add a mandatory Canvas background below the text.
+  @media (forced-colors: active) {
+    color: CanvasText;
+  }
+}
+
+.w-combobox__status {
+  padding: $spacing-sm;
+
+  @media (forced-colors: active) {
+    color: GrayText;
+  }
+}

+ 99 - 0
client/src/components/ComboBox/ComboBox.test.tsx

@@ -0,0 +1,99 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import ComboBox from './ComboBox';
+import Icon from '../Icon/Icon';
+
+const testProps = {
+  label: 'Search options…',
+  placeholder: 'Search options…',
+  getItemLabel: (_, item) => item.label,
+  getItemDescription: (item) => item.description,
+  getSearchFields: (item) => [item.label, item.description, item.type],
+  noResultsText: 'No results, sorry!',
+  onSelect: () => {},
+};
+
+describe('ComboBox', () => {
+  it('renders empty', () => {
+    const wrapper = shallow(<ComboBox {...testProps} items={[]} />);
+    expect(wrapper.find('.w-combobox__status').text()).toBe(
+      'No results, sorry!',
+    );
+  });
+
+  describe('rendering', () => {
+    let items;
+    let wrapper;
+
+    beforeEach(() => {
+      items = [
+        {
+          type: 'blockTypes',
+          label: 'Blocks',
+          items: [
+            {
+              type: 'blockquote',
+              description: 'Blockquote',
+              icon: 'blockquote',
+            },
+            {
+              type: 'paragraph',
+              description: 'Paragraph',
+              icon: <span className="custom-icon">P</span>,
+            },
+            {
+              type: 'heading-one',
+              label: 'H1',
+              description: 'Heading 1',
+            },
+            {
+              type: 'heading-two',
+              label: 'H2',
+              render: ({ option }) => (
+                <span className="custom-text">{option.label}</span>
+              ),
+            },
+          ],
+        },
+        {
+          type: 'entityTypes',
+          items: [
+            {
+              type: 'link',
+              label: '🔗',
+              description: 'Link',
+            },
+          ],
+        },
+      ];
+      wrapper = shallow(<ComboBox {...testProps} items={items} />);
+    });
+
+    it('shows items', () => {
+      const options = wrapper.find('.w-combobox__option-text');
+      expect(options).toHaveLength(
+        items[0].items.length + items[1].items.length,
+      );
+      expect(options.at(0).text()).toBe('Blockquote');
+    });
+
+    it('uses Icon component', () => {
+      expect(wrapper.find(Icon).at(0).prop('name')).toBe('blockquote');
+    });
+
+    it('supports custom icons', () => {
+      expect(wrapper.find('.custom-icon').text()).toBe('P');
+    });
+
+    it('supports label as icon', () => {
+      expect(wrapper.find('.custom-text').text()).toBe('H2');
+    });
+
+    it('combines two categories into one, with two columns', () => {
+      expect(wrapper.find('.w-combobox__optgroup-label')).toHaveLength(1);
+      expect(wrapper.find('.w-combobox__option--col1')).toHaveLength(3);
+      expect(wrapper.find('.w-combobox__option--col2')).toHaveLength(2);
+    });
+  });
+});

+ 240 - 0
client/src/components/ComboBox/ComboBox.tsx

@@ -0,0 +1,240 @@
+import React, { useEffect, useState } from 'react';
+import {
+  useCombobox,
+  UseComboboxStateChange,
+  UseComboboxStateChangeTypes,
+} from 'downshift';
+
+import { gettext } from '../../utils/gettext';
+import Icon from '../Icon/Icon';
+
+import findMatches from './findMatches';
+
+export const comboBoxTriggerLabel = gettext('Insert a block');
+export const comboBoxLabel = gettext('Search options…');
+export const comboBoxNoResults = gettext('No results');
+
+export interface ComboBoxCategory<ItemType> {
+  type: string;
+  label: string | null;
+  items: ItemType[];
+}
+
+export interface ComboBoxItem {
+  type?: string;
+  label?: string | null;
+  description?: string | null;
+  icon?: string | JSX.Element | null;
+  category?: string;
+  render?: (props: { option: ComboBoxItem }) => JSX.Element | string;
+}
+
+export { UseComboboxStateChange };
+
+export type ComboBoxStateChange = UseComboboxStateChange<ComboBoxItem>;
+
+export interface ComboBoxProps<ComboBoxOption> {
+  label?: string;
+  placeholder?: string;
+  inputValue?: string;
+  items: ComboBoxCategory<ComboBoxOption>[];
+  getItemLabel: (
+    type: string | undefined,
+    item: ComboBoxOption,
+  ) => string | null | undefined;
+  getItemDescription: (item: ComboBoxOption) => string | null | undefined;
+  getSearchFields: (item: ComboBoxOption) => (string | null | undefined)[];
+  onSelect: (change: UseComboboxStateChange<ComboBoxOption>) => void;
+  noResultsText?: string;
+}
+
+/**
+ * Generic ComboBox component built with downshift, with a 2-column layout.
+ */
+export default function ComboBox<ComboBoxOption extends ComboBoxItem>({
+  label,
+  placeholder,
+  inputValue,
+  items,
+  getItemLabel,
+  getItemDescription,
+  getSearchFields,
+  onSelect,
+  noResultsText,
+}: ComboBoxProps<ComboBoxOption>) {
+  // If there is no label defined, we treat the combobox as not needing its own field.
+  const inlineCombobox = !label;
+  const flatItems = items.flatMap<ComboBoxOption>(
+    (category) => category.items || [],
+  );
+  const [inputItems, setInputItems] = useState<ComboBoxOption[]>(flatItems);
+  // Re-create the categories so the two-column layout flows as expected.
+  const categories = items.reduce<ComboBoxCategory<ComboBoxOption>[]>(
+    (cats, cat, index) => {
+      if (cat.label || index === 0) {
+        return [...cats, { ...cat, items: cat.items.slice() }];
+      }
+
+      // eslint-disable-next-line no-param-reassign
+      cats[index - 1].items = cats[index - 1].items.concat(cat.items);
+
+      return cats;
+    },
+    [],
+  );
+  const noResults = inputItems.length === 0;
+  const {
+    getLabelProps,
+    getMenuProps,
+    getInputProps,
+    getItemProps,
+    setHighlightedIndex,
+    setInputValue,
+    openMenu,
+  } = useCombobox<ComboBoxOption>({
+    ...(typeof inputValue !== 'undefined' && { inputValue }),
+    initialInputValue: inputValue || '',
+    items: inputItems,
+    itemToString(item: ComboBoxOption | null) {
+      if (!item) {
+        return '';
+      }
+
+      return getItemDescription(item) || getItemLabel(item.type, item) || '';
+    },
+    selectedItem: null,
+
+    onSelectedItemChange: onSelect,
+
+    onInputValueChange: (changes) => {
+      const { inputValue: val } = changes;
+      if (!val) {
+        setInputItems(flatItems);
+        return;
+      }
+
+      const filtered = findMatches<ComboBoxOption>(
+        flatItems,
+        getSearchFields,
+        val,
+      );
+      setInputItems(filtered);
+      // Always reset the first item to highlighted on filtering, to speed up selection.
+      setHighlightedIndex(0);
+    },
+  });
+
+  useEffect(() => {
+    if (inputValue) {
+      openMenu();
+      setInputValue(inputValue);
+      const filtered = findMatches<ComboBoxOption>(
+        flatItems,
+        getSearchFields,
+        inputValue,
+      );
+      setInputItems(filtered);
+      // Always reset the first item to highlighted on filtering, to speed up selection.
+      setHighlightedIndex(0);
+    } else {
+      setInputValue('');
+      setInputItems(flatItems);
+      setHighlightedIndex(-1);
+    }
+  }, [inputValue]);
+
+  return (
+    <div className="w-combobox">
+      {/* downshift does the label-field association itself. */}
+      {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
+      <label {...getLabelProps()} className="w-sr-only">
+        {label}
+      </label>
+      <div className="w-combobox__field">
+        <input
+          {...getInputProps()}
+          type="text"
+          // Prevent the field from receiving focus if it’s not visible.
+          disabled={inlineCombobox}
+          placeholder={placeholder}
+        />
+      </div>
+      {noResults ? (
+        <div className="w-combobox__status">{noResultsText}</div>
+      ) : null}
+      <div {...getMenuProps()} className="w-combobox__menu">
+        {categories.map((category) => {
+          const categoryItems = (category.items || []).filter((item) =>
+            inputItems.find((i) => i.type === item.type),
+          );
+          const itemColumns = Math.ceil(categoryItems.length / 2);
+
+          if (categoryItems.length === 0) {
+            return null;
+          }
+
+          return (
+            <div className="w-combobox__optgroup" key={category.type}>
+              {category.label ? (
+                <div className="w-combobox__optgroup-label">
+                  {category.label}
+                </div>
+              ) : null}
+              {categoryItems.map((item, index) => {
+                const itemLabel = getItemLabel(item.type, item);
+                const description = getItemDescription(item);
+                const itemIndex = inputItems.findIndex(
+                  (i) => i.type === item.type,
+                );
+                const itemColumn = index + 1 <= itemColumns ? 1 : 2;
+                const hasIcon =
+                  typeof item.icon !== 'undefined' && item.icon !== null;
+                let icon: JSX.Element | null | undefined = null;
+
+                if (hasIcon) {
+                  icon =
+                    typeof item.icon === 'string' ? (
+                      <Icon name={item.icon} />
+                    ) : (
+                      item.icon
+                    );
+                }
+
+                const onMouseDown = (e) => {
+                  e.stopPropagation();
+                  onSelect({
+                    selectedItem: item,
+                    type: '__item_click__' as UseComboboxStateChangeTypes.ItemClick,
+                  });
+                };
+
+                return (
+                  // Side-step Downshift event handling and trigger selection on mouse down for clicks,
+                  // so we preserve keyboard focus when used within rich text editors.
+                  // eslint-disable-next-line jsx-a11y/no-static-element-interactions
+                  <div
+                    key={item.type}
+                    {...getItemProps({ item, index: itemIndex })}
+                    onMouseDown={onMouseDown}
+                    className={`w-combobox__option w-combobox__option--col${itemColumn}`}
+                  >
+                    <div className="w-combobox__option-icon">
+                      {icon}
+                      {/* Support for rich text options using text as an icon (for example "B" for bold). */}
+                      {itemLabel && !hasIcon ? <span>{itemLabel}</span> : null}
+                    </div>
+                    <div className="w-combobox__option-text">
+                      {item.render
+                        ? item.render({ option: item })
+                        : description}
+                    </div>
+                  </div>
+                );
+              })}
+            </div>
+          );
+        })}
+      </div>
+    </div>
+  );
+}

+ 44 - 0
client/src/components/ComboBox/findMatches.test.ts

@@ -0,0 +1,44 @@
+import findMatches, { contains } from './findMatches';
+
+describe('findMatches', () => {
+  describe.each`
+    label                           | string    | substring   | result
+    ${'full match'}                 | ${'abcä'} | ${'abcä'}   | ${true}
+    ${'start match'}                | ${'abcä'} | ${'ab'}     | ${true}
+    ${'end match'}                  | ${'abcä'} | ${'cä'}     | ${true}
+    ${'base full match'}            | ${'abcä'} | ${'abca'}   | ${true}
+    ${'base partial match'}         | ${'abcä'} | ${'ca'}     | ${true}
+    ${'base full match reverse'}    | ${'abca'} | ${'abcä'}   | ${true}
+    ${'base partial match reverse'} | ${'abca'} | ${'cä'}     | ${true}
+    ${'no match'}                   | ${'abcä'} | ${'potato'} | ${false}
+  `('contains', ({ label, string, substring, result }) => {
+    test(label, () => {
+      expect(contains(string, substring)).toBe(result);
+    });
+  });
+
+  const findMatchesItems = [
+    { label: 'label', desc: '' },
+    { label: '', desc: 'description' },
+    { label: 'abcä', desc: 'abcä' },
+    { label: 'abca', desc: 'abca' },
+    { label: 'ab', desc: 'ab' },
+    { label: null, desc: null },
+    { label: undefined, desc: undefined },
+  ];
+
+  describe.each`
+    label                 | input            | results
+    ${'one match label'}  | ${'label'}       | ${[0]}
+    ${'one match desc'}   | ${'description'} | ${[1]}
+    ${'multiple matches'} | ${'ab'}          | ${[2, 3, 4]}
+    ${'base match'}       | ${'ca'}          | ${[2, 3]}
+  `('findMatches', ({ label, input, results }) => {
+    test(label, () => {
+      const getSearchFields = (i) => [i.label, i.desc];
+      expect(findMatches(findMatchesItems, getSearchFields, input)).toEqual(
+        expect.arrayContaining(results.map((i) => findMatchesItems[i])),
+      );
+    });
+  });
+});

+ 47 - 0
client/src/components/ComboBox/findMatches.ts

@@ -0,0 +1,47 @@
+// Language-sensitive string comparison.
+// See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Collator/Collator.
+const collator = new Intl.Collator(undefined, {
+  usage: 'search',
+  sensitivity: 'base',
+  ignorePunctuation: true,
+});
+
+/**
+ * Whether a string contains a subsring, with case-insensitive, locale-insensitive search.
+ * See https://github.com/adobe/react-spectrum/blob/70e769acf639fc4ef3a704cb8fad81349cb4137a/packages/%40react-aria/i18n/src/useFilter.ts#L57.
+ * See also https://github.com/arty-name/locale-index-of,
+ * and https://github.com/tc39/ecma402/issues/506.
+ */
+export const contains = (string: string, substring: string) => {
+  if (substring.length === 0) {
+    return true;
+  }
+
+  const haystack = string.normalize('NFC');
+  const needle = substring.normalize('NFC');
+
+  for (let scan = 0; scan + needle.length <= haystack.length; scan += 1) {
+    const slice = haystack.slice(scan, scan + needle.length);
+    if (collator.compare(needle, slice) === 0) {
+      return true;
+    }
+  }
+
+  return false;
+};
+
+/**
+ * Find all items where a search field matches the input.
+ */
+const findMatches = <T extends object>(
+  items: T[],
+  getSearchFields: (item: T) => (string | null | undefined)[],
+  input: string,
+) =>
+  items.filter((item) => {
+    const matches = getSearchFields(item);
+
+    return matches.some((match) => match && contains(match, input));
+  });
+
+export default findMatches;

+ 56 - 51
package-lock.json

@@ -12,6 +12,7 @@
         "@tippyjs/react": "^4.2.6",
         "a11y-dialog": "^7.4.0",
         "axe-core": "^4.6.2",
+        "downshift": "^7.2.0",
         "draft-js": "^0.10.5",
         "draftail": "^2.0.0-rc.5",
         "draftjs-filters": "^3.0.1",
@@ -13168,6 +13169,28 @@
         "react-dom": "^16.8.0 || ^17.0.0"
       }
     },
+    "node_modules/@storybook/ui/node_modules/compute-scroll-into-view": {
+      "version": "1.0.20",
+      "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz",
+      "integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==",
+      "dev": true
+    },
+    "node_modules/@storybook/ui/node_modules/downshift": {
+      "version": "6.1.12",
+      "resolved": "https://registry.npmjs.org/downshift/-/downshift-6.1.12.tgz",
+      "integrity": "sha512-7XB/iaSJVS4T8wGFT3WRXmSF1UlBHAA40DshZtkrIscIN+VC+Lh363skLxFTvJwtNgHxAMDGEHT4xsyQFWL+UA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/runtime": "^7.14.8",
+        "compute-scroll-into-view": "^1.0.17",
+        "prop-types": "^15.7.2",
+        "react-is": "^17.0.2",
+        "tslib": "^2.3.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.12.0"
+      }
+    },
     "node_modules/@tippyjs/react": {
       "version": "4.2.6",
       "license": "MIT",
@@ -16539,9 +16562,9 @@
       "license": "MIT"
     },
     "node_modules/compute-scroll-into-view": {
-      "version": "1.0.17",
-      "dev": true,
-      "license": "MIT"
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-2.0.4.tgz",
+      "integrity": "sha512-y/ZA3BGnxoM/QHHQ2Uy49CLtnWPbt4tTPpEEZiEmmiWBFKjej7nEyH8Ryz54jH0MLXflUYA3Er2zUxPSJu5R+g=="
     },
     "node_modules/concat-map": {
       "version": "0.0.1",
@@ -17962,12 +17985,12 @@
       "license": "BSD-2-Clause"
     },
     "node_modules/downshift": {
-      "version": "6.1.7",
-      "dev": true,
-      "license": "MIT",
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/downshift/-/downshift-7.2.0.tgz",
+      "integrity": "sha512-dEn1Sshe7iTelUhmdbmiJhtIiwIBxBV8p15PuvEBh0qZcHXZnEt0geuCIIkCL4+ooaKRuLE0Wc+Fz9SwWuBIyg==",
       "dependencies": {
         "@babel/runtime": "^7.14.8",
-        "compute-scroll-into-view": "^1.0.17",
+        "compute-scroll-into-view": "^2.0.4",
         "prop-types": "^15.7.2",
         "react-is": "^17.0.2",
         "tslib": "^2.3.0"
@@ -18023,26 +18046,6 @@
         "react-dom": "^16.6.0"
       }
     },
-    "node_modules/draftail/node_modules/compute-scroll-into-view": {
-      "version": "2.0.4",
-      "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-2.0.4.tgz",
-      "integrity": "sha512-y/ZA3BGnxoM/QHHQ2Uy49CLtnWPbt4tTPpEEZiEmmiWBFKjej7nEyH8Ryz54jH0MLXflUYA3Er2zUxPSJu5R+g=="
-    },
-    "node_modules/draftail/node_modules/downshift": {
-      "version": "7.2.0",
-      "resolved": "https://registry.npmjs.org/downshift/-/downshift-7.2.0.tgz",
-      "integrity": "sha512-dEn1Sshe7iTelUhmdbmiJhtIiwIBxBV8p15PuvEBh0qZcHXZnEt0geuCIIkCL4+ooaKRuLE0Wc+Fz9SwWuBIyg==",
-      "dependencies": {
-        "@babel/runtime": "^7.14.8",
-        "compute-scroll-into-view": "^2.0.4",
-        "prop-types": "^15.7.2",
-        "react-is": "^17.0.2",
-        "tslib": "^2.3.0"
-      },
-      "peerDependencies": {
-        "react": ">=16.12.0"
-      }
-    },
     "node_modules/draftjs-conductor": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/draftjs-conductor/-/draftjs-conductor-3.0.0.tgz",
@@ -40740,6 +40743,25 @@
             "resolve-from": "^5.0.0",
             "ts-dedent": "^2.0.0"
           }
+        },
+        "compute-scroll-into-view": {
+          "version": "1.0.20",
+          "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz",
+          "integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==",
+          "dev": true
+        },
+        "downshift": {
+          "version": "6.1.12",
+          "resolved": "https://registry.npmjs.org/downshift/-/downshift-6.1.12.tgz",
+          "integrity": "sha512-7XB/iaSJVS4T8wGFT3WRXmSF1UlBHAA40DshZtkrIscIN+VC+Lh363skLxFTvJwtNgHxAMDGEHT4xsyQFWL+UA==",
+          "dev": true,
+          "requires": {
+            "@babel/runtime": "^7.14.8",
+            "compute-scroll-into-view": "^1.0.17",
+            "prop-types": "^15.7.2",
+            "react-is": "^17.0.2",
+            "tslib": "^2.3.0"
+          }
         }
       }
     },
@@ -43100,8 +43122,9 @@
       }
     },
     "compute-scroll-into-view": {
-      "version": "1.0.17",
-      "dev": true
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-2.0.4.tgz",
+      "integrity": "sha512-y/ZA3BGnxoM/QHHQ2Uy49CLtnWPbt4tTPpEEZiEmmiWBFKjej7nEyH8Ryz54jH0MLXflUYA3Er2zUxPSJu5R+g=="
     },
     "concat-map": {
       "version": "0.0.1",
@@ -44086,11 +44109,12 @@
       "dev": true
     },
     "downshift": {
-      "version": "6.1.7",
-      "dev": true,
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/downshift/-/downshift-7.2.0.tgz",
+      "integrity": "sha512-dEn1Sshe7iTelUhmdbmiJhtIiwIBxBV8p15PuvEBh0qZcHXZnEt0geuCIIkCL4+ooaKRuLE0Wc+Fz9SwWuBIyg==",
       "requires": {
         "@babel/runtime": "^7.14.8",
-        "compute-scroll-into-view": "^1.0.17",
+        "compute-scroll-into-view": "^2.0.4",
         "prop-types": "^15.7.2",
         "react-is": "^17.0.2",
         "tslib": "^2.3.0"
@@ -44125,25 +44149,6 @@
         "draft-js-plugins-editor": "^2.1.1",
         "draftjs-conductor": "^3.0.0",
         "draftjs-filters": "^3.0.1"
-      },
-      "dependencies": {
-        "compute-scroll-into-view": {
-          "version": "2.0.4",
-          "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-2.0.4.tgz",
-          "integrity": "sha512-y/ZA3BGnxoM/QHHQ2Uy49CLtnWPbt4tTPpEEZiEmmiWBFKjej7nEyH8Ryz54jH0MLXflUYA3Er2zUxPSJu5R+g=="
-        },
-        "downshift": {
-          "version": "7.2.0",
-          "resolved": "https://registry.npmjs.org/downshift/-/downshift-7.2.0.tgz",
-          "integrity": "sha512-dEn1Sshe7iTelUhmdbmiJhtIiwIBxBV8p15PuvEBh0qZcHXZnEt0geuCIIkCL4+ooaKRuLE0Wc+Fz9SwWuBIyg==",
-          "requires": {
-            "@babel/runtime": "^7.14.8",
-            "compute-scroll-into-view": "^2.0.4",
-            "prop-types": "^15.7.2",
-            "react-is": "^17.0.2",
-            "tslib": "^2.3.0"
-          }
-        }
       }
     },
     "draftjs-conductor": {

+ 1 - 0
package.json

@@ -105,6 +105,7 @@
     "@tippyjs/react": "^4.2.6",
     "a11y-dialog": "^7.4.0",
     "axe-core": "^4.6.2",
+    "downshift": "^7.2.0",
     "draft-js": "^0.10.5",
     "draftail": "^2.0.0-rc.5",
     "draftjs-filters": "^3.0.1",