feat(Boxer): add @tanstack/react-virtual dependency and enhance Boxer component with improved option handling and ref exposure

This commit is contained in:
2026-01-17 19:33:19 +02:00
parent a8e9c50290
commit 1fb57d3454
7 changed files with 113 additions and 53 deletions

View File

@@ -1,6 +1,6 @@
import { Combobox, ScrollArea, useVirtualizedCombobox } from '@mantine/core';
import { useVirtualizer } from '@tanstack/react-virtual';
import React, { useEffect, useRef } from 'react';
import React, { useCallback, useEffect, useImperativeHandle, useRef } from 'react';
import type { BoxerItem, BoxerProps, BoxerRef } from './Boxer.types';
@@ -8,7 +8,7 @@ import { BoxerProvider, useBoxerStore } from './Boxer.store';
import BoxerTarget from './BoxerTarget';
import useBoxerOptions from './hooks/useBoxerOptions';
const BoxerInner = () => {
const BoxerInner = React.forwardRef<BoxerRef>((_, ref) => {
// Component Refs
const parentRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
@@ -84,7 +84,42 @@ const BoxerInner = () => {
const virtualItems = virtualizer.getVirtualItems();
// Component Callback Functions
const onClear = () => {
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 {
@@ -101,40 +136,11 @@ const BoxerInner = () => {
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();
}
}
}, [showAll, selectFirst, multiSelect, onChange, setSearch, setInput, openOnClear, setOpened, onOptionSubmit]);
// Component Hooks
const combobox = useVirtualizedCombobox({
getOptionId: (index) => boxerData[index]?.value ?? String(index),
getOptionId: (index) => String(index),
onDropdownClose: () => {
setOpened(false);
},
@@ -174,13 +180,13 @@ const BoxerInner = () => {
useEffect(() => {
// Handle search changes
const delayDebounceFn = setTimeout(() => {
if (search !== undefined) {
if (search !== undefined && opened) {
fetchData(search, true);
}
}, 300);
return () => clearTimeout(delayDebounceFn);
}, [search]);
}, [search, opened]);
useEffect(() => {
// Sync input with value
@@ -190,18 +196,20 @@ const BoxerInner = () => {
.map((item) => item.label)
.join(', ');
if (input !== labels && search === '') {
// 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;
if (
(input !== label && (search ?? '') === '' && valueRef.current !== value) ||
(!value && (search ?? '') === '')
) {
setSearch('');
// 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('');
}
}
@@ -223,7 +231,7 @@ const BoxerInner = () => {
bufferRef.current = buffer;
}
}
}, [value, boxerData, input, search, multiSelect]);
}, [value, boxerData, input, search, multiSelect, opened, onBufferChange, setInput, setSearch]);
useEffect(() => {
// Select first option automatically
@@ -234,6 +242,30 @@ const BoxerInner = () => {
}
}, [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}
@@ -329,12 +361,14 @@ const BoxerInner = () => {
</Combobox.Dropdown>
</Combobox>
);
};
});
BoxerInner.displayName = 'BoxerInner';
const Boxer = React.forwardRef<BoxerRef, BoxerProps>((props, ref) => {
return (
<BoxerProvider {...props}>
<BoxerInner />
<BoxerInner ref={ref} />
</BoxerProvider>
);
});