feat(Boxer): implement Boxer component with autocomplete and server-side support

This commit is contained in:
2026-01-17 18:26:20 +02:00
parent 31f2a0428f
commit a8e9c50290
9 changed files with 963 additions and 0 deletions

345
src/Boxer/Boxer.tsx Normal file
View 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;