380 lines
10 KiB
TypeScript
380 lines
10 KiB
TypeScript
import { Combobox, ScrollArea, useVirtualizedCombobox } from '@mantine/core';
|
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
import React, { useCallback, useEffect, useImperativeHandle, useRef } from 'react';
|
|
|
|
import type { BoxerItem, BoxerProps, BoxerRef } from './Boxer.types';
|
|
|
|
import { BoxerProvider, useBoxerStore } from './Boxer.store';
|
|
import BoxerTarget from './BoxerTarget';
|
|
import useBoxerOptions from './hooks/useBoxerOptions';
|
|
|
|
const BoxerInner = React.forwardRef<BoxerRef>((_, ref) => {
|
|
// Component Refs
|
|
const parentRef = useRef<HTMLDivElement>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const valueRef = useRef<any>(null);
|
|
const bufferRef = useRef<any>(null);
|
|
|
|
// Component store State
|
|
const {
|
|
boxerData,
|
|
clearable,
|
|
comboBoxProps,
|
|
dropDownProps,
|
|
error,
|
|
fetchData,
|
|
fetchMoreOnBottomReached,
|
|
input,
|
|
isFetching,
|
|
label,
|
|
mah,
|
|
multiSelect,
|
|
onBufferChange,
|
|
onChange,
|
|
opened,
|
|
openOnClear,
|
|
placeholder,
|
|
scrollAreaProps,
|
|
search,
|
|
selectedOptionIndex,
|
|
selectFirst,
|
|
setInput,
|
|
setOpened,
|
|
setSearch,
|
|
setSelectedOptionIndex,
|
|
showAll,
|
|
value,
|
|
} = useBoxerStore((state) => ({
|
|
boxerData: state.boxerData,
|
|
clearable: state.clearable,
|
|
comboBoxProps: state.comboBoxProps,
|
|
dropDownProps: state.dropDownProps,
|
|
error: state.error,
|
|
fetchData: state.fetchData,
|
|
fetchMoreOnBottomReached: state.fetchMoreOnBottomReached,
|
|
input: state.input,
|
|
isFetching: state.isFetching,
|
|
label: state.label,
|
|
mah: state.mah,
|
|
multiSelect: state.multiSelect,
|
|
onBufferChange: state.onBufferChange,
|
|
onChange: state.onChange,
|
|
opened: state.opened,
|
|
openOnClear: state.openOnClear,
|
|
placeholder: state.placeholder,
|
|
scrollAreaProps: state.scrollAreaProps,
|
|
search: state.search,
|
|
selectedOptionIndex: state.selectedOptionIndex,
|
|
selectFirst: state.selectFirst,
|
|
setInput: state.setInput,
|
|
setOpened: state.setOpened,
|
|
setSearch: state.setSearch,
|
|
setSelectedOptionIndex: state.setSelectedOptionIndex,
|
|
showAll: state.showAll,
|
|
value: state.value,
|
|
}));
|
|
|
|
// Virtualization setup
|
|
const count = boxerData.length;
|
|
const virtualizer = useVirtualizer({
|
|
count,
|
|
estimateSize: () => 36,
|
|
getScrollElement: () => parentRef.current,
|
|
});
|
|
const virtualItems = virtualizer.getVirtualItems();
|
|
|
|
// Component Callback Functions
|
|
const onOptionSubmit = useCallback(
|
|
(indexOrId: number | string) => {
|
|
const index = typeof indexOrId === 'string' ? parseInt(indexOrId, 10) : indexOrId;
|
|
const option = boxerData[index];
|
|
|
|
if (!option) {
|
|
return;
|
|
}
|
|
|
|
if (multiSelect) {
|
|
// Handle multi-select
|
|
const currentValues = Array.isArray(value) ? value : [];
|
|
const isSelected = currentValues.includes(option.value);
|
|
|
|
const newValues = isSelected
|
|
? currentValues.filter((v: any) => v !== option.value)
|
|
: [...currentValues, option.value];
|
|
|
|
onChange?.(newValues);
|
|
|
|
// Update buffer for multi-select
|
|
const newBuffer = boxerData.filter((item) => newValues.includes(item.value));
|
|
onBufferChange?.(newBuffer);
|
|
} else {
|
|
// Handle single select
|
|
onChange?.(option.value);
|
|
setSearch('');
|
|
setInput(option.label);
|
|
valueRef.current = option.value;
|
|
setOpened(false);
|
|
}
|
|
},
|
|
[boxerData, multiSelect, value, onChange, onBufferChange, setSearch, setInput, setOpened]
|
|
);
|
|
|
|
const onClear = useCallback(() => {
|
|
if (showAll && selectFirst) {
|
|
onOptionSubmit(0);
|
|
} else {
|
|
if (multiSelect) {
|
|
onChange?.([] as any);
|
|
} else {
|
|
onChange?.(null as any);
|
|
}
|
|
setSearch('');
|
|
setInput('');
|
|
inputRef.current?.focus();
|
|
}
|
|
|
|
if (openOnClear) {
|
|
setOpened(true);
|
|
}
|
|
}, [showAll, selectFirst, multiSelect, onChange, setSearch, setInput, openOnClear, setOpened, onOptionSubmit]);
|
|
|
|
// Component Hooks
|
|
const combobox = useVirtualizedCombobox({
|
|
getOptionId: (index) => String(index),
|
|
onDropdownClose: () => {
|
|
setOpened(false);
|
|
},
|
|
onDropdownOpen: () => {
|
|
if (!value || (multiSelect && (!Array.isArray(value) || value.length === 0))) {
|
|
setSearch('');
|
|
setInput('');
|
|
}
|
|
combobox.selectFirstOption();
|
|
},
|
|
onSelectedOptionSubmit: onOptionSubmit,
|
|
opened,
|
|
selectedOptionIndex,
|
|
setSelectedOptionIndex: (index) => {
|
|
setSelectedOptionIndex(index);
|
|
if (index !== -1) {
|
|
virtualizer.scrollToIndex(index);
|
|
}
|
|
},
|
|
totalOptionsCount: boxerData.length,
|
|
});
|
|
|
|
// Component variables
|
|
const { options } = useBoxerOptions({
|
|
boxerData,
|
|
multiSelect,
|
|
onOptionSubmit,
|
|
value,
|
|
});
|
|
|
|
// Component useEffects
|
|
useEffect(() => {
|
|
// Fetch initial data
|
|
fetchData('', true);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
// Handle search changes
|
|
const delayDebounceFn = setTimeout(() => {
|
|
if (search !== undefined && opened) {
|
|
fetchData(search, true);
|
|
}
|
|
}, 300);
|
|
|
|
return () => clearTimeout(delayDebounceFn);
|
|
}, [search, opened]);
|
|
|
|
useEffect(() => {
|
|
// Sync input with value
|
|
if (multiSelect) {
|
|
const labels = boxerData
|
|
.filter((item) => Array.isArray(value) && value.includes(item.value))
|
|
.map((item) => item.label)
|
|
.join(', ');
|
|
|
|
// When dropdown is closed, show selected labels. When open, allow searching
|
|
if (!opened && input !== labels) {
|
|
setInput(labels);
|
|
setSearch('');
|
|
}
|
|
} else {
|
|
const label = boxerData.find((item) => item.value === value)?.label;
|
|
|
|
// Only sync if we need to update the input to match the value
|
|
if (input !== label && (search ?? '') === '' && valueRef.current !== value && value) {
|
|
setInput(label ?? '');
|
|
} else if (!value && !valueRef.current && (search ?? '') === '') {
|
|
setSearch('');
|
|
setInput('');
|
|
}
|
|
}
|
|
|
|
// Handle buffer change
|
|
if (multiSelect) {
|
|
const buffer =
|
|
boxerData.filter((item: BoxerItem) => Array.isArray(value) && value.includes(item.value)) ??
|
|
[];
|
|
|
|
if (JSON.stringify(bufferRef.current) !== JSON.stringify(buffer)) {
|
|
onBufferChange?.(buffer);
|
|
bufferRef.current = buffer;
|
|
}
|
|
} else {
|
|
const buffer = boxerData?.find((item: BoxerItem) => item.value === value) ?? null;
|
|
|
|
if (bufferRef.current?.value !== buffer?.value) {
|
|
onBufferChange?.(buffer);
|
|
bufferRef.current = buffer;
|
|
}
|
|
}
|
|
}, [value, boxerData, input, search, multiSelect, opened, onBufferChange, setInput, setSearch]);
|
|
|
|
useEffect(() => {
|
|
// Select first option automatically
|
|
if (selectFirst && (boxerData?.length ?? 0) > 0 && !multiSelect) {
|
|
if (!value) {
|
|
onOptionSubmit?.(0);
|
|
}
|
|
}
|
|
}, [selectFirst, boxerData, multiSelect]);
|
|
|
|
// Expose ref methods
|
|
useImperativeHandle(ref, () => ({
|
|
clear: () => {
|
|
onClear();
|
|
},
|
|
close: () => {
|
|
setOpened(false);
|
|
combobox.closeDropdown();
|
|
},
|
|
focus: () => {
|
|
inputRef.current?.focus();
|
|
},
|
|
getValue: () => {
|
|
return value;
|
|
},
|
|
open: () => {
|
|
setOpened(true);
|
|
combobox.openDropdown();
|
|
},
|
|
setValue: (newValue: any) => {
|
|
onChange?.(newValue);
|
|
},
|
|
}));
|
|
|
|
return (
|
|
<Combobox
|
|
{...comboBoxProps}
|
|
resetSelectionOnOptionHover={false}
|
|
store={combobox}
|
|
withinPortal={true}
|
|
>
|
|
<Combobox.Target>
|
|
<Combobox.EventsTarget>
|
|
<BoxerTarget
|
|
clearable={clearable}
|
|
combobox={combobox}
|
|
error={error}
|
|
isFetching={isFetching}
|
|
label={label}
|
|
onBlur={() => {
|
|
if (!value && !multiSelect) {
|
|
setSearch('');
|
|
setInput('');
|
|
combobox.closeDropdown();
|
|
setOpened(false);
|
|
}
|
|
}}
|
|
onClear={onClear}
|
|
onSearch={(event) => {
|
|
setSearch(event.currentTarget.value);
|
|
setInput(event.currentTarget.value);
|
|
setOpened(true);
|
|
}}
|
|
placeholder={placeholder}
|
|
ref={inputRef}
|
|
search={input}
|
|
/>
|
|
</Combobox.EventsTarget>
|
|
</Combobox.Target>
|
|
|
|
<Combobox.Dropdown
|
|
onKeyDown={() => {
|
|
if (inputRef.current) {
|
|
inputRef.current.value = '';
|
|
inputRef.current?.focus();
|
|
}
|
|
}}
|
|
p={2}
|
|
{...dropDownProps}
|
|
>
|
|
{opened && options.length > 0 ? (
|
|
<Combobox.Options>
|
|
<ScrollArea.Autosize
|
|
{...scrollAreaProps}
|
|
mah={mah ?? 200}
|
|
viewportProps={{
|
|
...scrollAreaProps?.viewportProps,
|
|
onScroll: (event) => {
|
|
fetchMoreOnBottomReached(event.currentTarget as HTMLDivElement);
|
|
},
|
|
style: { border: '1px solid gray', borderRadius: 4 },
|
|
}}
|
|
viewportRef={parentRef}
|
|
>
|
|
<div
|
|
style={{
|
|
height: `${virtualizer.getTotalSize()}px`,
|
|
position: 'relative',
|
|
width: '100%',
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
left: 0,
|
|
position: 'absolute',
|
|
top: 0,
|
|
transform: `translateY(${virtualItems[0]?.start ?? 0}px)`,
|
|
width: '100%',
|
|
}}
|
|
>
|
|
{virtualItems.map((virtualRow) => (
|
|
<div
|
|
data-index={virtualRow.index}
|
|
key={virtualRow.key}
|
|
ref={virtualizer.measureElement}
|
|
>
|
|
{options[virtualRow.index]}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</ScrollArea.Autosize>
|
|
</Combobox.Options>
|
|
) : (
|
|
<Combobox.Empty>Nothing found</Combobox.Empty>
|
|
)}
|
|
</Combobox.Dropdown>
|
|
</Combobox>
|
|
);
|
|
});
|
|
|
|
BoxerInner.displayName = 'BoxerInner';
|
|
|
|
const Boxer = React.forwardRef<BoxerRef, BoxerProps>((props, ref) => {
|
|
return (
|
|
<BoxerProvider {...props}>
|
|
<BoxerInner ref={ref} />
|
|
</BoxerProvider>
|
|
);
|
|
});
|
|
|
|
Boxer.displayName = 'Boxer';
|
|
|
|
export { Boxer };
|
|
export default Boxer;
|