Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
* limitations under the License.
*/

import React from "react";
import React, { useMemo, useRef, useState } from "react";
import {
default as ReactSelect,
Props as ReactSelectProps,
Expand All @@ -28,6 +28,7 @@ import {
} from 'react-select';

import { selectStyles } from "@/v2/constants/select.constants";
import MultiSelectMenuList from './multiSelectMenuList';


// ------------- Types -------------- //
Expand All @@ -40,16 +41,20 @@ interface MultiSelectProps extends ReactSelectProps<Option, true> {
options: Option[];
selected: Option[];
placeholder: string;
fixedColumn: string;
// Accept a single key or an array of keys for columns that are always
// selected, hidden from the dropdown, and preserved through Unselect All.
fixedColumn: string | string[];
columnLength: number;
style?: StylesConfig<Option, true>;
showSearch?: boolean;
showSelectAll?: boolean;
onChange: (arg0: ValueType<Option, true>) => void;
onTagClose: (arg0: string) => void;
}

// ------------- Component -------------- //
// ------------- Module-level sub-components -------------- //

const Option: React.FC<OptionProps<Option, true>> = (props) => {
const OptionComponent: React.FC<OptionProps<Option, true>> = (props) => {
return (
<div>
<components.Option
Expand All @@ -66,9 +71,23 @@ const Option: React.FC<OptionProps<Option, true>> = (props) => {
<label>{props.label}</label>
</components.Option>
</div>
)
}
);
};

// Defined at module level so the reference is stable — no useMemo required.
// Suppresses react-select's blur-driven menu close while the user interacts
// with the search box inside the menu.
// searchInteracting ref is passed through react-select's selectProps.
const MultiSelectInput = ({ onBlur, ...inputProps }: any) => {
const { searchInteracting } = inputProps.selectProps ?? {};
const handleBlur = (e: React.FocusEvent<HTMLElement>) => {
if (searchInteracting?.current) return;
if (onBlur) onBlur(e);
};
return <input {...inputProps} onBlur={handleBlur} />;
};

// ------------- Component -------------- //

const MultiSelect: React.FC<MultiSelectProps> = ({
options = [],
Expand All @@ -80,14 +99,40 @@ const MultiSelect: React.FC<MultiSelectProps> = ({
columnLength,
tagRef,
style,
onTagClose = () => { }, // Assign default value as a void function
onChange = () => { }, // Assign default value as a void function
showSearch = false,
showSelectAll = false,
onTagClose = () => { },
onChange = () => { },
...props
}) => {
const [searchTerm, setSearchTerm] = useState('');
const [isMenuOpen, setIsMenuOpen] = useState(false);

const ValueContainer = ({ children, ...props }: ValueContainerProps<Option, true>) => {
// True while the user's pointer/keyboard focus is inside the search wrapper.
// Passed via selectProps so MultiSelectInput and MultiSelectMenuList can
// suppress react-select's blur-driven close without a stale closure.
const searchInteracting = useRef(false);
// Ref to the outer container div — used to detect "focus left the widget".
const containerRef = useRef<HTMLDivElement>(null);

// Normalise fixedColumn to an array of keys for uniform handling.
const fixedKeys: string[] = Array.isArray(fixedColumn)
? fixedColumn.filter(Boolean)
: fixedColumn ? [fixedColumn] : [];

const fixedOptions = options.filter((opt) => fixedKeys.includes(opt.value));
const selectableOptions = options.filter((opt) => !fixedKeys.includes(opt.value));

const filteredOptions = useMemo(() => {
if (!showSearch || !searchTerm) return selectableOptions;
return selectableOptions.filter((opt) =>
opt.label.toLowerCase().includes(searchTerm.toLowerCase())
);
}, [options, searchTerm, showSearch]);

const ValueContainer = ({ children, ...vcProps }: ValueContainerProps<Option, true>) => {
return (
<components.ValueContainer {...props}>
<components.ValueContainer {...vcProps}>
{React.Children.map(children, (child) => (
((child as React.ReactElement<any, string
| React.JSXElementConstructor<any>>
Expand All @@ -97,44 +142,91 @@ const MultiSelect: React.FC<MultiSelectProps> = ({
)}
{isDisabled
? placeholder
: `${placeholder}: ${selected.filter((opt) => opt.value !== fixedColumn).length} selected`
: `${placeholder}: ${selected.filter((opt) => !fixedKeys.includes(opt.value)).length} selected`
}
</components.ValueContainer>
);
};

const finalStyles = {...selectStyles, ...style ?? {}}
// Plain function — setters and refs are stable so no useCallback is needed.
// Guard against closing while the user is interacting with the search box.
// react-select fires onMenuClose when its DummyInput blurs, which also
// happens when we programmatically focus the search input (step in onClick).
// The searchInteracting ref blocks that false-positive close.
// For genuine outside clicks while the search box is focused, the race
// condition means this guard temporarily wins, but the 150ms onBlur
// fallback in MultiSelectMenuList closes the menu shortly after.
const handleMenuClose = () => {
if (!searchInteracting.current) {
setIsMenuOpen(false);
setSearchTerm('');
}
};

const searchModeProps = showSearch
? {
menuIsOpen: isMenuOpen,
onMenuOpen: () => setIsMenuOpen(true),
onMenuClose: handleMenuClose
}
: {};

const fixedOption = fixedColumn ? options.find((opt) => opt.value === fixedColumn) : undefined;
const selectableOptions = fixedColumn ? options.filter((opt) => opt.value !== fixedColumn) : options;
const finalStyles = { ...selectStyles, ...style ?? {} };

return (
// Extra data passed via selectProps so the module-level MultiSelectMenuList
// and MultiSelectInput components can read current state without closures.
// customOnChange is renamed to avoid shadowing react-select's own onChange.
const menuListProps = {
searchTerm,
setSearchTerm,
showSearch,
showSelectAll,
selected,
selectableOptions,
fixedOptions,
customOnChange: onChange,
searchInteracting,
setIsMenuOpen,
containerRef
};

const select = (
<ReactSelect
{...props}
{...(searchModeProps as any)}
{...(menuListProps as any)}
isMulti={true}
closeMenuOnSelect={false}
hideSelectedOptions={false}
isClearable={false}
isSearchable={false}
controlShouldRenderValue={false}
classNamePrefix='multi-select'
options={selectableOptions}
options={filteredOptions}
components={{
ValueContainer,
Option
Option: OptionComponent,
MenuList: MultiSelectMenuList,
...(showSearch ? { Input: MultiSelectInput } : {})
}}
menuPortalTarget={document.body}
placeholder={placeholder}
value={selected.filter((opt) => opt.value !== fixedColumn)}
value={selected.filter((opt) => !fixedKeys.includes(opt.value))}
isDisabled={isDisabled}
onChange={(selected: ValueType<Option, true>) => {
const selectedOpts = (selected as Option[]) ?? [];
const withFixed = fixedOption ? [fixedOption, ...selectedOpts] : selectedOpts;
onChange={(selectedValue: ValueType<Option, true>) => {
const selectedOpts = (selectedValue as Option[]) ?? [];
const withFixed = [...fixedOptions, ...selectedOpts];
if (selectedOpts.length === selectableOptions.length) return onChange!(options);
return onChange!(withFixed);
}}
styles={finalStyles} />
)
}
);

// Wrap in a container div only when showSearch is active so we have a
// boundary for detecting "focus left the widget" in the search onBlur.
return showSearch
? <div ref={containerRef}>{select}</div>
: select;
};

export default MultiSelect;
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import React, { useRef } from 'react';
import { components } from 'react-select';


export type Option = {
label: string;
value: string;
}

// Intercepts react-select v3's onInputBlur to suppress menu-close while the
// user is interacting with the search box inside the MenuList.
export const MultiSelectInput: React.FC<any> = ({ onBlur, ...inputProps }) => {
const { searchInteracting } = inputProps.selectProps;
const handleBlur = (e: React.FocusEvent) => {
if (searchInteracting?.current) return;
if (onBlur) onBlur(e);
};
return <components.Input {...inputProps} onBlur={handleBlur} />;
};

// Custom MenuList for MultiSelect that renders an optional search box and
// Select All / Unselect All toggle above the option list.
// Defined as a standalone module-level component so react-select always
// receives a stable reference — no useMemo required.
// All state is passed through react-select's selectProps mechanism.
const MultiSelectMenuList = (props: any) => {
const {
searchTerm,
setSearchTerm,
showSearch,
showSelectAll,
selected,
selectableOptions,
fixedOptions,
customOnChange,
searchInteracting,
setIsMenuOpen,
containerRef
} = props.selectProps;

// Ref used to re-focus the input after e.preventDefault() on the wrapper's
// onMouseDown (which suppresses the browser's default focus-on-click).
const searchInputRef = useRef<HTMLInputElement>(null);

const allSelected = selectableOptions?.length > 0 &&
selectableOptions.every((opt: Option) =>
selected?.some((s: Option) => s.value === opt.value)
);

const handleSelectAll = () => {
customOnChange([...(fixedOptions ?? []), ...(selectableOptions ?? [])]);
};

const handleUnselectAll = () => {
customOnChange(fixedOptions ?? []);
};

return (
<components.MenuList {...props}>
{showSearch && (
<div
style={{ padding: '8px 12px' }}
onMouseDown={(e: React.MouseEvent) => {
if (searchInteracting) searchInteracting.current = true;
// Prevent react-select's DummyInput from receiving the blur that
// would otherwise close the menu, but e.preventDefault() also
// suppresses the browser's default focus-on-click for child inputs,
// so we manually restore focus in onClick below.
e.preventDefault();
}}
onClick={() => searchInputRef.current?.focus()}
onFocus={() => {
if (searchInteracting) searchInteracting.current = true;
}}
onBlur={() => {
if (searchInteracting) searchInteracting.current = false;
setTimeout(() => {
const container = containerRef?.current;
if (container && !container.contains(document.activeElement)) {
setIsMenuOpen?.(false);
setSearchTerm?.('');
}
}, 150);
}}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
if (searchInteracting) searchInteracting.current = false;
setIsMenuOpen?.(false);
setSearchTerm?.('');
}
e.stopPropagation();
}}
>
<input
ref={searchInputRef}
type='text'
placeholder='Search...'
value={searchTerm ?? ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setSearchTerm?.(e.target.value)
}
style={{
width: '100%',
padding: '6px 8px',
borderRadius: '4px',
border: '1px solid #d9d9d9',
fontSize: '14px',
boxSizing: 'border-box',
outline: 'none'
}}
/>
</div>
)}
{showSelectAll && (
<div
style={{
padding: '6px 12px',
cursor: 'pointer',
borderBottom: '1px solid #f0f0f0',
display: 'flex',
alignItems: 'center'
}}
onMouseDown={(e: React.MouseEvent) => e.preventDefault()}
onClick={() => allSelected ? handleUnselectAll() : handleSelectAll()}
>
<input
type='checkbox'
checked={allSelected ?? false}
onChange={() => null}
style={{ marginRight: '8px', accentColor: '#1AA57A' }}
/>
<label style={{ cursor: 'pointer' }}>
{allSelected ? 'Unselect All' : 'Select All'}
</label>
</div>
)}
{props.children}
</components.MenuList>
);
};

export default MultiSelectMenuList;
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ const SingleSelect: React.FC<SingleSelectProps> = ({
components={{
ValueContainer
}}
menuPortalTarget={document.body}
placeholder={placeholder}
onChange={(selected: ValueType<Option, false>) => {
return onChange!(selected);
Expand Down
Loading