kopia lustrzana https://github.com/wagtail/wagtail
Add new ComboBox React component based on downshift
rodzic
46d92d8711
commit
3a7e489cdf
|
@ -101,6 +101,7 @@ These are classes for components.
|
||||||
@import '../src/components/Transition/Transition';
|
@import '../src/components/Transition/Transition';
|
||||||
@import '../src/components/LoadingSpinner/LoadingSpinner';
|
@import '../src/components/LoadingSpinner/LoadingSpinner';
|
||||||
@import '../src/components/PublicationStatus/PublicationStatus';
|
@import '../src/components/PublicationStatus/PublicationStatus';
|
||||||
|
@import '../src/components/ComboBox/ComboBox';
|
||||||
@import '../src/components/PageExplorer/PageExplorer';
|
@import '../src/components/PageExplorer/PageExplorer';
|
||||||
@import '../src/components/CommentApp/main';
|
@import '../src/components/CommentApp/main';
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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])),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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;
|
|
@ -12,6 +12,7 @@
|
||||||
"@tippyjs/react": "^4.2.6",
|
"@tippyjs/react": "^4.2.6",
|
||||||
"a11y-dialog": "^7.4.0",
|
"a11y-dialog": "^7.4.0",
|
||||||
"axe-core": "^4.6.2",
|
"axe-core": "^4.6.2",
|
||||||
|
"downshift": "^7.2.0",
|
||||||
"draft-js": "^0.10.5",
|
"draft-js": "^0.10.5",
|
||||||
"draftail": "^2.0.0-rc.5",
|
"draftail": "^2.0.0-rc.5",
|
||||||
"draftjs-filters": "^3.0.1",
|
"draftjs-filters": "^3.0.1",
|
||||||
|
@ -13168,6 +13169,28 @@
|
||||||
"react-dom": "^16.8.0 || ^17.0.0"
|
"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": {
|
"node_modules/@tippyjs/react": {
|
||||||
"version": "4.2.6",
|
"version": "4.2.6",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
@ -16539,9 +16562,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/compute-scroll-into-view": {
|
"node_modules/compute-scroll-into-view": {
|
||||||
"version": "1.0.17",
|
"version": "2.0.4",
|
||||||
"dev": true,
|
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-2.0.4.tgz",
|
||||||
"license": "MIT"
|
"integrity": "sha512-y/ZA3BGnxoM/QHHQ2Uy49CLtnWPbt4tTPpEEZiEmmiWBFKjej7nEyH8Ryz54jH0MLXflUYA3Er2zUxPSJu5R+g=="
|
||||||
},
|
},
|
||||||
"node_modules/concat-map": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
|
@ -17962,12 +17985,12 @@
|
||||||
"license": "BSD-2-Clause"
|
"license": "BSD-2-Clause"
|
||||||
},
|
},
|
||||||
"node_modules/downshift": {
|
"node_modules/downshift": {
|
||||||
"version": "6.1.7",
|
"version": "7.2.0",
|
||||||
"dev": true,
|
"resolved": "https://registry.npmjs.org/downshift/-/downshift-7.2.0.tgz",
|
||||||
"license": "MIT",
|
"integrity": "sha512-dEn1Sshe7iTelUhmdbmiJhtIiwIBxBV8p15PuvEBh0qZcHXZnEt0geuCIIkCL4+ooaKRuLE0Wc+Fz9SwWuBIyg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.14.8",
|
"@babel/runtime": "^7.14.8",
|
||||||
"compute-scroll-into-view": "^1.0.17",
|
"compute-scroll-into-view": "^2.0.4",
|
||||||
"prop-types": "^15.7.2",
|
"prop-types": "^15.7.2",
|
||||||
"react-is": "^17.0.2",
|
"react-is": "^17.0.2",
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
|
@ -18023,26 +18046,6 @@
|
||||||
"react-dom": "^16.6.0"
|
"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": {
|
"node_modules/draftjs-conductor": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/draftjs-conductor/-/draftjs-conductor-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/draftjs-conductor/-/draftjs-conductor-3.0.0.tgz",
|
||||||
|
@ -40740,6 +40743,25 @@
|
||||||
"resolve-from": "^5.0.0",
|
"resolve-from": "^5.0.0",
|
||||||
"ts-dedent": "^2.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": {
|
"compute-scroll-into-view": {
|
||||||
"version": "1.0.17",
|
"version": "2.0.4",
|
||||||
"dev": true
|
"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": {
|
"concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
|
@ -44086,11 +44109,12 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"downshift": {
|
"downshift": {
|
||||||
"version": "6.1.7",
|
"version": "7.2.0",
|
||||||
"dev": true,
|
"resolved": "https://registry.npmjs.org/downshift/-/downshift-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-dEn1Sshe7iTelUhmdbmiJhtIiwIBxBV8p15PuvEBh0qZcHXZnEt0geuCIIkCL4+ooaKRuLE0Wc+Fz9SwWuBIyg==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@babel/runtime": "^7.14.8",
|
"@babel/runtime": "^7.14.8",
|
||||||
"compute-scroll-into-view": "^1.0.17",
|
"compute-scroll-into-view": "^2.0.4",
|
||||||
"prop-types": "^15.7.2",
|
"prop-types": "^15.7.2",
|
||||||
"react-is": "^17.0.2",
|
"react-is": "^17.0.2",
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
|
@ -44125,25 +44149,6 @@
|
||||||
"draft-js-plugins-editor": "^2.1.1",
|
"draft-js-plugins-editor": "^2.1.1",
|
||||||
"draftjs-conductor": "^3.0.0",
|
"draftjs-conductor": "^3.0.0",
|
||||||
"draftjs-filters": "^3.0.1"
|
"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": {
|
"draftjs-conductor": {
|
||||||
|
|
|
@ -105,6 +105,7 @@
|
||||||
"@tippyjs/react": "^4.2.6",
|
"@tippyjs/react": "^4.2.6",
|
||||||
"a11y-dialog": "^7.4.0",
|
"a11y-dialog": "^7.4.0",
|
||||||
"axe-core": "^4.6.2",
|
"axe-core": "^4.6.2",
|
||||||
|
"downshift": "^7.2.0",
|
||||||
"draft-js": "^0.10.5",
|
"draft-js": "^0.10.5",
|
||||||
"draftail": "^2.0.0-rc.5",
|
"draftail": "^2.0.0-rc.5",
|
||||||
"draftjs-filters": "^3.0.1",
|
"draftjs-filters": "^3.0.1",
|
||||||
|
|
Ładowanie…
Reference in New Issue