Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 168 additions & 6 deletions src/WindowedSelect.tsx
Original file line number Diff line number Diff line change
@@ -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<any>,
...(options as GroupBase<any>[])
];
}

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 (
<UserOption
{...props}
isSelected={allFilteredSelected}
>
<span style={{ fontWeight: 500 }}>{props.data.label}</span>
</UserOption>
);
}
return <UserOption {...props} />;
};
}, [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 (
<Select
{...passedProps}
options={shouldShowSelectAll ? enhancedOptions : passedProps.options}
onChange={handleChange}
onInputChange={handleInputChange}
isOptionSelected={shouldShowSelectAll ? isOptionSelected : passedProps.isOptionSelected}
components={{
...passedProps.components,
...(shouldShowSelectAll ? { Option: SelectAllOptionComponent } : {}),
...(
isWindowed
? { MenuList }
Expand All @@ -30,4 +192,4 @@ function WindowedSelect ({ windowThreshold = 100, ...passedProps }: WindowedSele
);
}

export default React.forwardRef(WindowedSelect);
export default React.forwardRef(WindowedSelect);
13 changes: 13 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,19 @@ export function calcOptionsLength (options) {
: options.length;
}

export function flattenOptions (options: readonly any[]): any[] {
const head = options[0] || {};
const isGrouped = head.options !== undefined;

if (isGrouped) {
return (options as any[]).reduce((result: any[], group: any) => {
return [...result, ...(group.options || [])];
}, []);
}

return [...options];
}

export function flattenGroupedChildren(children) {
return children.reduce((result, child) => {
if (child.props.children != null && typeof child.props.children === "string") {
Expand Down