2024-08-29 15:52:16 +02:00

246 lines
7.7 KiB
TypeScript

import type { TeactNode } from '../../../lib/teact/teact';
import React, {
memo, useCallback, useEffect,
useMemo,
useRef,
} from '../../../lib/teact/teact';
import { requestMeasure } from '../../../lib/fasterdom/fasterdom';
import buildClassName from '../../../util/buildClassName';
import { MEMO_EMPTY_ARRAY } from '../../../util/memo';
import useInfiniteScroll from '../../../hooks/useInfiniteScroll';
import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang';
import Checkbox from '../../ui/Checkbox';
import InfiniteScroll from '../../ui/InfiniteScroll';
import InputText from '../../ui/InputText';
import Loading from '../../ui/Loading';
import Radio from '../../ui/Radio';
import Icon from '../icons/Icon';
import PickerItem from './PickerItem';
import styles from './PickerStyles.module.scss';
export type ItemPickerOption = {
label: TeactNode;
subLabel?: string;
disabled?: boolean;
isLoading?: boolean;
value: string;
};
type SingleModeProps = {
allowMultiple?: false;
itemInputType?: 'radio';
selectedValue?: string;
selectedValues?: never; // Help TS to throw an error if this is passed
onSelectedValueChange?: (value: string) => void;
};
type MultipleModeProps = {
allowMultiple: true;
itemInputType: 'checkbox';
selectedValue?: never;
selectedValues: string[];
lockedSelectedValues?: string[];
lockedUnselectedValues?: string[];
onSelectedValuesChange?: (values: string[]) => void;
};
type OwnProps = {
className?: string;
isSearchable?: boolean;
searchInputId?: string;
items: ItemPickerOption[];
itemClassName?: string;
filterValue?: string;
filterPlaceholder?: string;
notFoundText?: string;
isLoading?: boolean;
noScrollRestore?: boolean;
isViewOnly?: boolean;
withDefaultPadding?: boolean;
onFilterChange?: (value: string) => void;
onDisabledClick?: (value: string, isSelected: boolean) => void;
onLoadMore?: () => void;
} & (SingleModeProps | MultipleModeProps);
// Focus slows down animation, also it breaks transition layout in Chrome
const FOCUS_DELAY_MS = 500;
const ITEM_CLASS_NAME = 'ItemPickerItem';
const ItemPicker = ({
className,
isSearchable,
searchInputId,
items,
filterValue,
notFoundText,
isLoading,
noScrollRestore,
filterPlaceholder,
isViewOnly,
itemInputType,
itemClassName,
withDefaultPadding,
onFilterChange,
onDisabledClick,
onLoadMore,
...optionalProps
}: OwnProps) => {
const lang = useOldLang();
// eslint-disable-next-line no-null/no-null
const inputRef = useRef<HTMLInputElement>(null);
const allowMultiple = optionalProps.allowMultiple;
const lockedSelectedValues = allowMultiple ? optionalProps.lockedSelectedValues : undefined;
const lockedUnselectedValues = allowMultiple ? optionalProps.lockedUnselectedValues : undefined;
useEffect(() => {
if (!isSearchable) return;
setTimeout(() => {
requestMeasure(() => {
inputRef.current!.focus();
});
}, FOCUS_DELAY_MS);
}, [isSearchable]);
const selectedValues = useMemo(() => {
if (allowMultiple) {
return optionalProps.selectedValues;
}
return optionalProps.selectedValue ? [optionalProps.selectedValue] : MEMO_EMPTY_ARRAY;
}, [allowMultiple, optionalProps.selectedValue, optionalProps.selectedValues]);
const lockedSelectedValuesSet = useMemo(() => new Set(lockedSelectedValues), [lockedSelectedValues]);
const lockedUnselectedValuesSet = useMemo(() => new Set(lockedUnselectedValues), [lockedUnselectedValues]);
const sortedItemValuesList = useMemo(() => {
if (filterValue) {
return items.map((item) => item.value);
}
const lockedSelectedBucket: ItemPickerOption[] = [];
const unlockedBucket: ItemPickerOption[] = [];
const lockedUnselectableBucket: ItemPickerOption[] = [];
items.forEach((item) => {
if (lockedSelectedValuesSet.has(item.value)) {
lockedSelectedBucket.push(item);
} else if (lockedUnselectedValuesSet.has(item.value)) {
lockedUnselectableBucket.push(item);
} else {
unlockedBucket.push(item);
}
});
return lockedSelectedBucket.concat(unlockedBucket, lockedUnselectableBucket).map((item) => item.value);
}, [filterValue, items, lockedSelectedValuesSet, lockedUnselectedValuesSet]);
const handleItemClick = useLastCallback((value: string) => {
if (allowMultiple) {
const newSelectedValues = selectedValues.slice();
const index = newSelectedValues.indexOf(value);
if (index >= 0) {
newSelectedValues.splice(index, 1);
} else {
newSelectedValues.push(value);
}
optionalProps.onSelectedValuesChange?.(newSelectedValues);
return;
}
optionalProps.onSelectedValueChange?.(value);
});
const [viewportValuesList, getMore] = useInfiniteScroll(
onLoadMore, sortedItemValuesList, Boolean(filterValue),
);
const handleFilterChange = useLastCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const { value } = e.currentTarget;
onFilterChange?.(value);
});
const renderItem = useCallback((value: string) => {
const item = items.find((itemOption) => itemOption.value === value);
if (!item) return undefined;
const { label, subLabel, isLoading: isItemLoading } = item;
const isAlwaysUnselected = lockedUnselectedValuesSet.has(value);
const isAlwaysSelected = lockedSelectedValuesSet.has(value);
const isLocked = isAlwaysUnselected || isAlwaysSelected;
const isChecked = selectedValues.includes(value);
function getInputElement() {
if (isLocked) return <Icon name="lock-badge" />;
if (itemInputType === 'radio') {
return <Radio checked={isChecked} disabled={isLocked} isLoading={isItemLoading} onlyInput />;
}
if (itemInputType === 'checkbox') {
return <Checkbox checked={isChecked} disabled={isLocked} isLoading={isItemLoading} onlyInput />;
}
return undefined;
}
return (
<PickerItem
key={value}
className={buildClassName(ITEM_CLASS_NAME, itemClassName)}
title={label}
subtitle={subLabel}
disabled={isLocked}
inactive={isViewOnly}
ripple
inputElement={getInputElement()}
// eslint-disable-next-line react/jsx-no-bind
onClick={() => handleItemClick(value)}
// eslint-disable-next-line react/jsx-no-bind
onDisabledClick={onDisabledClick && (() => onDisabledClick(value, isAlwaysSelected))}
/>
);
}, [
items, lockedUnselectedValuesSet, lockedSelectedValuesSet, selectedValues, isViewOnly, onDisabledClick,
itemInputType, itemClassName,
]);
return (
<div className={buildClassName(styles.container, className)}>
{isSearchable && (
<div className={buildClassName(styles.header, 'custom-scroll')} dir={lang.isRtl ? 'rtl' : undefined}>
<InputText
id={searchInputId}
ref={inputRef}
value={filterValue}
onChange={handleFilterChange}
placeholder={filterPlaceholder || lang('Search')}
/>
</div>
)}
{viewportValuesList?.length ? (
<InfiniteScroll
className={buildClassName(styles.pickerList, withDefaultPadding && styles.padded, 'custom-scroll')}
items={viewportValuesList}
itemSelector={`.${ITEM_CLASS_NAME}`}
onLoadMore={getMore}
noScrollRestore={noScrollRestore}
>
{viewportValuesList.map((value) => renderItem(value))}
</InfiniteScroll>
) : !isLoading && viewportValuesList && !viewportValuesList.length ? (
<p className={styles.noResults}>{notFoundText || lang('SearchEmptyViewTitle')}</p>
) : (
<Loading />
)}
</div>
);
};
export default memo(ItemPicker);