feat(Boxer): add @tanstack/react-virtual dependency and enhance Boxer component with improved option handling and ref exposure
This commit is contained in:
@@ -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
20
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,7 +84,42 @@ const BoxerInner = () => {
|
|||||||
const virtualItems = virtualizer.getVirtualItems();
|
const virtualItems = virtualizer.getVirtualItems();
|
||||||
|
|
||||||
// Component Callback Functions
|
// 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) {
|
if (showAll && selectFirst) {
|
||||||
onOptionSubmit(0);
|
onOptionSubmit(0);
|
||||||
} else {
|
} else {
|
||||||
@@ -101,40 +136,11 @@ const BoxerInner = () => {
|
|||||||
if (openOnClear) {
|
if (openOnClear) {
|
||||||
setOpened(true);
|
setOpened(true);
|
||||||
}
|
}
|
||||||
};
|
}, [showAll, selectFirst, multiSelect, onChange, setSearch, setInput, openOnClear, setOpened, onOptionSubmit]);
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user