diff --git a/client/scss/core.scss b/client/scss/core.scss index d651297363..0e1de91ab6 100644 --- a/client/scss/core.scss +++ b/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'; diff --git a/client/src/components/ComboBox/ComboBox.scss b/client/src/components/ComboBox/ComboBox.scss new file mode 100644 index 0000000000..dc4d0e73e0 --- /dev/null +++ b/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; + } +} diff --git a/client/src/components/ComboBox/ComboBox.test.tsx b/client/src/components/ComboBox/ComboBox.test.tsx new file mode 100644 index 0000000000..8bb2ea716e --- /dev/null +++ b/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(); + 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: P, + }, + { + type: 'heading-one', + label: 'H1', + description: 'Heading 1', + }, + { + type: 'heading-two', + label: 'H2', + render: ({ option }) => ( + {option.label} + ), + }, + ], + }, + { + type: 'entityTypes', + items: [ + { + type: 'link', + label: '🔗', + description: 'Link', + }, + ], + }, + ]; + wrapper = shallow(); + }); + + 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); + }); + }); +}); diff --git a/client/src/components/ComboBox/ComboBox.tsx b/client/src/components/ComboBox/ComboBox.tsx new file mode 100644 index 0000000000..b2640de6fb --- /dev/null +++ b/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 { + 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; + +export interface ComboBoxProps { + label?: string; + placeholder?: string; + inputValue?: string; + items: ComboBoxCategory[]; + getItemLabel: ( + type: string | undefined, + item: ComboBoxOption, + ) => string | null | undefined; + getItemDescription: (item: ComboBoxOption) => string | null | undefined; + getSearchFields: (item: ComboBoxOption) => (string | null | undefined)[]; + onSelect: (change: UseComboboxStateChange) => void; + noResultsText?: string; +} + +/** + * Generic ComboBox component built with downshift, with a 2-column layout. + */ +export default function ComboBox({ + label, + placeholder, + inputValue, + items, + getItemLabel, + getItemDescription, + getSearchFields, + onSelect, + noResultsText, +}: ComboBoxProps) { + // If there is no label defined, we treat the combobox as not needing its own field. + const inlineCombobox = !label; + const flatItems = items.flatMap( + (category) => category.items || [], + ); + const [inputItems, setInputItems] = useState(flatItems); + // Re-create the categories so the two-column layout flows as expected. + const categories = items.reduce[]>( + (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({ + ...(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( + 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( + 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 ( +
+ {/* downshift does the label-field association itself. */} + {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + +
+ +
+ {noResults ? ( +
{noResultsText}
+ ) : null} +
+ {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 ( +
+ {category.label ? ( +
+ {category.label} +
+ ) : 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' ? ( + + ) : ( + 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 +
+
+ {icon} + {/* Support for rich text options using text as an icon (for example "B" for bold). */} + {itemLabel && !hasIcon ? {itemLabel} : null} +
+
+ {item.render + ? item.render({ option: item }) + : description} +
+
+ ); + })} +
+ ); + })} +
+
+ ); +} diff --git a/client/src/components/ComboBox/findMatches.test.ts b/client/src/components/ComboBox/findMatches.test.ts new file mode 100644 index 0000000000..afade6eab5 --- /dev/null +++ b/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])), + ); + }); + }); +}); diff --git a/client/src/components/ComboBox/findMatches.ts b/client/src/components/ComboBox/findMatches.ts new file mode 100644 index 0000000000..a818b35bd2 --- /dev/null +++ b/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 = ( + 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; diff --git a/package-lock.json b/package-lock.json index e386ee7eaa..d326fb44a0 100644 --- a/package-lock.json +++ b/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": { diff --git a/package.json b/package.json index 41568d12db..44fa372d77 100644 --- a/package.json +++ b/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",