diff --git a/packages/react-core/src/next/components/Select/Select.tsx b/packages/react-core/src/next/components/Select/Select.tsx new file mode 100644 index 00000000000..b2eed72f714 --- /dev/null +++ b/packages/react-core/src/next/components/Select/Select.tsx @@ -0,0 +1,129 @@ +import React from 'react'; +import { css } from '@patternfly/react-styles'; +import { Menu, MenuContent, MenuProps } from '../../../components/Menu'; +import { Popper } from '../../../helpers/Popper/Popper'; +import { getOUIAProps, OUIAProps, getDefaultOUIAId } from '../../../helpers'; + +export interface SelectProps extends MenuProps, OUIAProps { + /** Anything which can be rendered in a select */ + children?: React.ReactNode; + /** Classes applied to root element of select */ + className?: string; + /** Flag to indicate if select is open */ + isOpen?: boolean; + /** Single itemId for single select menus, or array of itemIds for multi select. You can also specify isSelected on the SelectOption. */ + selected?: any | any[]; + /** Renderer for a custom select toggle. Forwards a ref to the toggle. */ + toggle: (toggleRef: React.RefObject) => React.ReactNode; + /** Function callback when user selects an option. */ + onSelect?: (event?: React.MouseEvent, itemId?: string | number) => void; + /** Callback to allow the select component to change the open state of the menu. + * Triggered by clicking outside of the menu, or by pressing either tab or escape. */ + onOpenChange?: (isOpen: boolean) => void; + /** Indicates if the select should be without the outer box-shadow */ + isPlain?: boolean; + /** Minimum width of the select menu */ + minWidth?: string; + /** @hide Forwarded ref */ + innerRef?: React.Ref; +} + +const SelectBase: React.FunctionComponent = ({ + children, + className, + onSelect, + isOpen, + selected, + toggle, + onOpenChange, + isPlain, + minWidth, + innerRef, + ...props +}: SelectProps & OUIAProps) => { + const localMenuRef = React.useRef(); + const toggleRef = React.useRef(); + const containerRef = React.useRef(); + + const menuRef = (innerRef as React.RefObject) || localMenuRef; + React.useEffect(() => { + const handleMenuKeys = (event: KeyboardEvent) => { + if (!isOpen && toggleRef.current?.contains(event.target as Node)) { + // toggle was clicked open, focus on first menu item + if (event.key === 'Enter' || event.key === 'Space') { + setTimeout(() => { + const firstElement = menuRef?.current?.querySelector('li button:not(:disabled),li input:not(:disabled)'); + firstElement && (firstElement as HTMLElement).focus(); + }, 0); + } + } + // Close the menu on tab or escape if onOpenChange is provided + if ( + (isOpen && onOpenChange && menuRef.current?.contains(event.target as Node)) || + toggleRef.current?.contains(event.target as Node) + ) { + if (event.key === 'Escape' || event.key === 'Tab') { + onOpenChange(!isOpen); + toggleRef.current?.focus(); + } + } + }; + + const handleClickOutside = (event: MouseEvent) => { + // If the event is not on the toggle and onOpenChange callback is provided, close the menu + if (isOpen && onOpenChange && !toggleRef?.current?.contains(event.target as Node)) { + if (isOpen && !menuRef.current?.contains(event.target as Node)) { + onOpenChange(false); + } + } + }; + + window.addEventListener('keydown', handleMenuKeys); + window.addEventListener('click', handleClickOutside); + + return () => { + window.removeEventListener('keydown', handleMenuKeys); + window.removeEventListener('click', handleClickOutside); + }; + }, [isOpen, menuRef, onOpenChange]); + + const menu = ( + onSelect(event, itemId)} + isPlain={isPlain} + selected={selected} + {...(minWidth && { + style: { + '--pf-c-menu--MinWidth': minWidth + } as React.CSSProperties + })} + {...getOUIAProps( + Select.displayName, + props.ouiaId !== undefined ? props.ouiaId : getDefaultOUIAId(Select.displayName), + props.ouiaSafe !== undefined ? props.ouiaSafe : true + )} + {...props} + > + {children} + + ); + return ( +
+ +
+ ); +}; + +export const Select = React.forwardRef((props: SelectProps, ref: React.Ref) => ( + +)); + +Select.displayName = 'Select'; diff --git a/packages/react-core/src/next/components/Select/SelectGroup.tsx b/packages/react-core/src/next/components/Select/SelectGroup.tsx new file mode 100644 index 00000000000..4de9b3546b7 --- /dev/null +++ b/packages/react-core/src/next/components/Select/SelectGroup.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { css } from '@patternfly/react-styles'; +import { MenuGroupProps, MenuGroup } from '../../../components/Menu'; + +export interface SelectGroupProps extends Omit { + /** Anything which can be rendered in a select group */ + children: React.ReactNode; + /** Classes applied to root element of select group */ + className?: string; + /** Label of the select group */ + label?: string; +} + +export const SelectGroup: React.FunctionComponent = ({ + children, + className, + label, + ...props +}: SelectGroupProps) => ( + + {children} + +); +SelectGroup.displayName = 'SelectGroup'; diff --git a/packages/react-core/src/next/components/Select/SelectList.tsx b/packages/react-core/src/next/components/Select/SelectList.tsx new file mode 100644 index 00000000000..9ba2e0cc361 --- /dev/null +++ b/packages/react-core/src/next/components/Select/SelectList.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { css } from '@patternfly/react-styles'; +import { MenuListProps, MenuList } from '../../../components/Menu'; + +export interface SelectListProps extends MenuListProps { + /** Anything which can be rendered in a select list */ + children: React.ReactNode; + /** Classes applied to root element of select list */ + className?: string; +} + +export const SelectList: React.FunctionComponent = ({ + children, + className, + ...props +}: SelectListProps) => ( + + {children} + +); +SelectList.displayName = 'SelectList'; diff --git a/packages/react-core/src/next/components/Select/SelectOption.tsx b/packages/react-core/src/next/components/Select/SelectOption.tsx new file mode 100644 index 00000000000..d731bc59ed4 --- /dev/null +++ b/packages/react-core/src/next/components/Select/SelectOption.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { css } from '@patternfly/react-styles'; +import { MenuItemProps, MenuItem } from '../../../components/Menu'; + +export interface SelectOptionProps extends Omit { + /** Anything which can be rendered in a select option */ + children?: React.ReactNode; + /** Classes applied to root element of select option */ + className?: string; + /** Identifies the component in the Select onSelect callback */ + itemId?: any; + /** Indicates the option has a checkbox */ + hasCheck?: boolean; + /** Indicates the option is disabled */ + isDisabled?: boolean; + /** Indicates the option is selected */ + isSelected?: boolean; + /** Indicates the option is focused */ + isFocused?: boolean; +} + +export const SelectOption: React.FunctionComponent = ({ + children, + className, + ...props +}: SelectOptionProps) => ( + + {children} + +); +SelectOption.displayName = 'SelectOption'; diff --git a/packages/react-core/src/next/components/Select/examples/Select.md b/packages/react-core/src/next/components/Select/examples/Select.md new file mode 100644 index 00000000000..ae17b80c6d4 --- /dev/null +++ b/packages/react-core/src/next/components/Select/examples/Select.md @@ -0,0 +1,37 @@ +--- +id: Select +section: components +cssPrefix: pf-c-menu +propComponents: ['Select', SelectGroup, 'SelectOption', 'SelectList'] +beta: true +ouia: true +--- + +import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; + +## Examples + +### Single + +```ts file="./SelectBasic.tsx" +``` + +### Grouped single + +```ts file="./SelectGrouped.tsx" +``` + +### Checkbox + +```ts file="./SelectCheckbox.tsx" +``` + +### Typeahead + +```ts file="./SelectTypeahead.tsx" +``` + +### Multiple Typeahead + +```ts file="./SelectMultiTypeahead.tsx" +``` diff --git a/packages/react-core/src/next/components/Select/examples/SelectBasic.tsx b/packages/react-core/src/next/components/Select/examples/SelectBasic.tsx new file mode 100644 index 00000000000..c1519ea0de6 --- /dev/null +++ b/packages/react-core/src/next/components/Select/examples/SelectBasic.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { Select, SelectOption, SelectList } from '@patternfly/react-core/next'; +import { MenuToggle, MenuToggleElement } from '@patternfly/react-core'; + +export const SelectBasic: React.FunctionComponent = () => { + const [isOpen, setIsOpen] = React.useState(false); + const [selected, setSelected] = React.useState('Select a value'); + const menuRef = React.useRef(null); + + const onToggleClick = () => { + setIsOpen(!isOpen); + }; + + const onSelect = (_event: React.MouseEvent | undefined, itemId: string | number | undefined) => { + // eslint-disable-next-line no-console + console.log('selected', itemId); + + setSelected(itemId as string); + setIsOpen(false); + }; + + const toggle = (toggleRef: React.Ref) => ( + + {selected} + + ); + + return ( + + ); +}; diff --git a/packages/react-core/src/next/components/Select/examples/SelectCheckbox.tsx b/packages/react-core/src/next/components/Select/examples/SelectCheckbox.tsx new file mode 100644 index 00000000000..5ddc5e8dc2f --- /dev/null +++ b/packages/react-core/src/next/components/Select/examples/SelectCheckbox.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { Select, SelectOption, SelectList } from '@patternfly/react-core/next'; +import { MenuToggle, MenuToggleElement, Badge } from '@patternfly/react-core'; + +export const SelectCheckbox: React.FunctionComponent = () => { + const [isOpen, setIsOpen] = React.useState(false); + const [selectedItems, setSelectedItems] = React.useState([]); + const menuRef = React.useRef(null); + + const onToggleClick = () => { + setIsOpen(!isOpen); + }; + + const onSelect = (_event: React.MouseEvent | undefined, itemId: string | number | undefined) => { + // eslint-disable-next-line no-console + console.log('selected', itemId); + + if (selectedItems.includes(itemId as number)) { + setSelectedItems(selectedItems.filter(id => id !== itemId)); + } else { + setSelectedItems([...selectedItems, itemId as number]); + } + }; + + const toggle = (toggleRef: React.Ref) => ( + + Filter by status + {selectedItems.length > 0 && {selectedItems.length}} + + ); + + return ( + + ); +}; diff --git a/packages/react-core/src/next/components/Select/examples/SelectGrouped.tsx b/packages/react-core/src/next/components/Select/examples/SelectGrouped.tsx new file mode 100644 index 00000000000..852df961785 --- /dev/null +++ b/packages/react-core/src/next/components/Select/examples/SelectGrouped.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { Select, SelectOption, SelectList, SelectGroup } from '@patternfly/react-core/next'; +import { MenuToggle, MenuToggleElement } from '@patternfly/react-core'; + +export const SelectBasic: React.FunctionComponent = () => { + const [isOpen, setIsOpen] = React.useState(false); + const [selected, setSelected] = React.useState('Select a value'); + const menuRef = React.useRef(null); + + const onToggleClick = () => { + setIsOpen(!isOpen); + }; + + const onSelect = (_event: React.MouseEvent | undefined, itemId: string | number | undefined) => { + // eslint-disable-next-line no-console + console.log('selected', itemId); + + setSelected(itemId as string); + setIsOpen(false); + }; + + const toggle = (toggleRef: React.Ref) => ( + + {selected} + + ); + + return ( + + ); +}; diff --git a/packages/react-core/src/next/components/Select/examples/SelectMultiTypeahead.tsx b/packages/react-core/src/next/components/Select/examples/SelectMultiTypeahead.tsx new file mode 100644 index 00000000000..66f608b1d50 --- /dev/null +++ b/packages/react-core/src/next/components/Select/examples/SelectMultiTypeahead.tsx @@ -0,0 +1,195 @@ +import React from 'react'; +import { Select, SelectOption, SelectList, SelectOptionProps } from '@patternfly/react-core/next'; +import { + MenuToggle, + MenuToggleElement, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, + ChipGroup, + Chip, + Button +} from '@patternfly/react-core'; +import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; + +const initialSelectOptions: SelectOptionProps[] = [ + { itemId: 'Alabama', children: 'Alabama' }, + { itemId: 'Florida', children: 'Florida' }, + { itemId: 'New Jersey', children: 'New Jersey' }, + { itemId: 'New Mexico', children: 'New Mexico' }, + { itemId: 'New York', children: 'New York' }, + { itemId: 'North Carolina', children: 'North Carolina' } +]; + +export const SelectMultiTypeahead: React.FunctionComponent = () => { + const [isOpen, setIsOpen] = React.useState(false); + const [inputValue, setInputValue] = React.useState(''); + const [selected, setSelected] = React.useState([]); + const [selectOptions, setSelectOptions] = React.useState(initialSelectOptions); + const [focusedItemIndex, setFocusedItemIndex] = React.useState(null); + + const menuRef = React.useRef(null); + const textInputRef = React.useRef(); + + React.useEffect(() => { + let newSelectOptions: SelectOptionProps[] = initialSelectOptions; + + // Filter menu items based on the text input value when one exists + if (inputValue) { + newSelectOptions = initialSelectOptions.filter(menuItem => + String(menuItem.children) + .toLowerCase() + .includes(inputValue.toLowerCase()) + ); + + // When no options are found after filtering, display 'No results found' + if (!newSelectOptions.length) { + newSelectOptions = [{ isDisabled: true, children: 'No results found' }]; + } + } + + setSelectOptions(newSelectOptions); + setFocusedItemIndex(0); + }, [inputValue]); + + const handleMenuArrowKeys = (key: string) => { + let indexToFocus; + + if (isOpen) { + if (key === 'ArrowUp') { + // When no index is set or at the first index, focus to the last, otherwise decrement focus index + if (focusedItemIndex === null || focusedItemIndex === 0) { + indexToFocus = selectOptions.length - 1; + } else { + indexToFocus = focusedItemIndex - 1; + } + } + + if (key === 'ArrowDown') { + // When no index is set or at the last index, focus to the first, otherwise increment focus index + if (focusedItemIndex === null || focusedItemIndex === selectOptions.length - 1) { + indexToFocus = 0; + } else { + indexToFocus = focusedItemIndex + 1; + } + } + + setFocusedItemIndex(indexToFocus); + } + }; + + const onInputKeyDown = (event: React.KeyboardEvent) => { + const enabledMenuItems = selectOptions.filter(menuItem => !menuItem.isDisabled); + const [firstMenuItem] = enabledMenuItems; + const focusedItem = focusedItemIndex ? enabledMenuItems[focusedItemIndex] : firstMenuItem; + + switch (event.key) { + // Select the first available option + case 'Enter': + if (!isOpen) { + setIsOpen(prevIsOpen => !prevIsOpen); + } else { + onSelect(focusedItem.itemId as string); + } + break; + case 'Tab': + case 'Escape': + setIsOpen(false); + break; + case 'ArrowUp': + case 'ArrowDown': + handleMenuArrowKeys(event.key); + break; + default: + !isOpen && setIsOpen(true); + } + }; + + const onToggleClick = () => { + setIsOpen(!isOpen); + }; + + const onTextInputChange = (value: string) => { + setInputValue(value); + }; + + const onSelect = (itemId: string) => { + // eslint-disable-next-line no-console + console.log('selected', itemId); + + if (itemId) { + setSelected( + selected.includes(itemId) ? selected.filter(selection => selection !== itemId) : [...selected, itemId] + ); + } + }; + + const toggle = (toggleRef: React.Ref) => ( + + + + + {selected.map((selection, index) => ( + { + ev.stopPropagation(); + onSelect(selection); + }} + > + {selection} + + ))} + + + + {selected.length > 0 && ( + + )} + + + + ); + + return ( + + ); +}; diff --git a/packages/react-core/src/next/components/Select/examples/SelectTypeahead.tsx b/packages/react-core/src/next/components/Select/examples/SelectTypeahead.tsx new file mode 100644 index 00000000000..19f242e8ddf --- /dev/null +++ b/packages/react-core/src/next/components/Select/examples/SelectTypeahead.tsx @@ -0,0 +1,191 @@ +import React from 'react'; +import { Select, SelectOption, SelectList, SelectOptionProps } from '@patternfly/react-core/next'; +import { + MenuToggle, + MenuToggleElement, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, + Button +} from '@patternfly/react-core'; +import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; + +const initialSelectOptions: SelectOptionProps[] = [ + { itemId: 'Alabama', children: 'Alabama' }, + { itemId: 'Florida', children: 'Florida' }, + { itemId: 'New Jersey', children: 'New Jersey' }, + { itemId: 'New Mexico', children: 'New Mexico' }, + { itemId: 'New York', children: 'New York' }, + { itemId: 'North Carolina', children: 'North Carolina' } +]; + +export const SelectBasic: React.FunctionComponent = () => { + const [isOpen, setIsOpen] = React.useState(false); + const [selected, setSelected] = React.useState(''); + const [inputValue, setInputValue] = React.useState(''); + const [filterValue, setFilterValue] = React.useState(''); + const [selectOptions, setSelectOptions] = React.useState(initialSelectOptions); + const [focusedItemIndex, setFocusedItemIndex] = React.useState(null); + + const menuRef = React.useRef(null); + const textInputRef = React.useRef(); + + React.useEffect(() => { + let newSelectOptions: SelectOptionProps[] = initialSelectOptions; + + // Filter menu items based on the text input value when one exists + if (filterValue) { + newSelectOptions = initialSelectOptions.filter(menuItem => + String(menuItem.children) + .toLowerCase() + .includes(filterValue.toLowerCase()) + ); + + // When no options are found after filtering, display 'No results found' + if (!newSelectOptions.length) { + newSelectOptions = [{ isDisabled: true, children: 'No results found' }]; + } + } + + setSelectOptions(newSelectOptions); + }, [filterValue]); + + const onToggleClick = () => { + setIsOpen(!isOpen); + }; + + const onSelect = (_event: React.MouseEvent | undefined, itemId: string | number | undefined) => { + // eslint-disable-next-line no-console + console.log('selected', itemId); + + if (itemId) { + setInputValue(itemId as string); + setFilterValue(itemId as string); + setSelected(itemId as string); + } + setIsOpen(false); + setFocusedItemIndex(null); + }; + + const onTextInputChange = (value: string) => { + setInputValue(value); + setFilterValue(value); + }; + + const handleMenuArrowKeys = (key: string) => { + let indexToFocus; + + if (isOpen) { + if (key === 'ArrowUp') { + // When no index is set or at the first index, focus to the last, otherwise decrement focus index + if (focusedItemIndex === null || focusedItemIndex === 0) { + indexToFocus = selectOptions.length - 1; + } else { + indexToFocus = focusedItemIndex - 1; + } + } + + if (key === 'ArrowDown') { + // When no index is set or at the last index, focus to the first, otherwise increment focus index + if (focusedItemIndex === null || focusedItemIndex === selectOptions.length - 1) { + indexToFocus = 0; + } else { + indexToFocus = focusedItemIndex + 1; + } + } + + setFocusedItemIndex(indexToFocus); + } + }; + + const onInputKeyDown = (event: React.KeyboardEvent) => { + const enabledMenuItems = selectOptions.filter(menuItem => !menuItem.isDisabled); + const [firstMenuItem] = enabledMenuItems; + const focusedItem = focusedItemIndex ? enabledMenuItems[focusedItemIndex] : firstMenuItem; + + switch (event.key) { + // Select the first available option + case 'Enter': + if (isOpen) { + setInputValue(String(focusedItem.children)); + setSelected(String(focusedItem.children)); + } + + setIsOpen(prevIsOpen => !prevIsOpen); + setFocusedItemIndex(null); + + break; + case 'Tab': + case 'Escape': + setIsOpen(false); + break; + case 'ArrowUp': + case 'ArrowDown': + handleMenuArrowKeys(event.key); + break; + default: + !isOpen && setIsOpen(true); + } + }; + + const toggle = (toggleRef: React.Ref) => ( + + + + + + {!!inputValue && ( + + )} + + + + ); + + return ( + + ); +}; diff --git a/packages/react-core/src/next/components/Select/index.ts b/packages/react-core/src/next/components/Select/index.ts new file mode 100644 index 00000000000..5a8bca40468 --- /dev/null +++ b/packages/react-core/src/next/components/Select/index.ts @@ -0,0 +1,4 @@ +export * from './Select'; +export * from './SelectGroup'; +export * from './SelectList'; +export * from './SelectOption'; diff --git a/packages/react-core/src/next/components/index.ts b/packages/react-core/src/next/components/index.ts index 802328b87c6..144eadb6ee1 100644 --- a/packages/react-core/src/next/components/index.ts +++ b/packages/react-core/src/next/components/index.ts @@ -1,2 +1,3 @@ -export * from './Wizard'; export * from './Dropdown'; +export * from './Select'; +export * from './Wizard';