/**
* WordPress dependencies
*/
import { useEffect, useRef, useState } from '@safe-wordpress/element';
import { FormTokenField } from '@safe-wordpress/components';
import { sprintf, _x } from '@safe-wordpress/i18n';
import type { RefObject } from 'react';
/**
* External dependencies
*/
import { noop, isString, debounce } from 'lodash';
import { css, cx } from '@nelio/popups/css';
import {
useEntityKind,
useEntityRecords,
useEntityRecordSearch,
} from '@nelio/popups/hooks';
import type { Post, SearchQuery, Term } from '@nelio/popups/types';
type Kind = 'postType' | 'taxonomy';
type Item = {
readonly id: number;
readonly name: string;
};
export type SelectControlProps = {
readonly className?: string;
readonly kind: 'postType' | 'taxonomy';
readonly name: string;
readonly label?: string;
readonly placeholder?: string;
readonly value: ReadonlyArray< number >;
readonly onChange: ( value: ReadonlyArray< number > ) => void;
readonly disabled?: boolean;
readonly isSingle?: boolean;
};
export const ItemSelectControl = ( {
className,
kind,
name,
label,
placeholder,
value,
disabled,
isSingle,
onChange,
}: SelectControlProps ): JSX.Element => {
const [ autoExpand, setAutoExpand ] = useState( false ); // NOTE. Workaround.
const actualKind = useEntityKind( kind, name );
const hasSingleValue = isSingle && !! value.length;
const {
setQuery,
items: foundItems,
loadMoreItems,
} = useSearchResult( kind, name, { exclude: value } );
const ref = useRef< HTMLDivElement >( null );
useEffectOnScrollEnd( ref, loadMoreItems );
useEffectOnFocusAndBlur( ref, setAutoExpand );
const onSelectionChange = (
selection: ReadonlyArray< FormTokenField.Value | string >
): void => {
const str = selection.find( isString ) ?? '';
const item = findByName( str, foundItems );
onChange(
[ ...selection, { itemId: item?.id } ]
.filter( hasItemId )
.map( ( s ) => s.itemId )
);
};
const currentRecords = useEntityRecords( kind, name, value );
const selectedItems = [
...currentRecords.items.map( simplify ),
...( currentRecords.finished
? currentRecords.missingItems.map( makeMissingItem )
: currentRecords.pendingItems.map( makeLoadingItem ) ),
];
return (
p.name ) }
onInputChange={ setQuery }
onChange={ onSelectionChange }
maxLength={ isSingle ? 1 : undefined }
{ ...{
label: !! label ? label : '',
__experimentalShowHowTo: ! isSingle,
__experimentalExpandOnFocus: autoExpand && ! hasSingleValue,
placeholder: actualKind
? placeholder ??
_x( 'Search', 'command', 'nelio-popups' )
: _x( 'Loading…', 'text', 'nelio-popups' ),
} }
/>
);
};
// =====
// HOOKS
// =====
const useSearchResult = (
kind: Kind,
name: string,
searchQuery: SearchQuery
): {
setQuery: ( v: string ) => void;
items: ReadonlyArray< Item >;
loadMoreItems?: () => void;
} => {
const [ items, setItems ] = useState< ReadonlyArray< Item > >( [] );
const [ query, doSetQuery ] = useState( '' );
const [ page, setPage ] = useState( 1 );
const searchResult = useEntityRecordSearch( kind, name, {
...searchQuery,
search: query,
page,
nelio_popups_search_by_title: true,
} );
useEffect( () => {
if ( ! searchResult.finished ) {
return;
}
const cleanItems = searchResult.items.map( simplify );
setItems( 1 === page ? cleanItems : [ ...items, ...cleanItems ] );
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
// eslint-disable-next-line react-hooks/exhaustive-deps
searchQuery.exclude?.join( ',' ) ?? '',
searchResult.finished,
query,
page,
] );
return {
items: filterByQuery( { ...searchQuery, search: query }, items ),
loadMoreItems:
searchResult.finished && searchResult.more
? () => setPage( page + 1 )
: undefined,
setQuery: debounce( doSetQuery, 1000 ),
};
};
const useEffectOnScrollEnd = (
ref: RefObject< HTMLDivElement >,
callback = noop
) =>
useEffect( () => {
const onScroll = debounce(
( ev: Event ) =>
ev.target &&
isBottomScroll( ev.target as HTMLElement ) &&
callback(),
200
);
const opts = { capture: true };
ref.current?.addEventListener( 'scroll', onScroll, opts );
return () =>
// eslint-disable-next-line react-hooks/exhaustive-deps
ref.current?.removeEventListener( 'scroll', onScroll, opts );
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ callback, ref.current ] );
const useEffectOnFocusAndBlur = (
ref: RefObject< HTMLDivElement >,
callback: ( focus: boolean ) => void = noop
) =>
useEffect( () => {
const onFocus = () => callback( true );
const onBlur = () => callback( false );
const opts = { capture: true };
ref.current?.addEventListener( 'focus', onFocus, opts );
ref.current?.addEventListener( 'blur', onBlur, opts );
return () => {
ref.current?.removeEventListener( 'focus', onFocus, opts );
// eslint-disable-next-line react-hooks/exhaustive-deps
ref.current?.removeEventListener( 'blur', onBlur, opts );
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ callback, ref.current ] );
// =======
// HELPERS
// =======
const makeMissingItem = ( itemId: number ): Item => ( {
id: itemId,
/* translators: item id */
name: sprintf( _x( 'Missing item %d', 'text', 'nelio-popups' ), itemId ),
} );
const makeLoadingItem = ( itemId: number ): Item => ( {
id: itemId,
name: sprintf(
/* translators: an id */
_x( 'Loading item %d…', 'text', 'nelio-popups' ),
itemId
),
} );
const filterByQuery = (
searchQuery: SearchQuery,
items: ReadonlyArray< Item >
): ReadonlyArray< Item > => {
if ( searchQuery.search ) {
const { search } = searchQuery;
items = items.filter( ( item ) =>
item.name.toLowerCase().includes( search.toLowerCase() )
);
}
if ( searchQuery.exclude ) {
const { exclude } = searchQuery;
items = items.filter( ( { id } ) => ! exclude.includes( id ) );
}
return items;
};
const findByName = (
name: string,
items: ReadonlyArray< Item >
): Item | undefined =>
items.find( ( item ) => item.name.toLowerCase() === name.toLowerCase() );
const itemToFormValue = ( item: Item ): FormTokenField.Value =>
( {
itemId: item.id,
value: item.name,
title: item.name,
} ) as FormTokenField.Value;
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
const hasItemId = ( p: any ): p is { itemId: number } => !! p.itemId;
const getName = ( item: Post | Term ): string => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
const hasName = ( i: any ): i is { name: string } => !! i.name;
return hasName( item ) ? item.name : item.title.raw;
};
const simplify = ( item: Post | Term ): Item => ( {
id: item.id,
name: sprintf(
'%1$s (%2$d)',
getName( item ).replace( /,/g, '' ),
item.id
),
} );
const isBottomScroll = ( el: HTMLElement ): boolean =>
el.scrollHeight - el.scrollTop === el.clientHeight;
// ======
// STYLES
// ======
const CUSTOM_STYLE = css( {
'& .components-form-token-field__input-container': {
background: '#fff',
},
'& .components-form-token-field__help, & .components-form-token-field__label':
{
display: 'none',
},
'& ul.components-form-token-field__suggestions-list': {
margin: '0',
padding: '0',
},
} );
const NO_INPUT_STYLE = css( {
'& input[type="text"].components-form-token-field__input': {
height: '0!important',
minHeight: '0!important',
minWidth: '0!important',
opacity: '0',
width: '0!important',
},
} );