From a8e9c50290db07b227bec324b40dda8826e4fd83 Mon Sep 17 00:00:00 2001 From: "Hein (Warky)" Date: Sat, 17 Jan 2026 18:26:20 +0200 Subject: [PATCH] feat(Boxer): implement Boxer component with autocomplete and server-side support --- src/Boxer/Boxer.store.tsx | 159 +++++++++++++ src/Boxer/Boxer.tsx | 345 ++++++++++++++++++++++++++++ src/Boxer/Boxer.types.ts | 107 +++++++++ src/Boxer/BoxerTarget.tsx | 73 ++++++ src/Boxer/hooks/useBoxerOptions.tsx | 44 ++++ src/Boxer/index.ts | 10 + src/Boxer/stories/Boxer.stories.tsx | 217 +++++++++++++++++ src/Boxer/todo.md | 7 + src/lib.ts | 1 + 9 files changed, 963 insertions(+) create mode 100644 src/Boxer/Boxer.store.tsx create mode 100644 src/Boxer/Boxer.tsx create mode 100644 src/Boxer/Boxer.types.ts create mode 100644 src/Boxer/BoxerTarget.tsx create mode 100644 src/Boxer/hooks/useBoxerOptions.tsx create mode 100644 src/Boxer/stories/Boxer.stories.tsx diff --git a/src/Boxer/Boxer.store.tsx b/src/Boxer/Boxer.store.tsx new file mode 100644 index 0000000..05277e0 --- /dev/null +++ b/src/Boxer/Boxer.store.tsx @@ -0,0 +1,159 @@ +import { createSyncStore } from '@warkypublic/zustandsyncstore'; +import { produce } from 'immer'; + +import type { BoxerProps, BoxerStoreState } from './Boxer.types'; + +const { Provider: BoxerProvider, useStore: useBoxerStore } = createSyncStore< + BoxerStoreState, + BoxerProps +>( + (set, get) => ({ + boxerData: [], + // Data Actions + fetchData: async (search?: string, reset?: boolean) => { + const state = get(); + + // Handle local data + if (state.dataSource === 'local' || !state.onAPICall) { + const localData = state.data ?? []; + + if (!search) { + set({ boxerData: localData, hasMore: false, total: localData.length }); + return; + } + + // Filter local data based on search + const filtered = localData.filter((item) => + item.label.toLowerCase().includes(search.toLowerCase()) + ); + set({ boxerData: filtered, hasMore: false, total: filtered.length }); + return; + } + + // Handle server-side data + if (state.onAPICall) { + try { + set({ isFetching: true }); + + const currentPage = reset ? 0 : state.page; + + const result = await state.onAPICall({ + page: currentPage, + pageSize: state.pageSize, + search, + }); + + set( + produce((draft: BoxerStoreState) => { + if (reset) { + draft.boxerData = result.data; + draft.page = 0; + } else { + draft.boxerData = [...(draft.boxerData ?? []), ...result.data]; + } + draft.total = result.total; + draft.hasMore = draft.boxerData.length < result.total; + draft.isFetching = false; + }) + ); + } catch (error) { + console.error('Boxer fetchData error:', error); + set({ isFetching: false }); + } + } + }, + fetchMoreOnBottomReached: (target: HTMLDivElement) => { + const state = get(); + + if (!state.hasMore || state.isFetching) { + return; + } + + const scrollPercentage = + (target.scrollTop + target.clientHeight) / target.scrollHeight; + + // Load more when scrolled past 80% + if (scrollPercentage > 0.8) { + state.loadMore(); + } + }, + // State Management + getState: (key) => { + const current = get(); + return current?.[key]; + }, + hasMore: true, + input: '', + isFetching: false, + loadMore: async () => { + const state = get(); + + if (!state.hasMore || state.isFetching) { + return; + } + + set( + produce((draft: BoxerStoreState) => { + draft.page = draft.page + 1; + }) + ); + + await state.fetchData(state.search); + }, + // Initial State + opened: false, + page: 0, + + pageSize: 50, + + search: '', + + selectedOptionIndex: -1, + + setInput: (input: string) => { + set({ input }); + }, + + // Actions + setOpened: (opened: boolean) => { + set({ opened }); + }, + + setSearch: (search: string) => { + set({ search }); + }, + + setSelectedOptionIndex: (index: number) => { + set({ selectedOptionIndex: index }); + }, + + setState: (key, value) => { + set( + produce((state) => { + state[key] = value; + }) + ); + }, + + total: 0, + }), + ({ + data = [], + dataSource = 'local', + pageSize = 50, + ...props + }) => { + return { + ...props, + boxerData: data, // Initialize with local data if provided + data, + dataSource, + hasMore: dataSource === 'server', + pageSize, + total: data.length, + }; + } +); + +export { BoxerProvider }; +export { useBoxerStore }; diff --git a/src/Boxer/Boxer.tsx b/src/Boxer/Boxer.tsx new file mode 100644 index 0000000..4ccbc7f --- /dev/null +++ b/src/Boxer/Boxer.tsx @@ -0,0 +1,345 @@ +import { Combobox, ScrollArea, useVirtualizedCombobox } from '@mantine/core'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import React, { useEffect, useRef } from 'react'; + +import type { BoxerItem, BoxerProps, BoxerRef } from './Boxer.types'; + +import { BoxerProvider, useBoxerStore } from './Boxer.store'; +import BoxerTarget from './BoxerTarget'; +import useBoxerOptions from './hooks/useBoxerOptions'; + +const BoxerInner = () => { + // Component Refs + const parentRef = useRef(null); + const inputRef = useRef(null); + const valueRef = useRef(null); + const bufferRef = useRef(null); + + // Component store State + const { + boxerData, + clearable, + comboBoxProps, + dropDownProps, + error, + fetchData, + fetchMoreOnBottomReached, + input, + isFetching, + label, + mah, + multiSelect, + onBufferChange, + onChange, + opened, + openOnClear, + placeholder, + scrollAreaProps, + search, + selectedOptionIndex, + selectFirst, + setInput, + setOpened, + setSearch, + setSelectedOptionIndex, + showAll, + value, + } = useBoxerStore((state) => ({ + boxerData: state.boxerData, + clearable: state.clearable, + comboBoxProps: state.comboBoxProps, + dropDownProps: state.dropDownProps, + error: state.error, + fetchData: state.fetchData, + fetchMoreOnBottomReached: state.fetchMoreOnBottomReached, + input: state.input, + isFetching: state.isFetching, + label: state.label, + mah: state.mah, + multiSelect: state.multiSelect, + onBufferChange: state.onBufferChange, + onChange: state.onChange, + opened: state.opened, + openOnClear: state.openOnClear, + placeholder: state.placeholder, + scrollAreaProps: state.scrollAreaProps, + search: state.search, + selectedOptionIndex: state.selectedOptionIndex, + selectFirst: state.selectFirst, + setInput: state.setInput, + setOpened: state.setOpened, + setSearch: state.setSearch, + setSelectedOptionIndex: state.setSelectedOptionIndex, + showAll: state.showAll, + value: state.value, + })); + + // Virtualization setup + const count = boxerData.length; + const virtualizer = useVirtualizer({ + count, + estimateSize: () => 36, + getScrollElement: () => parentRef.current, + }); + const virtualItems = virtualizer.getVirtualItems(); + + // Component Callback Functions + const onClear = () => { + 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); + } + }; + + 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 + const combobox = useVirtualizedCombobox({ + getOptionId: (index) => boxerData[index]?.value ?? String(index), + onDropdownClose: () => { + setOpened(false); + }, + onDropdownOpen: () => { + if (!value || (multiSelect && (!Array.isArray(value) || value.length === 0))) { + setSearch(''); + setInput(''); + } + combobox.selectFirstOption(); + }, + onSelectedOptionSubmit: onOptionSubmit, + opened, + selectedOptionIndex, + setSelectedOptionIndex: (index) => { + setSelectedOptionIndex(index); + if (index !== -1) { + virtualizer.scrollToIndex(index); + } + }, + totalOptionsCount: boxerData.length, + }); + + // Component variables + const { options } = useBoxerOptions({ + boxerData, + multiSelect, + onOptionSubmit, + value, + }); + + // Component useEffects + useEffect(() => { + // Fetch initial data + fetchData('', true); + }, []); + + useEffect(() => { + // Handle search changes + const delayDebounceFn = setTimeout(() => { + if (search !== undefined) { + fetchData(search, true); + } + }, 300); + + return () => clearTimeout(delayDebounceFn); + }, [search]); + + useEffect(() => { + // Sync input with value + if (multiSelect) { + const labels = boxerData + .filter((item) => Array.isArray(value) && value.includes(item.value)) + .map((item) => item.label) + .join(', '); + + if (input !== labels && search === '') { + setInput(labels); + } + } else { + const label = boxerData.find((item) => item.value === value)?.label; + + if ( + (input !== label && (search ?? '') === '' && valueRef.current !== value) || + (!value && (search ?? '') === '') + ) { + setSearch(''); + setInput(label ?? ''); + } + } + + // Handle buffer change + if (multiSelect) { + const buffer = + boxerData.filter((item: BoxerItem) => Array.isArray(value) && value.includes(item.value)) ?? + []; + + if (JSON.stringify(bufferRef.current) !== JSON.stringify(buffer)) { + onBufferChange?.(buffer); + bufferRef.current = buffer; + } + } else { + const buffer = boxerData?.find((item: BoxerItem) => item.value === value) ?? null; + + if (bufferRef.current?.value !== buffer?.value) { + onBufferChange?.(buffer); + bufferRef.current = buffer; + } + } + }, [value, boxerData, input, search, multiSelect]); + + useEffect(() => { + // Select first option automatically + if (selectFirst && (boxerData?.length ?? 0) > 0 && !multiSelect) { + if (!value) { + onOptionSubmit?.(0); + } + } + }, [selectFirst, boxerData, multiSelect]); + + return ( + + + + { + if (!value && !multiSelect) { + setSearch(''); + setInput(''); + combobox.closeDropdown(); + setOpened(false); + } + }} + onClear={onClear} + onSearch={(event) => { + setSearch(event.currentTarget.value); + setInput(event.currentTarget.value); + setOpened(true); + }} + placeholder={placeholder} + ref={inputRef} + search={input} + /> + + + + { + if (inputRef.current) { + inputRef.current.value = ''; + inputRef.current?.focus(); + } + }} + p={2} + {...dropDownProps} + > + {opened && options.length > 0 ? ( + + { + fetchMoreOnBottomReached(event.currentTarget as HTMLDivElement); + }, + style: { border: '1px solid gray', borderRadius: 4 }, + }} + viewportRef={parentRef} + > +
+
+ {virtualItems.map((virtualRow) => ( +
+ {options[virtualRow.index]} +
+ ))} +
+
+
+
+ ) : ( + Nothing found + )} +
+
+ ); +}; + +const Boxer = React.forwardRef((props, ref) => { + return ( + + + + ); +}); + +Boxer.displayName = 'Boxer'; + +export { Boxer }; +export default Boxer; diff --git a/src/Boxer/Boxer.types.ts b/src/Boxer/Boxer.types.ts new file mode 100644 index 0000000..25651b8 --- /dev/null +++ b/src/Boxer/Boxer.types.ts @@ -0,0 +1,107 @@ +import type { ComboboxProps, ScrollAreaAutosizeProps, TextInputProps } from '@mantine/core'; +import type { UseVirtualizerOptions } from '@tanstack/react-virtual'; + +export type BoxerDataSource = + | 'local' // Local array data + | 'server'; // Server-side with infinite loading + +export type BoxerItem = { + [key: string]: any; + label: string; + value: any; +}; + +export interface BoxerProps { + clearable?: boolean; + // Component Props + comboBoxProps?: Partial; + + // Data Configuration + data?: Array; + + dataSource?: BoxerDataSource; + disabled?: boolean; + dropDownProps?: React.ComponentPropsWithoutRef<'div'>; + + error?: string; + // Advanced + id?: string; + inputProps?: Partial; + label?: string; + leftSection?: React.ReactNode; + mah?: number; // Max height for dropdown + + // Component Configuration + multiSelect?: boolean; + name?: string; + // API Configuration (for server-side) + onAPICall?: (params: { + page: number; + pageSize: number; + search?: string; + }) => Promise<{ data: Array; total: number }>; + onBufferChange?: (buffer: Array | BoxerItem | null) => void; + onChange?: (value: any | Array) => void; + + openOnClear?: boolean; + + // UI Configuration + placeholder?: string; + // Styling + rightSection?: React.ReactNode; + scrollAreaProps?: Partial; + searchable?: boolean; + + selectFirst?: boolean; + showAll?: boolean; + + // Value Management + value?: any | Array; + // Virtualization + virtualizer?: Partial>; +} + +export interface BoxerRef { + clear: () => void; + close: () => void; + focus: () => void; + getValue: () => any | Array; + open: () => void; + setValue: (value: any | Array) => void; +} + +export interface BoxerState { + // Data State + boxerData: Array; + fetchData: (search?: string, reset?: boolean) => Promise; + fetchMoreOnBottomReached: (target: HTMLDivElement) => void; + // State Management + getState: (key: K) => BoxerStoreState[K]; + hasMore: boolean; + + input: string; + isFetching: boolean; + // Data Actions + loadMore: () => Promise; + // Internal State + opened: boolean; + page: number; + + pageSize: number; + search: string; + selectedOptionIndex: number; + setInput: (input: string) => void; + + // Actions + setOpened: (opened: boolean) => void; + setSearch: (search: string) => void; + setSelectedOptionIndex: (index: number) => void; + + setState: ( + key: K, + value: Partial + ) => void; + total: number; +} + +export type BoxerStoreState = BoxerProps & BoxerState; diff --git a/src/Boxer/BoxerTarget.tsx b/src/Boxer/BoxerTarget.tsx new file mode 100644 index 0000000..3f25f3f --- /dev/null +++ b/src/Boxer/BoxerTarget.tsx @@ -0,0 +1,73 @@ +import type { ComboboxStore } from '@mantine/core'; + +import { ActionIcon, Loader, TextInput } from '@mantine/core'; +import { IconX } from '@tabler/icons-react'; +import React, { forwardRef } from 'react'; + +interface BoxerTargetProps { + clearable?: boolean; + combobox: ComboboxStore; + disabled?: boolean; + error?: string; + isFetching?: boolean; + label?: string; + leftSection?: React.ReactNode; + onBlur: () => void; + onClear: () => void; + onSearch: (event: React.ChangeEvent) => void; + placeholder?: string; + search: string; +} + +const BoxerTarget = forwardRef((props, ref) => { + const { + clearable = true, + combobox, + disabled, + error, + isFetching, + label, + leftSection, + onBlur, + onClear, + onSearch, + placeholder, + search, + } = props; + + const rightSection = isFetching ? ( + + ) : search && clearable ? ( + { + e.stopPropagation(); + onClear(); + }} + size="sm" + variant="subtle" + > + + + ) : null; + + return ( + combobox.openDropdown()} + onFocus={() => combobox.openDropdown()} + placeholder={placeholder} + ref={ref} + rightSection={rightSection} + value={search} + /> + ); +}); + +BoxerTarget.displayName = 'BoxerTarget'; + +export default BoxerTarget; diff --git a/src/Boxer/hooks/useBoxerOptions.tsx b/src/Boxer/hooks/useBoxerOptions.tsx new file mode 100644 index 0000000..0244bb2 --- /dev/null +++ b/src/Boxer/hooks/useBoxerOptions.tsx @@ -0,0 +1,44 @@ +import { Combobox, Checkbox } from '@mantine/core'; +import { useMemo } from 'react'; + +import type { BoxerItem } from '../Boxer.types'; + +interface UseBoxerOptionsProps { + boxerData: Array; + value?: any | Array; + multiSelect?: boolean; + onOptionSubmit: (index: number) => void; +} + +const useBoxerOptions = (props: UseBoxerOptionsProps) => { + const { boxerData, value, multiSelect, onOptionSubmit } = props; + + const options = useMemo(() => { + return boxerData.map((item, index) => { + const isSelected = multiSelect + ? Array.isArray(value) && value.includes(item.value) + : value === item.value; + + return ( + + {multiSelect ? ( +
+ {}} tabIndex={-1} /> + {item.label} +
+ ) : ( + item.label + )} +
+ ); + }); + }, [boxerData, value, multiSelect]); + + return { options }; +}; + +export default useBoxerOptions; diff --git a/src/Boxer/index.ts b/src/Boxer/index.ts index e69de29..14c4740 100644 --- a/src/Boxer/index.ts +++ b/src/Boxer/index.ts @@ -0,0 +1,10 @@ +export { Boxer, default } from './Boxer'; +export { BoxerProvider, useBoxerStore } from './Boxer.store'; +export type { + BoxerDataSource, + BoxerItem, + BoxerProps, + BoxerRef, + BoxerState, + BoxerStoreState, +} from './Boxer.types'; diff --git a/src/Boxer/stories/Boxer.stories.tsx b/src/Boxer/stories/Boxer.stories.tsx new file mode 100644 index 0000000..bb892ac --- /dev/null +++ b/src/Boxer/stories/Boxer.stories.tsx @@ -0,0 +1,217 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { useState } from 'react'; + +import type { BoxerItem, BoxerProps } from '../Boxer.types'; + +import { Boxer } from '../Boxer'; + +const meta: Meta = { + component: Boxer, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + title: 'Components/Boxer', +}; + +export default meta; +type Story = StoryObj; + +// Sample data +const sampleData: Array = [ + { label: 'Apple', value: 'apple' }, + { label: 'Banana', value: 'banana' }, + { label: 'Cherry', value: 'cherry' }, + { label: 'Date', value: 'date' }, + { label: 'Elderberry', value: 'elderberry' }, + { label: 'Fig', value: 'fig' }, + { label: 'Grape', value: 'grape' }, + { label: 'Honeydew', value: 'honeydew' }, + { label: 'Kiwi', value: 'kiwi' }, + { label: 'Lemon', value: 'lemon' }, + { label: 'Mango', value: 'mango' }, + { label: 'Nectarine', value: 'nectarine' }, + { label: 'Orange', value: 'orange' }, + { label: 'Papaya', value: 'papaya' }, + { label: 'Quince', value: 'quince' }, + { label: 'Raspberry', value: 'raspberry' }, + { label: 'Strawberry', value: 'strawberry' }, + { label: 'Tangerine', value: 'tangerine' }, + { label: 'Ugli Fruit', value: 'ugli' }, + { label: 'Watermelon', value: 'watermelon' }, +]; + +// Local Data Example +export const LocalData: Story = { + render: () => { + const [value, setValue] = useState(null); + + return ( +
+ +
+ Selected Value: {value ?? 'None'} +
+
+ ); + }, +}; + +// Multi-Select Example +export const MultiSelect: Story = { + render: () => { + const [value, setValue] = useState>([]); + + return ( +
+ +
+ Selected Values:{' '} + {value.length > 0 ? value.join(', ') : 'None'} +
+
+ ); + }, +}; + +// Server-Side Example (Simulated) +export const ServerSide: Story = { + render: () => { + const [value, setValue] = useState(null); + + // Simulate server-side API call + const handleAPICall = async (params: { + page: number; + pageSize: number; + search?: string; + }): Promise<{ data: Array; total: number }> => { + // Simulate network delay + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Filter based on search + let filteredData = [...sampleData]; + if (params.search) { + filteredData = filteredData.filter((item) => + item.label.toLowerCase().includes(params.search!.toLowerCase()) + ); + } + + // Paginate + const start = params.page * params.pageSize; + const end = start + params.pageSize; + const paginatedData = filteredData.slice(start, end); + + return { + data: paginatedData, + total: filteredData.length, + }; + }; + + return ( +
+ +
+ Selected Value: {value ?? 'None'} +
+
+ ); + }, +}; + +// Select First Example +export const SelectFirst: Story = { + render: () => { + const [value, setValue] = useState(null); + + return ( +
+ +
+ Selected Value: {value ?? 'None'} +
+
+ ); + }, +}; + +// With Error +export const WithError: Story = { + render: () => { + const [value, setValue] = useState(null); + + return ( +
+ +
+ ); + }, +}; + +// Disabled +export const Disabled: Story = { + render: () => { + return ( +
+ {}} + placeholder="Select a fruit" + value="apple" + /> +
+ ); + }, +}; diff --git a/src/Boxer/todo.md b/src/Boxer/todo.md index 4d829cb..aee5ab1 100644 --- a/src/Boxer/todo.md +++ b/src/Boxer/todo.md @@ -1,3 +1,10 @@ +The plan and requirements: +Auto complete lookup with server side lookup support and infinite loading. +It must also have local array lookup and autocomplete. +When a users starts typing, it must start autocomplete list. +Exiting selected item must always be the first on the list and populated from the input in case the options does not exist anymore, it must not beak existing data. + + - [ ] Auto Complete - [ ] Multi Select - [ ] Virtualize diff --git a/src/lib.ts b/src/lib.ts index 4cc5bd1..ea6858f 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -1,3 +1,4 @@ +export * from './Boxer'; export * from './Former'; export * from './FormerControllers'; export * from './Gridler';