Add new ComboBox React component based on downshift

pull/9920/head
Thibaud Colas 2023-01-18 09:59:36 +00:00
rodzic 46d92d8711
commit 3a7e489cdf
8 zmienionych plików z 629 dodań i 51 usunięć

Wyświetl plik

@ -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';

Wyświetl plik

@ -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;
}
}

Wyświetl plik

@ -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);
});
});
});

Wyświetl plik

@ -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 its 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>
);
}

Wyświetl plik

@ -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])),
);
});
});
});

Wyświetl plik

@ -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;

107
package-lock.json wygenerowano
Wyświetl plik

@ -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": {

Wyświetl plik

@ -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",