diff --git a/src/components/Select.tsx b/src/components/Select.tsx index 0ebc9151..0d9622f6 100644 --- a/src/components/Select.tsx +++ b/src/components/Select.tsx @@ -10,11 +10,13 @@ import { useState, } from 'react' import { HiddenSelect, useSelect } from '@react-aria/select' -import { SelectState, useSelectState } from '@react-stately/select' -import { AriaSelectProps } from '@react-types/select' import { useButton } from '@react-aria/button' import styled, { useTheme } from 'styled-components' +import { AriaSelectProps } from '@react-types/select' + +import { BimodalSelectProps, BimodalSelectState, useBimodalSelectState } from '../utils/useBimodalSelectState' + import { ListBoxItemBaseProps } from './ListBoxItem' import DropdownArrowIcon from './icons/DropdownArrowIcon' import { PopoverListBox } from './PopoverListBox' @@ -44,8 +46,9 @@ export type SelectProps = Exclude & { placement?: Placement width?: string | number maxHeight?: string | number + onSelectionChange?: (arg: any) => any } & Omit< - AriaSelectProps, + BimodalSelectProps, 'autoFocus' | 'onLoadMore' | 'isLoading' | 'validationState' | 'placeholder' > @@ -147,6 +150,22 @@ const SelectInner = styled.div<{ }, })) +function Select( + props: Omit< + SelectProps, + 'selectionMode' | 'selectedKeys' | 'onSelectionChange' + > & { + selectionMode?: 'single' + } & Pick, 'onSelectionChange'> +): ReactElement +function Select( + props: Omit< + SelectProps, + 'selectionMode' | 'selectedKey' | 'onSelectionChange' + > & { + selectionMode: 'multiple' + } & { onSelectionChange: (keys: Set) => any } +): ReactElement function Select({ children, selectedKey, @@ -169,13 +188,16 @@ function Select({ maxHeight, ...props }: SelectProps) { - const stateRef = useRef | null>(null) + const stateRef = useRef | null>(null) const [isOpenUncontrolled, setIsOpen] = useState(false) const nextFocusedKeyRef = useRef(null) if (typeof isOpen !== 'boolean') { isOpen = isOpenUncontrolled } + if (props.selectionMode === 'multiple' && selectedKey) { + throw new Error('When using selectionMode="multiple", you must use "selectedKeys" instead of "selectedKey"') + } const selectStateBaseProps = useSelectComboStateProps({ dropdownHeader, @@ -190,7 +212,7 @@ function Select({ nextFocusedKeyRef, }) - const selectStateProps: AriaSelectProps = { + const selectStateProps: BimodalSelectProps = { ...selectStateBaseProps, isOpen, defaultOpen: false, @@ -199,7 +221,7 @@ function Select({ ...props, } - const state = useSelectState(selectStateProps) + const state = useBimodalSelectState(selectStateProps) setNextFocusedKey({ nextFocusedKeyRef, state, stateRef }) @@ -215,7 +237,14 @@ function Select({ rightContent={rightContent} isOpen={state.isOpen} > - {state.selectedItem?.props?.children?.props?.label || label} + {(props.selectionMode === 'multiple' + && state.selectedItems.length > 0 + && state.selectedItems + .map(item => item?.props?.children?.props?.label) + .filter(label => !!label) + .join(', ')) + || state.selectedItem?.props?.children?.props?.label + || label} ) diff --git a/src/components/SelectComboShared.tsx b/src/components/SelectComboShared.tsx index c074c188..afcc8a63 100644 --- a/src/components/SelectComboShared.tsx +++ b/src/components/SelectComboShared.tsx @@ -4,10 +4,13 @@ import { MutableRefObject, RefObject, SetStateAction, + useCallback, useRef, } from 'react' import { ListState } from '@react-stately/list' +import { Selection } from '@react-types/shared' + import { FOOTER_KEY, HEADER_KEY, useItemWrappedChildren } from './ListBox' import { ComboBoxProps } from './ComboBox' import { SelectProps } from './Select' @@ -34,6 +37,10 @@ type UseSelectComboStatePropsReturn = Pick< 'children' | 'onOpenChange' | 'onSelectionChange' > +function setDifference(a: Set, b: Set): Set { + return new Set([...a].filter(x => !b.has(x))) +} + function useSelectComboStateProps({ setIsOpen, onOpenChange, @@ -47,6 +54,8 @@ function useSelectComboStateProps({ nextFocusedKeyRef, }: UseSelectComboStatePropsArgs): UseSelectComboStatePropsReturn { const temporarilyPreventClose = useRef(false) + const getCurrentKeys = useCallback(() => new Set(stateRef.current?.selectionManager.selectedKeys ?? []), + [stateRef]) return { onOpenChange: (open: boolean, ...args: any[]) => { @@ -60,24 +69,37 @@ function useSelectComboStateProps({ onOpenChange.apply(this, [open, ...args]) } }, - onSelectionChange: (newKey, ...args) => { - if (newKey === HEADER_KEY && onHeaderClick) { - temporarilyPreventClose.current = true - onHeaderClick() + onSelectionChange: (newKeyOrKeys: Key | Selection, ...args: any) => { + let newKey: Key + + if ( + typeof newKeyOrKeys === 'string' + || typeof newKeyOrKeys === 'number' + ) { + newKey = newKeyOrKeys } - else if (newKey === FOOTER_KEY && onFooterClick) { + else { + const currentKeys = getCurrentKeys() + const diff = setDifference(newKeyOrKeys, currentKeys) + + newKey = diff.keys().next().value || '' + } + switch (newKey) { + case HEADER_KEY: temporarilyPreventClose.current = true - onFooterClick() + onHeaderClick?.() + break + case FOOTER_KEY: + temporarilyPreventClose.current = true + onFooterClick?.() if (stateRef.current) { nextFocusedKeyRef.current - = stateRef?.current?.collection?.getKeyBefore(FOOTER_KEY) + = stateRef?.current?.collection?.getKeyBefore(FOOTER_KEY) } - } - else if (onSelectionChange) { - onSelectionChange.apply(this, [ - typeof newKey === 'string' ? newKey : '', - ...args, - ]) + break + default: + onSelectionChange?.apply(this, [newKeyOrKeys, ...args]) + break } }, children: useItemWrappedChildren(children, dropdownHeader, dropdownFooter), diff --git a/src/stories/Select.stories.tsx b/src/stories/Select.stories.tsx index fef2f66c..643e264e 100644 --- a/src/stories/Select.stories.tsx +++ b/src/stories/Select.stories.tsx @@ -1,4 +1,4 @@ -import { Div, Flex } from 'honorable' +import { Div, Flex, H4 } from 'honorable' import { ComponentProps, Key, @@ -37,7 +37,7 @@ const portrait = ( ) const smallIcon = -const chipProps:Partial> = { +const chipProps: Partial> = { size: 'small', hue: 'lighter', } @@ -177,18 +177,28 @@ function Template() { const shownStep = 4 const [shownLimit, setShownLimit] = useState(shownStep) + const [selectedKeys, setSelectedKeys] = useState(new Set(['pizza', 'sushi'])) + const curItem = items.find(item => item.key === selectedKey) const customLabel = curItem ? `You have selected ${curItem.label}` : 'Select an item please' + const curItems = items.filter(item => selectedKeys.has(item.key)) + const customLabelMultiple + = curItems.length > 0 + ? `Selections: ${curItems.map(item => item.label).join(', ')}` + : 'Select items' + return ( + {/* SINGLE SELECT */}
+

Single select

+ + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + + {/* MULTIPLE SELECT */} +

+ Multiple select +

+
+ +
+ +
+ +
+ +
+ +
+ + + + ) } diff --git a/src/utils/useBimodalSelectState.ts b/src/utils/useBimodalSelectState.ts new file mode 100644 index 00000000..dc309375 --- /dev/null +++ b/src/utils/useBimodalSelectState.ts @@ -0,0 +1,138 @@ +/* +Modified from: +https://github.com/adobe/react-spectrum/blob/main/packages/%40react-stately/select/src/useSelectState.ts +*/ + +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed 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 REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { useMenuTriggerState } from '@react-stately/menu' +import { AriaSelectProps } from '@react-types/select' +import { ListProps, useListState } from '@react-stately/list' +import { + Key, + useCallback, + useRef, + useState, +} from 'react' +import { useControlledState } from '@react-stately/utils' +import { Node } from '@react-types/shared' +import type { SelectState } from '@react-stately/select' + +export type BimodalSelectState = SelectState & { + selectedKeys: Set + setSelectedKeys: any + selectedItems: Node[] +} + +export type BimodalSelectProps = Omit< + AriaSelectProps, + 'onSelectionChange' +> & + Omit, 'onSelectionChange'> + +/** + * Provides state management for a select component. Handles building a collection + * of items from props, handles the open state for the popup menu, and manages + * multiple selection state. + */ +function useBimodalSelectState( + p: BimodalSelectProps & Pick, 'onSelectionChange'> +): BimodalSelectState +function useBimodalSelectState( + p: BimodalSelectProps & Pick, 'onSelectionChange'> +): BimodalSelectState +function useBimodalSelectState({ + selectionMode = 'single', + onSelectionChange: onSelectChangeProp, + ...props +}: BimodalSelectProps & { onSelectionChange: (arg: any) => any }): BimodalSelectState { + const [selectedKey, setSelectedKey] = useControlledState(selectionMode === 'single' ? props.selectedKey : undefined, + props.defaultSelectedKey ?? null, + selectionMode === 'single' ? onSelectChangeProp : undefined) + const listStateRef = useRef>() + const getAllKeys = useCallback(() => new Set(listStateRef.current?.collection?.getKeys() ?? []), + []) + const selectedKeys + = selectionMode === 'multiple' ? props.selectedKeys : new Set([selectedKey]) + const triggerState = useMenuTriggerState(props) + + const onSelectionChange = useCallback['onSelectionChange']>(keys => { + if (selectionMode === 'single' && keys !== 'all') { + const key = keys.values().next().value + + // Always fire onSelectionChange, even if the key is the same + // as the current key (useControlledState does not). + if (key === selectedKey && onSelectChangeProp) { + onSelectChangeProp(key) + } + + setSelectedKey(key) + triggerState.close() + } + if (selectionMode === 'multiple') { + onSelectChangeProp(keys === 'all' ? getAllKeys() : keys) + } + }, + [ + getAllKeys, + onSelectChangeProp, + selectedKey, + selectionMode, + setSelectedKey, + triggerState, + ]) + + const listState = useListState({ + disallowEmptySelection: selectionMode === 'single', + allowDuplicateSelectionEvents: true, + ...props, + selectionMode, + selectedKeys, + onSelectionChange, + }) + + listStateRef.current = listState + + const selectedItem + = selectedKey != null ? listState.collection.getItem(selectedKey) : null + + const selectedItems = Array.from(selectedKeys).map(key => listState.collection.getItem(key)) + + const [isFocused, setFocused] = useState(false) + + return { + ...listState, + ...triggerState, + selectedKey, + setSelectedKey, + selectedItem, + selectedKeys: listState.selectionManager.selectedKeys, // might not get updated every time + selectedItems, + setSelectedKeys: listState.selectionManager.setSelectedKeys, // might not get updated every time + open() { + // Don't open if the collection is empty. + if (listState.collection.size !== 0) { + triggerState.open() + } + }, + toggle(focusStrategy) { + if (listState.collection.size !== 0) { + triggerState.toggle(focusStrategy) + } + }, + isFocused, + setFocused, + } +} + +export { useBimodalSelectState }