feat(Boxer): implement Boxer component with autocomplete and server-side support
This commit is contained in:
345
src/Boxer/Boxer.tsx
Normal file
345
src/Boxer/Boxer.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
import { Combobox, ScrollArea, useVirtualizedCombobox } from '@mantine/core';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import React, { useEffect, 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 = () => {
|
||||
// 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 onClear = () => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
function onOptionSubmit(index: number) {
|
||||
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;
|
||||
combobox.closeDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
// Component Hooks
|
||||
const combobox = useVirtualizedCombobox({
|
||||
getOptionId: (index) => boxerData[index]?.value ?? 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) {
|
||||
fetchData(search, true);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(delayDebounceFn);
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
// Sync input with value
|
||||
if (multiSelect) {
|
||||
const labels = boxerData
|
||||
.filter((item) => Array.isArray(value) && value.includes(item.value))
|
||||
.map((item) => item.label)
|
||||
.join(', ');
|
||||
|
||||
if (input !== labels && search === '') {
|
||||
setInput(labels);
|
||||
}
|
||||
} else {
|
||||
const label = boxerData.find((item) => item.value === value)?.label;
|
||||
|
||||
if (
|
||||
(input !== label && (search ?? '') === '' && valueRef.current !== value) ||
|
||||
(!value && (search ?? '') === '')
|
||||
) {
|
||||
setSearch('');
|
||||
setInput(label ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
// 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]);
|
||||
|
||||
useEffect(() => {
|
||||
// Select first option automatically
|
||||
if (selectFirst && (boxerData?.length ?? 0) > 0 && !multiSelect) {
|
||||
if (!value) {
|
||||
onOptionSubmit?.(0);
|
||||
}
|
||||
}
|
||||
}, [selectFirst, boxerData, multiSelect]);
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
const Boxer = React.forwardRef<BoxerRef, BoxerProps>((props, ref) => {
|
||||
return (
|
||||
<BoxerProvider {...props}>
|
||||
<BoxerInner />
|
||||
</BoxerProvider>
|
||||
);
|
||||
});
|
||||
|
||||
Boxer.displayName = 'Boxer';
|
||||
|
||||
export { Boxer };
|
||||
export default Boxer;
|
||||
Reference in New Issue
Block a user