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

20
pnpm-lock.yaml generated
View File

@@ -29,6 +29,9 @@ importers:
'@tanstack/react-query': '@tanstack/react-query':
specifier: ^5.90.5 specifier: ^5.90.5
version: 5.90.5(react@19.2.0) 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': '@warkypublic/artemis-kit':
specifier: ^1.0.10 specifier: ^1.0.10
version: 1.0.10 version: 1.0.10
@@ -1042,6 +1045,15 @@ packages:
peerDependencies: peerDependencies:
react: ^18 || ^19 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': '@testing-library/dom@10.4.1':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -4618,6 +4630,14 @@ snapshots:
'@tanstack/query-core': 5.90.5 '@tanstack/query-core': 5.90.5
react: 19.2.0 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': '@testing-library/dom@10.4.1':
dependencies: dependencies:
'@babel/code-frame': 7.27.1 '@babel/code-frame': 7.27.1

View File

@@ -44,7 +44,7 @@ const { Provider: BoxerProvider, useStore: useBoxerStore } = createSyncStore<
}); });
set( set(
produce((draft: BoxerStoreState) => { produce((draft) => {
if (reset) { if (reset) {
draft.boxerData = result.data; draft.boxerData = result.data;
draft.page = 0; draft.page = 0;
@@ -93,7 +93,7 @@ const { Provider: BoxerProvider, useStore: useBoxerStore } = createSyncStore<
} }
set( set(
produce((draft: BoxerStoreState) => { produce((draft) => {
draft.page = draft.page + 1; draft.page = draft.page + 1;
}) })
); );

View File

@@ -1,6 +1,6 @@
import { Combobox, ScrollArea, useVirtualizedCombobox } from '@mantine/core'; import { Combobox, ScrollArea, useVirtualizedCombobox } from '@mantine/core';
import { useVirtualizer } from '@tanstack/react-virtual'; 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'; import type { BoxerItem, BoxerProps, BoxerRef } from './Boxer.types';
@@ -8,7 +8,7 @@ import { BoxerProvider, useBoxerStore } from './Boxer.store';
import BoxerTarget from './BoxerTarget'; import BoxerTarget from './BoxerTarget';
import useBoxerOptions from './hooks/useBoxerOptions'; import useBoxerOptions from './hooks/useBoxerOptions';
const BoxerInner = () => { const BoxerInner = React.forwardRef<BoxerRef>((_, ref) => {
// Component Refs // Component Refs
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -84,29 +84,14 @@ const BoxerInner = () => {
const virtualItems = virtualizer.getVirtualItems(); const virtualItems = virtualizer.getVirtualItems();
// Component Callback Functions // Component Callback Functions
const onClear = () => { const onOptionSubmit = useCallback(
if (showAll && selectFirst) { (indexOrId: number | string) => {
onOptionSubmit(0); const index = typeof indexOrId === 'string' ? parseInt(indexOrId, 10) : indexOrId;
} 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]; const option = boxerData[index];
if (!option) return; if (!option) {
return;
}
if (multiSelect) { if (multiSelect) {
// Handle multi-select // Handle multi-select
@@ -128,13 +113,34 @@ const BoxerInner = () => {
setSearch(''); setSearch('');
setInput(option.label); setInput(option.label);
valueRef.current = option.value; valueRef.current = option.value;
combobox.closeDropdown(); setOpened(false);
} }
},
[boxerData, multiSelect, value, onChange, onBufferChange, setSearch, setInput, setOpened]
);
const onClear = useCallback(() => {
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);
}
}, [showAll, selectFirst, multiSelect, onChange, setSearch, setInput, openOnClear, setOpened, onOptionSubmit]);
// Component Hooks // Component Hooks
const combobox = useVirtualizedCombobox({ const combobox = useVirtualizedCombobox({
getOptionId: (index) => boxerData[index]?.value ?? String(index), getOptionId: (index) => String(index),
onDropdownClose: () => { onDropdownClose: () => {
setOpened(false); setOpened(false);
}, },
@@ -174,13 +180,13 @@ const BoxerInner = () => {
useEffect(() => { useEffect(() => {
// Handle search changes // Handle search changes
const delayDebounceFn = setTimeout(() => { const delayDebounceFn = setTimeout(() => {
if (search !== undefined) { if (search !== undefined && opened) {
fetchData(search, true); fetchData(search, true);
} }
}, 300); }, 300);
return () => clearTimeout(delayDebounceFn); return () => clearTimeout(delayDebounceFn);
}, [search]); }, [search, opened]);
useEffect(() => { useEffect(() => {
// Sync input with value // Sync input with value
@@ -190,18 +196,20 @@ const BoxerInner = () => {
.map((item) => item.label) .map((item) => item.label)
.join(', '); .join(', ');
if (input !== labels && search === '') { // When dropdown is closed, show selected labels. When open, allow searching
if (!opened && input !== labels) {
setInput(labels); setInput(labels);
setSearch('');
} }
} else { } else {
const label = boxerData.find((item) => item.value === value)?.label; const label = boxerData.find((item) => item.value === value)?.label;
if ( // Only sync if we need to update the input to match the value
(input !== label && (search ?? '') === '' && valueRef.current !== value) || if (input !== label && (search ?? '') === '' && valueRef.current !== value && value) {
(!value && (search ?? '') === '')
) {
setSearch('');
setInput(label ?? ''); setInput(label ?? '');
} else if (!value && !valueRef.current && (search ?? '') === '') {
setSearch('');
setInput('');
} }
} }
@@ -223,7 +231,7 @@ const BoxerInner = () => {
bufferRef.current = buffer; bufferRef.current = buffer;
} }
} }
}, [value, boxerData, input, search, multiSelect]); }, [value, boxerData, input, search, multiSelect, opened, onBufferChange, setInput, setSearch]);
useEffect(() => { useEffect(() => {
// Select first option automatically // Select first option automatically
@@ -234,6 +242,30 @@ const BoxerInner = () => {
} }
}, [selectFirst, boxerData, multiSelect]); }, [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 ( return (
<Combobox <Combobox
{...comboBoxProps} {...comboBoxProps}
@@ -329,12 +361,14 @@ const BoxerInner = () => {
</Combobox.Dropdown> </Combobox.Dropdown>
</Combobox> </Combobox>
); );
}; });
BoxerInner.displayName = 'BoxerInner';
const Boxer = React.forwardRef<BoxerRef, BoxerProps>((props, ref) => { const Boxer = React.forwardRef<BoxerRef, BoxerProps>((props, ref) => {
return ( return (
<BoxerProvider {...props}> <BoxerProvider {...props}>
<BoxerInner /> <BoxerInner ref={ref} />
</BoxerProvider> </BoxerProvider>
); );
}); });

View File

@@ -1,5 +1,5 @@
import type { ComboboxProps, ScrollAreaAutosizeProps, TextInputProps } from '@mantine/core'; 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 = export type BoxerDataSource =
| 'local' // Local array data | 'local' // Local array data
@@ -45,6 +45,8 @@ export interface BoxerProps {
openOnClear?: boolean; openOnClear?: boolean;
pageSize?: number;
// UI Configuration // UI Configuration
placeholder?: string; placeholder?: string;
// Styling // Styling
@@ -58,7 +60,7 @@ export interface BoxerProps {
// Value Management // Value Management
value?: any | Array<any>; value?: any | Array<any>;
// Virtualization // Virtualization
virtualizer?: Partial<UseVirtualizerOptions<HTMLDivElement, Element>>; virtualizer?: Partial<VirtualizerOptions<HTMLDivElement, Element>>;
} }
export interface BoxerRef { export interface BoxerRef {

View File

@@ -24,6 +24,9 @@ const useBoxerOptions = (props: UseBoxerOptionsProps) => {
key={`${item.value}-${index}`} key={`${item.value}-${index}`}
value={String(index)} value={String(index)}
active={isSelected} active={isSelected}
onClick={() => {
onOptionSubmit(index);
}}
> >
{multiSelect ? ( {multiSelect ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
@@ -36,7 +39,7 @@ const useBoxerOptions = (props: UseBoxerOptionsProps) => {
</Combobox.Option> </Combobox.Option>
); );
}); });
}, [boxerData, value, multiSelect]); }, [boxerData, value, multiSelect, onOptionSubmit]);
return { options }; return { options };
}; };

View File

@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react';
import { useState } from 'react'; import { useState } from 'react';
import type { BoxerItem, BoxerProps } from '../Boxer.types'; import type { BoxerItem } from '../Boxer.types';
import { Boxer } from '../Boxer'; import { Boxer } from '../Boxer';