From 1fb57d3454cb8c492615278e5d80f4a6b10b31be Mon Sep 17 00:00:00 2001 From: "Hein (Warky)" Date: Sat, 17 Jan 2026 19:33:19 +0200 Subject: [PATCH] feat(Boxer): add @tanstack/react-virtual dependency and enhance Boxer component with improved option handling and ref exposure --- package.json | 5 +- pnpm-lock.yaml | 20 +++++ src/Boxer/Boxer.store.tsx | 4 +- src/Boxer/Boxer.tsx | 124 ++++++++++++++++++---------- src/Boxer/Boxer.types.ts | 6 +- src/Boxer/hooks/useBoxerOptions.tsx | 5 +- src/Boxer/stories/Boxer.stories.tsx | 2 +- 7 files changed, 113 insertions(+), 53 deletions(-) diff --git a/package.json b/package.json index 2f11be5..9c4fd43 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 962ddeb..58e8bcc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/Boxer/Boxer.store.tsx b/src/Boxer/Boxer.store.tsx index 05277e0..85f0010 100644 --- a/src/Boxer/Boxer.store.tsx +++ b/src/Boxer/Boxer.store.tsx @@ -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; }) ); diff --git a/src/Boxer/Boxer.tsx b/src/Boxer/Boxer.tsx index 4ccbc7f..16fdea0 100644 --- a/src/Boxer/Boxer.tsx +++ b/src/Boxer/Boxer.tsx @@ -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((_, ref) => { // Component Refs const parentRef = useRef(null); const inputRef = useRef(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 ( { ); -}; +}); + +BoxerInner.displayName = 'BoxerInner'; const Boxer = React.forwardRef((props, ref) => { return ( - + ); }); diff --git a/src/Boxer/Boxer.types.ts b/src/Boxer/Boxer.types.ts index 25651b8..cac466d 100644 --- a/src/Boxer/Boxer.types.ts +++ b/src/Boxer/Boxer.types.ts @@ -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; // Virtualization - virtualizer?: Partial>; + virtualizer?: Partial>; } export interface BoxerRef { diff --git a/src/Boxer/hooks/useBoxerOptions.tsx b/src/Boxer/hooks/useBoxerOptions.tsx index 0244bb2..7028ecf 100644 --- a/src/Boxer/hooks/useBoxerOptions.tsx +++ b/src/Boxer/hooks/useBoxerOptions.tsx @@ -24,6 +24,9 @@ const useBoxerOptions = (props: UseBoxerOptionsProps) => { key={`${item.value}-${index}`} value={String(index)} active={isSelected} + onClick={() => { + onOptionSubmit(index); + }} > {multiSelect ? (
@@ -36,7 +39,7 @@ const useBoxerOptions = (props: UseBoxerOptionsProps) => { ); }); - }, [boxerData, value, multiSelect]); + }, [boxerData, value, multiSelect, onOptionSubmit]); return { options }; }; diff --git a/src/Boxer/stories/Boxer.stories.tsx b/src/Boxer/stories/Boxer.stories.tsx index bb892ac..05cad78 100644 --- a/src/Boxer/stories/Boxer.stories.tsx +++ b/src/Boxer/stories/Boxer.stories.tsx @@ -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';