Skip to content

Commit

Permalink
Merge pull request #2501 from epam/feature/picker-input-min-chars-ope…
Browse files Browse the repository at this point in the history
…n-body

[PickerInput]: Added possibility to type symbols in search field for multiselect when minCharsToSearch > 0 and searchPosition in the body
  • Loading branch information
AlekseyManetov authored Oct 9, 2024
2 parents 6eca172 + 02e05f3 commit 716a2d6
Show file tree
Hide file tree
Showing 16 changed files with 737 additions and 85 deletions.
6 changes: 5 additions & 1 deletion changelog.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# 5.*.* - **.**.****

**What's New**
* [PickerInput]:
* Added support of `minCharsToSearch` > 0 with `searchPosition = 'body'`.
* Added renderEmpty prop to render custom empty block for depends on various reasons.
* `renderNotFonud` prop is deprecated, please
* Sass updated to the last version, warnings 'Mixed Declarations' fixed https://sass-lang.com/documentation/breaking-changes/mixed-decls/
* [DataTable]: - `ColumnsConfigurationModal` - updated modal width from 420px to 560px according design, 'disabled' state for locked columns is changed to 'readonly', added vertical paddings to multiline column names.
* [Modals]: for mobile view (width is up to 720px) by default the modal position is fixed at the bottom edge of the screen
Expand Down Expand Up @@ -38,7 +42,7 @@
* [LinkButton]: added `weight` and `underline` props
* [DataTable]: disable animation for loading skeletons due to performance issues
* [DatePickers]: added 'DDMMYYYY' format to the list of supported date formats for parsing user input
* Uploaded new version of icons pack:
* Uploaded new version of icons pack:
* icons added: action-clock_fast-fill, action-clock_fast-outline
* icons updated (visual weight tweaked, icon size was slightly decreased): action-job_function-fill, action-job_function-outline, communication-mail-fill, communication-mail-outline

Expand Down
3 changes: 1 addition & 2 deletions uui-components/src/pickers/KeyboardUtils.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { DataSourceState, DataRowProps, IEditable, IDataSourceView } from '@epam/uui-core';
import { PickerInputSearchPosition } from './hooks/types';
import { DataSourceState, DataRowProps, IEditable, IDataSourceView, PickerInputSearchPosition } from '@epam/uui-core';

interface DataSourceKeyboardParams extends IEditable<DataSourceState> {
listView: IDataSourceView<any, any, any>;
Expand Down
10 changes: 3 additions & 7 deletions uui-components/src/pickers/PickerBodyBase.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,15 @@
import * as React from 'react';
import isEqual from 'react-fast-compare';

import {
DataSourceListProps, DataSourceState, IEditable, IHasRawProps, isMobile,
} from '@epam/uui-core';
import { DataSourceListProps, DataSourceState, IEditable, IHasRawProps, isMobile } from '@epam/uui-core';
import { PickerInputBaseProps } from './hooks/types';

export interface PickerBodyBaseProps extends DataSourceListProps, IEditable<DataSourceState>, IHasRawProps<React.HTMLAttributes<HTMLDivElement>> {
export interface PickerBodyBaseProps extends DataSourceListProps, IEditable<DataSourceState>, IHasRawProps<React.HTMLAttributes<HTMLDivElement>>, Pick<PickerInputBaseProps<any, any>, 'minCharsToSearch' | 'renderEmpty' | 'renderNotFound' | 'fixedBodyPosition' | 'searchDebounceDelay'> {
onKeyDown?(e: React.KeyboardEvent<HTMLElement>): void;
renderNotFound?: () => React.ReactNode;
rows: React.ReactNode[];
scheduleUpdate?: () => void;
search: IEditable<string>;
showSearch?: boolean | 'auto';
fixedBodyPosition?: boolean;
searchDebounceDelay?: number;
}

export abstract class PickerBodyBase<TProps extends PickerBodyBaseProps> extends React.Component<TProps> {
Expand Down
6 changes: 4 additions & 2 deletions uui-components/src/pickers/PickerToggler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ function PickerTogglerComponent<TItem, TId>(props: PickerTogglerProps<TItem, TId

const shouldToggleBody = (e: React.MouseEvent<HTMLDivElement>): boolean => {
const isInteractionDisabled = (props.isDisabled || props.isReadonly || isEventTargetInsideClickable(e));
const shouldOpenWithMinCharsToSearch = (inFocus && props.value && props.minCharsToSearch);
const shouldOpenWithMinCharsToSearch = (inFocus && props.value && (props.minCharsToSearch && props.searchPosition === 'input'));
const isPickerOpenWithSearchInInput = (props.isOpen && props.searchPosition === 'input' && (e.target as HTMLInputElement).tagName === 'INPUT');
return !(isInteractionDisabled || shouldOpenWithMinCharsToSearch || isPickerOpenWithSearchInInput);
};
Expand Down Expand Up @@ -260,7 +260,9 @@ function PickerTogglerComponent<TItem, TId>(props: PickerTogglerProps<TItem, TId
rawProps={ { role: 'button', 'aria-label': 'Clear' } }
/>
)}
{props.isDropdown && !props?.minCharsToSearch && <IconContainer icon={ props.dropdownIcon } flipY={ props.isOpen } cx="uui-icon-dropdown" />}
{props.isDropdown
&& (!props?.minCharsToSearch || (props?.minCharsToSearch && props.searchPosition === 'body'))
&& <IconContainer icon={ props.dropdownIcon } flipY={ props.isOpen } cx="uui-icon-dropdown" />}
</div>
)}
</div>
Expand Down
5 changes: 1 addition & 4 deletions uui-components/src/pickers/hooks/types.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import {
CX, DataSourceState, ICanBeReadonly, ICanFocus, IDisableable, IEditable, IHasCaption, IHasIcon, IHasPlaceholder,
IHasRawProps, IModal, PickerBaseOptions, PickerBaseProps, PickerFooterProps, SortingOption,
IHasRawProps, IModal, PickerBaseOptions, PickerBaseProps, PickerFooterProps, PickerInputEditMode, PickerInputSearchPosition, SortingOption,
} from '@epam/uui-core';
import { Placement } from '@popperjs/core';
import { PickerTogglerProps } from '../PickerToggler';
import { Dispatch, SetStateAction } from 'react';

export type PickerInputSearchPosition = 'input' | 'body' | 'none';
export type PickerInputEditMode = 'dropdown' | 'modal';

export type PickerInputBaseProps<TItem, TId> = PickerBaseProps<TItem, TId>
& ICanFocus<HTMLElement> &
IHasPlaceholder &
Expand Down
53 changes: 22 additions & 31 deletions uui-components/src/pickers/hooks/usePickerInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ export function usePickerInput<TItem, TId, TProps>(props: UsePickerInputProps<TI
}, mobilePopperModifier,
], []);

const getSearchPosition = () => {
if (isMobile() && props.searchPosition !== 'none') return 'body';
if (props.editMode === 'modal' && props.searchPosition !== 'none') return 'body';
if (!props.searchPosition) {
return props.selectionMode === 'multi' ? 'body' : 'input';
} else {
return props.searchPosition;
}
};

const pickerInputState = usePickerInputState({
dataSourceState: { visibleCount: initialRowsVisible, checked: [] },
});
Expand All @@ -31,18 +41,15 @@ export function usePickerInput<TItem, TId, TProps>(props: UsePickerInputProps<TI
} = pickerInputState;

const defaultShouldShowBody = () => {
const searchPosition = props.searchPosition || 'input';
const isOpened = opened && !props.isDisabled;

if (props.minCharsToSearch && props.editMode !== 'modal' && searchPosition === 'input') {
const isEnoughSearchLength = dataSourceState.search
? dataSourceState.search.length >= props.minCharsToSearch
: false;
return isEnoughSearchLength && isOpened;
if (props.minCharsToSearch && getSearchPosition() === 'input') {
return isSearchLongEnough() && isOpened;
}
return isOpened;
};

const isSearchLongEnough = () => props.minCharsToSearch ? (dataSourceState.search?.length ?? 0) >= props.minCharsToSearch : true;

const shouldShowBody = () => (props.shouldShowBody ?? defaultShouldShowBody)();

const showSelectedOnly = !shouldShowBody() || pickerInputState.showSelected;
Expand Down Expand Up @@ -108,9 +115,7 @@ export function usePickerInput<TItem, TId, TProps>(props: UsePickerInputProps<TI
};

const toggleBodyOpening = (newOpened: boolean) => {
if (opened === newOpened
|| (props.minCharsToSearch && (dataSourceState.search?.length ?? 0) < props.minCharsToSearch)
) {
if (opened === newOpened || (getSearchPosition() === 'input' && !isSearchLongEnough())) {
return;
}
if (props.editMode === 'modal') {
Expand All @@ -125,16 +130,6 @@ export function usePickerInput<TItem, TId, TProps>(props: UsePickerInputProps<TI
handleDataSourceValueChange((currentState) => ({ ...currentState, search: '', selectedId: row.id }));
};

const getSearchPosition = () => {
if (isMobile() && props.searchPosition !== 'none') return 'body';
if (props.editMode === 'modal' && props.searchPosition !== 'none') return 'body';
if (!props.searchPosition) {
return props.selectionMode === 'multi' ? 'body' : 'input';
} else {
return props.searchPosition;
}
};

const getPlaceholder = () => props.placeholder ?? i18n.pickerInput.defaultPlaceholder(getEntityName());

const handleClearSelection = () => {
Expand Down Expand Up @@ -182,14 +177,10 @@ export function usePickerInput<TItem, TId, TProps>(props: UsePickerInputProps<TI
'aria-orientation': 'vertical',
...props.rawProps?.body,
},
renderNotFound:
props.renderNotFound
&& (() =>
props.renderNotFound({
search: dataSourceState.search,
onClose: () => toggleBodyOpening(false),
})),
renderNotFound: props.renderNotFound,
renderEmpty: props.renderEmpty,
onKeyDown: (e) => handlePickerInputKeyboard(rows, e),
minCharsToSearch: props.minCharsToSearch,
fixedBodyPosition: props.fixedBodyPosition,
searchDebounceDelay: props.searchDebounceDelay,
};
Expand Down Expand Up @@ -221,7 +212,7 @@ export function usePickerInput<TItem, TId, TProps>(props: UsePickerInputProps<TI
}, [handleDataSourceValueChange, setOpened, setIsSearchChanged]);

const getRows = () => {
if (!shouldShowBody()) return [];
if (!shouldShowBody() || !isSearchLongEnough()) return [];

const preparedRows = view.getVisibleRows();

Expand All @@ -248,6 +239,7 @@ export function usePickerInput<TItem, TId, TProps>(props: UsePickerInputProps<TI
onClose: handleCloseBody,
selectionMode: props.selectionMode,
disableClear: props.disableClear,
isSearchTooShort: !isSearchLongEnough(),
};
};

Expand Down Expand Up @@ -283,14 +275,13 @@ export function usePickerInput<TItem, TId, TProps>(props: UsePickerInputProps<TI
onIconClick,
id,
} = props;
const searchPosition = getSearchPosition();
const forcedDisabledClear = Boolean(searchPosition === 'body' && !selectedRowsCount);
const forcedDisabledClear = Boolean(getSearchPosition() === 'body' && !selectedRowsCount);
const disableClear = forcedDisabledClear || propDisableClear;
let searchValue: string | undefined = getSearchValue();
if (isSingleSelect() && selectedRows[0]?.isLoading) {
searchValue = undefined;
}

const searchPosition = getSearchPosition();
return {
isSingleLine,
maxItems,
Expand Down
36 changes: 34 additions & 2 deletions uui-core/src/types/pickers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,23 @@ export type ArrayPickerProps<TId, TItem> =
export type PickerBindingProps<TItem, TId> = SinglePickerProps<TId, TItem> | ArrayPickerProps<TId, TItem>;

export type PickerBindingValueType = 'scalar' | 'array';
/**
* Options for positioning the search input within PickerInput.
*/
export type PickerInputSearchPosition = 'input' | 'body' | 'none';
/**
* Options for displaying content in PickerInput.
*/
export type PickerInputEditMode = 'dropdown' | 'modal';

/**
* Picker empty body configuration.
*/
export interface PickerEmptyBodyProps {
minCharsToSearch?: number;
search: string;
onClose: () => void;
}

export type PickerBaseOptions<TItem, TId> = {
/** Name of the entity being selected. Affects wording like "Please select [entity]" */
Expand All @@ -70,10 +87,21 @@ export type PickerBaseOptions<TItem, TId> = {
/** Gets options for each row. Allow to make rows non-selectable, as well as many other tweaks. */
getRowOptions?: (item: TItem, index: number) => DataRowOptions<TItem, TId>;

/** Overrides the default 'no records found' banner.
* The 'search' callback parameter allows to distinguish cases when there's no records at all, and when current search doesn't find anything. */
/**
* @deprecated in favor of `renderEmpty` method.
* Overrides the default 'no records found' banner.
* The 'search' callback parameter allows to distinguish cases when there's no records at all, and when current search doesn't find anything.
* */
renderNotFound?: (props: { search: string; onClose: () => void }) => ReactNode;

/**
* Overrides the rendering of PickerBody content when it is empty.
* It's used for different empty reasons, like: no record find, no record at all, there is not enough search length to start loading(minCharsToSearch prop provided).
* Consider this all cases where a custom callback is provided.
* If not provided, default implementation is used.
*/
renderEmpty?: (props: PickerEmptyBodyProps) => ReactNode;

/** Defines which value is to set on clear. E.g. you can put an empty array instead of null for empty multi-select Pickers */
emptyValue?: undefined | null | [];

Expand Down Expand Up @@ -122,6 +150,10 @@ export type PickerFooterProps<TItem, TId> = {
selection: PickerBindingProps<TItem, TId>['value'];
/** Defines a search value */
search: string;
/**
* Indicates whether the search does not contain enough characters to load data.
*/
isSearchTooShort?: boolean;
};

/**
Expand Down
6 changes: 6 additions & 0 deletions uui/components/pickers/DataPickerBody.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,9 @@
padding-top: var(--uui-data_picker_body-no-data-vertical-padding);
padding-bottom: var(--uui-data_picker_body-no-data-vertical-padding);
}


.type-search-to-load-size-24 {
padding-top: 20px;
padding-bottom: 20px;
}
58 changes: 44 additions & 14 deletions uui/components/pickers/DataPickerBody.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';
import {
Lens, DataSourceState, isMobile, cx, Overwrite,
Lens, DataSourceState, isMobile, cx, Overwrite, IDropdownBodyProps, devLogger,
} from '@epam/uui-core';
import { FlexCell, PickerBodyBase, PickerBodyBaseProps } from '@epam/uui-components';
import { SearchInput, SearchInputProps } from '../inputs';
Expand All @@ -17,7 +17,7 @@ interface DataPickerBodyMods {
searchSize?: ControlSize;
}

export interface DataPickerBodyProps extends Overwrite<DataPickerBodyMods, DataPickerBodyModsOverride>, PickerBodyBaseProps {
export interface DataPickerBodyProps extends Overwrite<DataPickerBodyMods, DataPickerBodyModsOverride>, PickerBodyBaseProps, IDropdownBodyProps {
maxHeight?: number;
editMode?: 'dropdown' | 'modal';
selectionMode?: 'single' | 'multi';
Expand All @@ -27,21 +27,51 @@ export interface DataPickerBodyProps extends Overwrite<DataPickerBodyMods, DataP
export class DataPickerBody extends PickerBodyBase<DataPickerBodyProps> {
lens = Lens.onEditableComponent<DataSourceState>(this);
searchLens = this.lens.prop('search');
renderNotFound() {
if (this.props.renderNotFound) {
return this.props.renderNotFound();
getSearchSize = () => (isMobile() ? settings.sizes.pickerInput.body.mobile.searchInput : this.props.searchSize) as SearchInputProps['size'];

renderEmpty() {
const search = this.searchLens.get();

if (this.props.renderEmpty) {
return this.props.renderEmpty({
minCharsToSearch: this.props.minCharsToSearch,
onClose: this.props.onClose,
search: search,
});
}

return (
<FlexCell cx={ css.noData } grow={ 1 } textAlign="center">
<Text size={ this.props.searchSize }>{i18n.dataPickerBody.noRecordsMessage}</Text>
</FlexCell>
);
if (this.props.minCharsToSearch && search.length < this.props.minCharsToSearch) {
return (
<FlexCell cx={ css.noData } grow={ 1 } textAlign="center">
<Text size={ this.props.searchSize }>{i18n.dataPickerBody.typeSearchToLoadMessage}</Text>
</FlexCell>
);
}

if (this.props.rows.length === 0) {
if (this.props.renderNotFound) {
if (__DEV__) {
devLogger.warn('[PickerInput]: renderNotFound prop is deprecated. Please use renderEmpty prop instead.');
}

return this.props.renderNotFound({
onClose: this.props.onClose,
search: this.searchLens.get(),
});
}

// Default no record found message for 'NOT_FOUND' and "NO_RECORDS" reason
// TODO: make separate messages for 'NOT_FOUND' and "NO_RECORDS" reason
return (
<FlexCell cx={ css.noData } grow={ 1 } textAlign="center">
<Text size={ this.props.searchSize }>{i18n.dataPickerBody.noRecordsMessage}</Text>
</FlexCell>
);
}
}

render() {
const searchSize = (isMobile() ? settings.sizes.pickerInput.body.mobile.searchInput : this.props.searchSize) as SearchInputProps['size'];

const searchSize = this.getSearchSize();
return (
<>
{this.showSearch() && (
Expand All @@ -60,15 +90,15 @@ export class DataPickerBody extends PickerBodyBase<DataPickerBodyProps> {
</div>
)}
<FlexRow key="body" cx={ cx('uui-picker_input-body', css[this.props.editMode], css[this.props.selectionMode]) } rawProps={ { style: { maxHeight: this.props.maxHeight, maxWidth: this.props.maxWidth } } }>
{ this.props.rowsCount > 0 ? (
{ this.props.rows.length > 0 ? (
<VirtualList
{ ...this.lens.toProps() }
rows={ this.props.rows }
rawProps={ this.props.rawProps }
rowsCount={ this.props.rowsCount }
isLoading={ this.props.isReloading }
/>
) : (this.renderNotFound())}
) : this.renderEmpty()}
</FlexRow>
</>
);
Expand Down
5 changes: 3 additions & 2 deletions uui/components/pickers/DataPickerFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ function DataPickerFooterImpl<TItem, TId>(props: PropsWithChildren<DataPickerFoo
view,
showSelected,
selectionMode,
isSearchTooShort,
} = props;

const size = isMobile() ? settings.sizes.pickerInput.body.mobile.footer.linkButton as LinkButtonProps['size'] : props.size;
Expand All @@ -40,9 +41,9 @@ function DataPickerFooterImpl<TItem, TId>(props: PropsWithChildren<DataPickerFoo

// show always for multi picker and for single only in case if search not disabled and doesn't searching.
const isSearching = search && search?.length;
const shouldShowFooter = isSinglePicker ? (!isSearching && !props.disableClear) : !isSearching;
const hideFooter = isSearchTooShort || rowsCount === 0 || (isSinglePicker ? (isSearching && props.disableClear) : isSearching);

return shouldShowFooter && (
return !hideFooter && (
<FlexRow cx={ css.footer }>
{!isSinglePicker && (
<Switch
Expand Down
Loading

0 comments on commit 716a2d6

Please sign in to comment.