/**
* External dependencies
*/
// eslint-disable-next-line no-restricted-imports
import * as Ariakit from '@ariakit/react';
import removeAccents from 'remove-accents';
import clsx from 'clsx';
/**
* WordPress dependencies
*/
import { useInstanceId } from '@wordpress/compose';
import { __, sprintf } from '@wordpress/i18n';
import { useState, useMemo, useDeferredValue } from '@wordpress/element';
import {
VisuallyHidden,
Icon,
Composite,
Spinner,
} from '@wordpress/components';
import { search, check } from '@wordpress/icons';
/**
* Internal dependencies
*/
import { getCurrentValue } from './utils';
import type { Filter, NormalizedFilter, View, Option } from '../../types';
import useElements from '../../hooks/use-elements';
interface SearchWidgetProps {
view: View;
filter: NormalizedFilter & {
elements: Option[];
};
onChangeView: ( view: View ) => void;
}
function normalizeSearchInput( input = '' ) {
return removeAccents( input.trim().toLowerCase() );
}
const getNewValue = (
filterDefinition: NormalizedFilter,
currentFilter: Filter | undefined,
value: any
) => {
if ( filterDefinition.singleSelection ) {
return value;
}
if ( Array.isArray( currentFilter?.value ) ) {
return currentFilter.value.includes( value )
? currentFilter.value.filter( ( v ) => v !== value )
: [ ...currentFilter.value, value ];
}
return [ value ];
};
function generateFilterElementCompositeItemId(
prefix: string,
filterElementValue: string
) {
return `${ prefix }-${ filterElementValue }`;
}
const MultiSelectionOption = ( { selected }: { selected: boolean } ) => {
return (
{ selected && }
);
};
const SingleSelectionOption = ( { selected }: { selected: boolean } ) => {
return (
);
};
function ListBox( { view, filter, onChangeView }: SearchWidgetProps ) {
const baseId = useInstanceId( ListBox, 'dataviews-filter-list-box' );
const [ activeCompositeId, setActiveCompositeId ] = useState<
string | null | undefined
>(
// When there are one or less operators, the first item is set as active
// (by setting the initial `activeId` to `undefined`).
// With 2 or more operators, the focus is moved on the operators control
// (by setting the initial `activeId` to `null`), meaning that there won't
// be an active item initially. Focus is then managed via the
// `onFocusVisible` callback.
filter.operators?.length === 1 ? undefined : null
);
const currentFilter = view.filters?.find(
( f ) => f.field === filter.field
);
const currentValue = getCurrentValue( filter, currentFilter );
return (
{
// `onFocusVisible` needs the `Composite` component to be focusable,
// which is implicitly achieved via the `virtualFocus` prop.
if ( ! activeCompositeId && filter.elements.length ) {
setActiveCompositeId(
generateFilterElementCompositeItemId(
baseId,
filter.elements[ 0 ].value
)
);
}
} }
render={ }
>
{ filter.elements.map( ( element ) => (
}
onClick={ () => {
const newFilters = currentFilter
? [
...( view.filters ?? [] ).map(
( _filter ) => {
if (
_filter.field ===
filter.field
) {
return {
..._filter,
operator:
currentFilter.operator ||
filter
.operators[ 0 ],
value: getNewValue(
filter,
currentFilter,
element.value
),
};
}
return _filter;
}
),
]
: [
...( view.filters ?? [] ),
{
field: filter.field,
operator: filter.operators[ 0 ],
value: getNewValue(
filter,
currentFilter,
element.value
),
},
];
onChangeView( {
...view,
page: 1,
filters: newFilters,
} );
} }
/>
}
>
{ filter.singleSelection && (
) }
{ ! filter.singleSelection && (
) }
{ element.label }
) ) }
);
}
function ComboboxList( { view, filter, onChangeView }: SearchWidgetProps ) {
const [ searchValue, setSearchValue ] = useState( '' );
const deferredSearchValue = useDeferredValue( searchValue );
const currentFilter = view.filters?.find(
( _filter ) => _filter.field === filter.field
);
const currentValue = getCurrentValue( filter, currentFilter );
const matches = useMemo( () => {
const normalizedSearch = normalizeSearchInput( deferredSearchValue );
return filter.elements.filter( ( item ) =>
normalizeSearchInput( item.label ).includes( normalizedSearch )
);
}, [ filter.elements, deferredSearchValue ] );
return (
{
const newFilters = currentFilter
? [
...( view.filters ?? [] ).map( ( _filter ) => {
if ( _filter.field === filter.field ) {
return {
..._filter,
operator:
currentFilter.operator ||
filter.operators[ 0 ],
value,
};
}
return _filter;
} ),
]
: [
...( view.filters ?? [] ),
{
field: filter.field,
operator: filter.operators[ 0 ],
value,
},
];
onChangeView( {
...view,
page: 1,
filters: newFilters,
} );
} }
setValue={ setSearchValue }
>
{ __( 'Search items' ) }
}
>
{ __( 'Search items' ) }
{ matches.map( ( element ) => {
return (
{ filter.singleSelection && (
) }
{ ! filter.singleSelection && (
) }
{ !! element.description && (
{ element.description }
) }
);
} ) }
{ ! matches.length && { __( 'No results found' ) }
}
);
}
export default function SearchWidget( props: SearchWidgetProps ) {
const { elements, isLoading } = useElements( {
elements: props.filter.elements,
getElements: props.filter.getElements,
} );
if ( isLoading ) {
return (
);
}
if ( elements.length === 0 ) {
return (
{ __( 'No elements found' ) }
);
}
const Widget = elements.length > 10 ? ComboboxList : ListBox;
return ;
}