feat(Boxer): add @tanstack/react-virtual dependency and enhance Boxer component with improved option handling and ref exposure
This commit is contained in:
@@ -44,7 +44,7 @@ const { Provider: BoxerProvider, useStore: useBoxerStore } = createSyncStore<
|
||||
});
|
||||
|
||||
set(
|
||||
produce((draft: BoxerStoreState) => {
|
||||
produce((draft) => {
|
||||
if (reset) {
|
||||
draft.boxerData = result.data;
|
||||
draft.page = 0;
|
||||
@@ -93,7 +93,7 @@ const { Provider: BoxerProvider, useStore: useBoxerStore } = createSyncStore<
|
||||
}
|
||||
|
||||
set(
|
||||
produce((draft: BoxerStoreState) => {
|
||||
produce((draft) => {
|
||||
draft.page = draft.page + 1;
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ComboboxProps, ScrollAreaAutosizeProps, TextInputProps } from '@mantine/core';
|
||||
import type { UseVirtualizerOptions } from '@tanstack/react-virtual';
|
||||
import type { VirtualizerOptions } from '@tanstack/react-virtual';
|
||||
|
||||
export type BoxerDataSource =
|
||||
| 'local' // Local array data
|
||||
@@ -45,6 +45,8 @@ export interface BoxerProps {
|
||||
|
||||
openOnClear?: boolean;
|
||||
|
||||
pageSize?: number;
|
||||
|
||||
// UI Configuration
|
||||
placeholder?: string;
|
||||
// Styling
|
||||
@@ -58,7 +60,7 @@ export interface BoxerProps {
|
||||
// Value Management
|
||||
value?: any | Array<any>;
|
||||
// Virtualization
|
||||
virtualizer?: Partial<UseVirtualizerOptions<HTMLDivElement, Element>>;
|
||||
virtualizer?: Partial<VirtualizerOptions<HTMLDivElement, Element>>;
|
||||
}
|
||||
|
||||
export interface BoxerRef {
|
||||
|
||||
@@ -24,6 +24,9 @@ const useBoxerOptions = (props: UseBoxerOptionsProps) => {
|
||||
key={`${item.value}-${index}`}
|
||||
value={String(index)}
|
||||
active={isSelected}
|
||||
onClick={() => {
|
||||
onOptionSubmit(index);
|
||||
}}
|
||||
>
|
||||
{multiSelect ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
@@ -36,7 +39,7 @@ const useBoxerOptions = (props: UseBoxerOptionsProps) => {
|
||||
</Combobox.Option>
|
||||
);
|
||||
});
|
||||
}, [boxerData, value, multiSelect]);
|
||||
}, [boxerData, value, multiSelect, onOptionSubmit]);
|
||||
|
||||
return { options };
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { BoxerItem, BoxerProps } from '../Boxer.types';
|
||||
import type { BoxerItem } from '../Boxer.types';
|
||||
|
||||
import { Boxer } from '../Boxer';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user