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

@@ -49,6 +49,7 @@
"./oranguru.css": "./src/oranguru.css"
},
"dependencies": {
"@tanstack/react-virtual": "^3.13.18",
"moment": "^2.30.1"
},
"devDependencies": {
@@ -93,16 +94,16 @@
"@glideapps/glide-data-grid": "^6.0.3",
"@mantine/core": "^8.3.1",
"@mantine/hooks": "^8.3.1",
"@mantine/notifications": "^8.3.5",
"@mantine/modals": "^8.3.5",
"@mantine/notifications": "^8.3.5",
"@tabler/icons-react": "^3.35.0",
"@tanstack/react-query": "^5.90.5",
"@warkypublic/artemis-kit": "^1.0.10",
"@warkypublic/zustandsyncstore": "^0.0.4",
"react-hook-form": "^7.71.0",
"immer": "^10.1.3",
"react": ">= 19.0.0",
"react-dom": ">= 19.0.0",
"react-hook-form": "^7.71.0",
"use-sync-external-store": ">= 1.4.0",
"zustand": ">= 5.0.0"
}

20
pnpm-lock.yaml generated
View File

@@ -29,6 +29,9 @@ importers:
'@tanstack/react-query':
specifier: ^5.90.5
version: 5.90.5(react@19.2.0)
'@tanstack/react-virtual':
specifier: ^3.13.18
version: 3.13.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@warkypublic/artemis-kit':
specifier: ^1.0.10
version: 1.0.10
@@ -1042,6 +1045,15 @@ packages:
peerDependencies:
react: ^18 || ^19
'@tanstack/react-virtual@3.13.18':
resolution: {integrity: sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/virtual-core@3.13.18':
resolution: {integrity: sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==}
'@testing-library/dom@10.4.1':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'}
@@ -4618,6 +4630,14 @@ snapshots:
'@tanstack/query-core': 5.90.5
react: 19.2.0
'@tanstack/react-virtual@3.13.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@tanstack/virtual-core': 3.13.18
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
'@tanstack/virtual-core@3.13.18': {}
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.27.1

View File

@@ -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;
})
);

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>
);
});

View File

@@ -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 {

View File

@@ -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 };
};

View File

@@ -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';