diff --git a/src/WindowedSelect.tsx b/src/WindowedSelect.tsx index d2919ca..cca8f47 100644 --- a/src/WindowedSelect.tsx +++ b/src/WindowedSelect.tsx @@ -1,24 +1,186 @@ import MenuList from './MenuList'; import * as React from 'react'; -import Select, { Props as SelectProps } from 'react-select'; -import { calcOptionsLength } from './util'; +import Select, { Props as SelectProps, GroupBase, components as defaultComponents } from 'react-select'; +import { calcOptionsLength, flattenOptions } from './util'; + +interface SelectAllConfig { + /** Label shown when not all options are selected. Default: "Select All" */ + selectAllLabel?: string + /** Label shown when all options are selected. Default: "Deselect All" */ + deselectAllLabel?: string +} interface WindowedSelectProps extends SelectProps { windowThreshold: number + /** + * Enable a "Select All" / "Deselect All" option at the top of the menu. + * Only works when `isMulti` is true. + * Pass `true` for default labels, or an object to customize labels. + */ + enableSelectAll?: boolean | SelectAllConfig } -function WindowedSelect ({ windowThreshold = 100, ...passedProps }: WindowedSelectProps, ref) { +const SELECT_ALL_VALUE = '__windowed_select_all__'; + +function isSelectAllOption(option: any): boolean { + return option && option.value === SELECT_ALL_VALUE; +} + +function WindowedSelect ({ windowThreshold = 100, enableSelectAll, ...passedProps }: WindowedSelectProps, ref) { + const { options, isMulti, value, onChange, filterOption } = passedProps; + const [inputValue, setInputValue] = React.useState(''); + const optionsLength = React.useMemo( - () => calcOptionsLength(passedProps.options), - [passedProps.options] + () => calcOptionsLength(options), + [options] ); const isWindowed = optionsLength >= windowThreshold; + // Flatten grouped options for comparison + const allFlatOptions = React.useMemo( + () => flattenOptions(options || []), + [options] + ); + + // Compute filtered options based on current search input + const filteredOptions = React.useMemo(() => { + if (!inputValue) return allFlatOptions; + + return allFlatOptions.filter(option => { + if (filterOption) { + return filterOption( + { label: option.label, value: option.value, data: option }, + inputValue + ); + } + // Default: case-insensitive label match + return String(option.label || '').toLowerCase().includes(inputValue.toLowerCase()); + }); + }, [allFlatOptions, inputValue, filterOption]); + + const shouldShowSelectAll = enableSelectAll && isMulti; + + // Determine if all filtered options are currently selected + const selectedValues = React.useMemo(() => { + if (!Array.isArray(value)) return new Set(); + const getOptValue = passedProps.getOptionValue || ((opt: any) => opt.value); + return new Set(value.map(v => getOptValue(v))); + }, [value, passedProps.getOptionValue]); + + const allFilteredSelected = React.useMemo(() => { + if (!filteredOptions.length) return false; + const getOptValue = passedProps.getOptionValue || ((opt: any) => opt.value); + return filteredOptions.every(opt => selectedValues.has(getOptValue(opt))); + }, [filteredOptions, selectedValues, passedProps.getOptionValue]); + + // Build select all option + const selectAllOption = React.useMemo(() => { + if (!shouldShowSelectAll) return null; + + const config: SelectAllConfig = typeof enableSelectAll === 'object' ? enableSelectAll : {}; + const label = allFilteredSelected + ? (config.deselectAllLabel || 'Deselect All') + : (config.selectAllLabel || 'Select All'); + + return { label, value: SELECT_ALL_VALUE }; + }, [shouldShowSelectAll, allFilteredSelected, enableSelectAll]); + + // Prepend select all option to the options list + const enhancedOptions = React.useMemo(() => { + if (!selectAllOption || !options) return options; + + // Handle grouped options + const head = (options as any[])[0] || {}; + if (head.options !== undefined) { + // Grouped: insert as a standalone group at the top + return [ + { label: '', options: [selectAllOption] } as GroupBase, + ...(options as GroupBase[]) + ]; + } + + return [selectAllOption, ...(options as any[])]; + }, [selectAllOption, options]); + + // Intercept onChange to handle select all toggle + const handleChange = React.useCallback((selected: any, actionMeta: any) => { + if (!onChange) return; + + if (shouldShowSelectAll && actionMeta.action === 'select-option' && isSelectAllOption(actionMeta.option)) { + if (allFilteredSelected) { + // Deselect all filtered options, keep options that aren't in the filtered set + const getOptValue = passedProps.getOptionValue || ((opt: any) => opt.value); + const filteredValueSet = new Set(filteredOptions.map(opt => getOptValue(opt))); + const remaining = Array.isArray(value) + ? value.filter(v => !filteredValueSet.has(getOptValue(v))) + : []; + onChange(remaining, { ...actionMeta, action: 'deselect-option' }); + } else { + // Select all filtered options, merged with already selected + const getOptValue = passedProps.getOptionValue || ((opt: any) => opt.value); + const currentSelected = Array.isArray(value) ? [...value] : []; + const newOptions = filteredOptions.filter( + opt => !selectedValues.has(getOptValue(opt)) + ); + onChange([...currentSelected, ...newOptions], actionMeta); + } + return; + } + + // Filter out the select-all option from the selected values + if (shouldShowSelectAll && Array.isArray(selected)) { + selected = selected.filter(opt => !isSelectAllOption(opt)); + } + + onChange(selected, actionMeta); + }, [onChange, shouldShowSelectAll, allFilteredSelected, filteredOptions, value, selectedValues, passedProps.getOptionValue]); + + // Track input value for filtering + const handleInputChange = React.useCallback((newInputValue: string, actionMeta: any) => { + setInputValue(newInputValue); + if (passedProps.onInputChange) { + passedProps.onInputChange(newInputValue, actionMeta); + } + }, [passedProps.onInputChange]); + + // Custom Option component to style the select-all row differently + const SelectAllOptionComponent = React.useMemo(() => { + const UserOption = passedProps.components?.Option || defaultComponents.Option; + + return (props: any) => { + if (isSelectAllOption(props.data)) { + return ( + + {props.data.label} + + ); + } + return ; + }; + }, [passedProps.components?.Option, allFilteredSelected]); + + // Hide the select-all option from the selected values display + const isOptionSelected = React.useCallback((option: any, selectValue: any) => { + if (isSelectAllOption(option)) return allFilteredSelected; + if (passedProps.isOptionSelected) return passedProps.isOptionSelected(option, selectValue); + + const getOptValue = passedProps.getOptionValue || ((opt: any) => opt.value); + return selectedValues.has(getOptValue(option)); + }, [allFilteredSelected, selectedValues, passedProps.isOptionSelected, passedProps.getOptionValue]); + return (