From 6f7e2f285cd0b9ee015cc4baf7a1ac73e5663f62 Mon Sep 17 00:00:00 2001 From: Katie McFaul Date: Wed, 28 Sep 2022 11:56:21 -0400 Subject: [PATCH 1/7] next(Select): add select using menu components --- .../src/next/components/Select/Select.tsx | 123 +++++++++++ .../next/components/Select/SelectGroup.tsx | 24 +++ .../src/next/components/Select/SelectList.tsx | 21 ++ .../next/components/Select/SelectOption.tsx | 31 +++ .../next/components/Select/examples/Select.md | 31 +++ .../Select/examples/SelectBasic.tsx | 60 ++++++ .../Select/examples/SelectCheckbox.tsx | 72 +++++++ .../Select/examples/SelectMultiTypeahead.tsx | 191 ++++++++++++++++++ .../Select/examples/SelectTypeahead.tsx | 173 ++++++++++++++++ .../src/next/components/Select/index.ts | 4 + .../react-core/src/next/components/index.ts | 3 +- 11 files changed, 732 insertions(+), 1 deletion(-) create mode 100644 packages/react-core/src/next/components/Select/Select.tsx create mode 100644 packages/react-core/src/next/components/Select/SelectGroup.tsx create mode 100644 packages/react-core/src/next/components/Select/SelectList.tsx create mode 100644 packages/react-core/src/next/components/Select/SelectOption.tsx create mode 100644 packages/react-core/src/next/components/Select/examples/Select.md create mode 100644 packages/react-core/src/next/components/Select/examples/SelectBasic.tsx create mode 100644 packages/react-core/src/next/components/Select/examples/SelectCheckbox.tsx create mode 100644 packages/react-core/src/next/components/Select/examples/SelectMultiTypeahead.tsx create mode 100644 packages/react-core/src/next/components/Select/examples/SelectTypeahead.tsx create mode 100644 packages/react-core/src/next/components/Select/index.ts 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..1890e6bd6d3 --- /dev/null +++ b/packages/react-core/src/next/components/Select/Select.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import { css } from '@patternfly/react-styles'; +import { Menu, MenuContent, MenuProps } from '../../../components/Menu'; +import { Popper } from '../../../helpers/Popper/Popper'; + +export interface SelectProps extends MenuProps { + /** 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 MenuItem. */ + selected?: any | any[]; + /** Renderer for a custom select toggle. Forwards a ref to the toggle. */ + toggle: (toggleRef: React.RefObject) => React.ReactNode; + /** Function callback called when user selects item. */ + onSelect?: (event?: React.MouseEvent, itemId?: string | number) => void; + /** Callback to allow the dropdown 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; + /** Min 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) => { + 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') { + 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 + })} + {...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..195f35ec8bd --- /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 Menu onSelect or onActionClick callback */ + itemId?: any; + /** Flag indicating the option has a checkbox */ + hasCheck?: boolean; + /** Render option as disabled */ + isDisabled?: boolean; + /** Flag indicating if the option is selected */ + isSelected?: boolean; + /** Flag indicating 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..47c6830e891 --- /dev/null +++ b/packages/react-core/src/next/components/Select/examples/Select.md @@ -0,0 +1,31 @@ +--- +id: Select +section: components +cssPrefix: pf-c-menu +propComponents: ['Select', SelectGroup, 'SelectOption', 'SelectList'] +beta: true +--- + +import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; + +## Examples + +### Single + +```ts file="./SelectBasic.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..965b6278977 --- /dev/null +++ b/packages/react-core/src/next/components/Select/examples/SelectBasic.tsx @@ -0,0 +1,60 @@ +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 = (ev: React.MouseEvent) => { + ev.stopPropagation(); // Stop handleClickOutside from handling + setTimeout(() => { + if (menuRef.current) { + const firstElement = menuRef.current.querySelector('li > button:not(:disabled)'); + firstElement && (firstElement as HTMLElement).focus(); + } + }, 0); + 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..c89e5e01377 --- /dev/null +++ b/packages/react-core/src/next/components/Select/examples/SelectCheckbox.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { Select, SelectOption, SelectList } from '@patternfly/react-core/next'; +import { MenuToggle, MenuToggleElement } 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 = (ev: React.MouseEvent) => { + ev.stopPropagation(); // Stop handleClickOutside from handling + setTimeout(() => { + if (menuRef.current) { + const firstElement = menuRef.current.querySelector('li > button:not(:disabled)'); + firstElement && (firstElement as HTMLElement).focus(); + } + }, 0); + 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 + + ); + + 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..4cc9755cb0f --- /dev/null +++ b/packages/react-core/src/next/components/Select/examples/SelectMultiTypeahead.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, + ChipGroup, + Chip, + Button +} from '@patternfly/react-core'; +import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; + +const initialSelectOptions: SelectOptionProps[] = [ + { itemId: 'Option 1', children: 'Option 1' }, + { itemId: 'Option 2', children: 'Option 2' }, + { itemId: 'Option 3', children: 'Option 3' } +]; + +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 = (ev: React.MouseEvent) => { + ev.stopPropagation(); + 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..79e7d42a682 --- /dev/null +++ b/packages/react-core/src/next/components/Select/examples/SelectTypeahead.tsx @@ -0,0 +1,173 @@ +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: 'Option 1', children: 'Option 1' }, + { itemId: 'Option 2', children: 'Option 2' }, + { itemId: 'Option 3', children: 'Option 3' } +]; + +export const SelectBasic: React.FunctionComponent = () => { + const [isOpen, setIsOpen] = React.useState(false); + const [selected, setSelected] = React.useState('Select a value'); + const [inputValue, setInputValue] = 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); + }, [inputValue]); + + const onToggleClick = (ev: React.MouseEvent) => { + ev.stopPropagation(); + 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); + setSelected(itemId as string); + } + setIsOpen(false); + setFocusedItemIndex(null); + }; + + const onTextInputChange = (value: string) => { + setInputValue(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'; From 1be2c11b081d456a120e56f91ea400d85bf0f451 Mon Sep 17 00:00:00 2001 From: Katie McFaul Date: Thu, 29 Sep 2022 10:58:34 -0400 Subject: [PATCH 2/7] add id --- .../src/next/components/Select/examples/SelectBasic.tsx | 1 + .../src/next/components/Select/examples/SelectCheckbox.tsx | 1 + .../src/next/components/Select/examples/SelectMultiTypeahead.tsx | 1 + .../src/next/components/Select/examples/SelectTypeahead.tsx | 1 + 4 files changed, 4 insertions(+) diff --git a/packages/react-core/src/next/components/Select/examples/SelectBasic.tsx b/packages/react-core/src/next/components/Select/examples/SelectBasic.tsx index 965b6278977..32d27b60947 100644 --- a/packages/react-core/src/next/components/Select/examples/SelectBasic.tsx +++ b/packages/react-core/src/next/components/Select/examples/SelectBasic.tsx @@ -43,6 +43,7 @@ export const SelectBasic: React.FunctionComponent = () => { return ( { return ( Date: Thu, 29 Sep 2022 12:09:28 -0400 Subject: [PATCH 3/7] fix duplicate id --- .../next/components/Select/examples/SelectMultiTypeahead.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-core/src/next/components/Select/examples/SelectMultiTypeahead.tsx b/packages/react-core/src/next/components/Select/examples/SelectMultiTypeahead.tsx index 744db0eec2f..c70d3ace941 100644 --- a/packages/react-core/src/next/components/Select/examples/SelectMultiTypeahead.tsx +++ b/packages/react-core/src/next/components/Select/examples/SelectMultiTypeahead.tsx @@ -130,7 +130,7 @@ export const SelectMultiTypeahead: React.FunctionComponent = () => { onClick={onToggleClick} onChange={onTextInputChange} onKeyDown={onInputKeyDown} - id="typeahead-select-input" + id="multi-typeahead-select-input" autoComplete="off" innerRef={textInputRef} > From 1fb42aeabec148ead0aa1967a5ca2824296287f5 Mon Sep 17 00:00:00 2001 From: Katie McFaul Date: Fri, 30 Sep 2022 11:07:08 -0400 Subject: [PATCH 4/7] pr feedback --- .../src/next/components/Select/Select.tsx | 24 +++++--- .../next/components/Select/SelectOption.tsx | 10 +-- .../next/components/Select/examples/Select.md | 10 +++ .../Select/examples/SelectBasic.tsx | 9 +-- .../Select/examples/SelectCheckbox.tsx | 9 +-- .../Select/examples/SelectGrouped.tsx | 61 +++++++++++++++++++ .../Select/examples/SelectMultiSingle.tsx | 57 +++++++++++++++++ .../Select/examples/SelectMultiTypeahead.tsx | 3 +- .../Select/examples/SelectTypeahead.tsx | 3 +- 9 files changed, 152 insertions(+), 34 deletions(-) create mode 100644 packages/react-core/src/next/components/Select/examples/SelectGrouped.tsx create mode 100644 packages/react-core/src/next/components/Select/examples/SelectMultiSingle.tsx diff --git a/packages/react-core/src/next/components/Select/Select.tsx b/packages/react-core/src/next/components/Select/Select.tsx index 1890e6bd6d3..b2eed72f714 100644 --- a/packages/react-core/src/next/components/Select/Select.tsx +++ b/packages/react-core/src/next/components/Select/Select.tsx @@ -2,32 +2,33 @@ 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 { +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 MenuItem. */ + /** 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 called when user selects item. */ + /** Function callback when user selects an option. */ onSelect?: (event?: React.MouseEvent, itemId?: string | number) => void; - /** Callback to allow the dropdown component to change the open state of the menu. + /** 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; - /** Min width of the select menu */ + /** Minimum width of the select menu */ minWidth?: string; /** @hide Forwarded ref */ innerRef?: React.Ref; } -const SelectBase: React.FunctionComponent = ({ +const SelectBase: React.FunctionComponent = ({ children, className, onSelect, @@ -39,7 +40,7 @@ const SelectBase: React.FunctionComponent = ({ minWidth, innerRef, ...props -}: SelectProps) => { +}: SelectProps & OUIAProps) => { const localMenuRef = React.useRef(); const toggleRef = React.useRef(); const containerRef = React.useRef(); @@ -49,9 +50,9 @@ const SelectBase: React.FunctionComponent = ({ 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') { + if (event.key === 'Enter' || event.key === 'Space') { setTimeout(() => { - const firstElement = menuRef?.current?.querySelector('li > button:not(:disabled),li input:not(:disabled)'); + const firstElement = menuRef?.current?.querySelector('li button:not(:disabled),li input:not(:disabled)'); firstElement && (firstElement as HTMLElement).focus(); }, 0); } @@ -98,6 +99,11 @@ const SelectBase: React.FunctionComponent = ({ '--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} diff --git a/packages/react-core/src/next/components/Select/SelectOption.tsx b/packages/react-core/src/next/components/Select/SelectOption.tsx index 195f35ec8bd..d731bc59ed4 100644 --- a/packages/react-core/src/next/components/Select/SelectOption.tsx +++ b/packages/react-core/src/next/components/Select/SelectOption.tsx @@ -7,15 +7,15 @@ export interface SelectOptionProps extends Omit { children?: React.ReactNode; /** Classes applied to root element of select option */ className?: string; - /** Identifies the component in the Menu onSelect or onActionClick callback */ + /** Identifies the component in the Select onSelect callback */ itemId?: any; - /** Flag indicating the option has a checkbox */ + /** Indicates the option has a checkbox */ hasCheck?: boolean; - /** Render option as disabled */ + /** Indicates the option is disabled */ isDisabled?: boolean; - /** Flag indicating if the option is selected */ + /** Indicates the option is selected */ isSelected?: boolean; - /** Flag indicating the option is focused */ + /** Indicates the option is focused */ isFocused?: boolean; } diff --git a/packages/react-core/src/next/components/Select/examples/Select.md b/packages/react-core/src/next/components/Select/examples/Select.md index 47c6830e891..aa82af9e858 100644 --- a/packages/react-core/src/next/components/Select/examples/Select.md +++ b/packages/react-core/src/next/components/Select/examples/Select.md @@ -15,6 +15,16 @@ import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; ```ts file="./SelectBasic.tsx" ``` +### Grouped single + +```ts file="./SelectGrouped.tsx" +``` + +### Multiple single + +```ts file="./SelectMultiSingle.tsx" +``` + ### Checkbox ```ts file="./SelectCheckbox.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 index 32d27b60947..c1519ea0de6 100644 --- a/packages/react-core/src/next/components/Select/examples/SelectBasic.tsx +++ b/packages/react-core/src/next/components/Select/examples/SelectBasic.tsx @@ -7,14 +7,7 @@ export const SelectBasic: React.FunctionComponent = () => { const [selected, setSelected] = React.useState('Select a value'); const menuRef = React.useRef(null); - const onToggleClick = (ev: React.MouseEvent) => { - ev.stopPropagation(); // Stop handleClickOutside from handling - setTimeout(() => { - if (menuRef.current) { - const firstElement = menuRef.current.querySelector('li > button:not(:disabled)'); - firstElement && (firstElement as HTMLElement).focus(); - } - }, 0); + const onToggleClick = () => { setIsOpen(!isOpen); }; diff --git a/packages/react-core/src/next/components/Select/examples/SelectCheckbox.tsx b/packages/react-core/src/next/components/Select/examples/SelectCheckbox.tsx index 3a1b24b6f2f..4e14f60fb84 100644 --- a/packages/react-core/src/next/components/Select/examples/SelectCheckbox.tsx +++ b/packages/react-core/src/next/components/Select/examples/SelectCheckbox.tsx @@ -7,14 +7,7 @@ export const SelectCheckbox: React.FunctionComponent = () => { const [selectedItems, setSelectedItems] = React.useState([]); const menuRef = React.useRef(null); - const onToggleClick = (ev: React.MouseEvent) => { - ev.stopPropagation(); // Stop handleClickOutside from handling - setTimeout(() => { - if (menuRef.current) { - const firstElement = menuRef.current.querySelector('li > button:not(:disabled)'); - firstElement && (firstElement as HTMLElement).focus(); - } - }, 0); + const onToggleClick = () => { setIsOpen(!isOpen); }; 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..5fed49c44af --- /dev/null +++ b/packages/react-core/src/next/components/Select/examples/SelectGrouped.tsx @@ -0,0 +1,61 @@ +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/SelectMultiSingle.tsx b/packages/react-core/src/next/components/Select/examples/SelectMultiSingle.tsx new file mode 100644 index 00000000000..6e466840f73 --- /dev/null +++ b/packages/react-core/src/next/components/Select/examples/SelectMultiSingle.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { Select, SelectOption, SelectList } from '@patternfly/react-core/next'; +import { MenuToggle, MenuToggleElement } from '@patternfly/react-core'; + +export const SelectMultiSingle: 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 string)) { + setSelectedItems(selectedItems.filter(id => id !== itemId)); + } else { + setSelectedItems([...selectedItems, itemId as string]); + } + }; + + const toggle = (toggleRef: React.Ref) => ( + + Select a value + + ); + + 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 index c70d3ace941..be9dab0b069 100644 --- a/packages/react-core/src/next/components/Select/examples/SelectMultiTypeahead.tsx +++ b/packages/react-core/src/next/components/Select/examples/SelectMultiTypeahead.tsx @@ -102,8 +102,7 @@ export const SelectMultiTypeahead: React.FunctionComponent = () => { } }; - const onToggleClick = (ev: React.MouseEvent) => { - ev.stopPropagation(); + const onToggleClick = () => { setIsOpen(!isOpen); }; diff --git a/packages/react-core/src/next/components/Select/examples/SelectTypeahead.tsx b/packages/react-core/src/next/components/Select/examples/SelectTypeahead.tsx index 9434d273187..b9c2d03285f 100644 --- a/packages/react-core/src/next/components/Select/examples/SelectTypeahead.tsx +++ b/packages/react-core/src/next/components/Select/examples/SelectTypeahead.tsx @@ -46,8 +46,7 @@ export const SelectBasic: React.FunctionComponent = () => { setSelectOptions(newSelectOptions); }, [inputValue]); - const onToggleClick = (ev: React.MouseEvent) => { - ev.stopPropagation(); + const onToggleClick = () => { setIsOpen(!isOpen); }; From 1157e6d2bbe774eaea8ff492948b06082d5ad556 Mon Sep 17 00:00:00 2001 From: Katie McFaul Date: Fri, 30 Sep 2022 15:31:55 -0400 Subject: [PATCH 5/7] add ouia props --- .../react-core/src/next/components/Select/examples/Select.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-core/src/next/components/Select/examples/Select.md b/packages/react-core/src/next/components/Select/examples/Select.md index aa82af9e858..5e14e28d286 100644 --- a/packages/react-core/src/next/components/Select/examples/Select.md +++ b/packages/react-core/src/next/components/Select/examples/Select.md @@ -4,6 +4,7 @@ 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'; From d357fce00122df36599013b6e985ee7ee3dfcccc Mon Sep 17 00:00:00 2001 From: Katie McFaul Date: Mon, 3 Oct 2022 11:01:26 -0400 Subject: [PATCH 6/7] update ta data, update ta behavior to better match select --- .../Select/examples/SelectCheckbox.tsx | 3 +- .../Select/examples/SelectMultiSingle.tsx | 3 +- .../Select/examples/SelectMultiTypeahead.tsx | 10 ++++-- .../Select/examples/SelectTypeahead.tsx | 36 ++++++++++++++----- 4 files changed, 38 insertions(+), 14 deletions(-) diff --git a/packages/react-core/src/next/components/Select/examples/SelectCheckbox.tsx b/packages/react-core/src/next/components/Select/examples/SelectCheckbox.tsx index 4e14f60fb84..5ddc5e8dc2f 100644 --- a/packages/react-core/src/next/components/Select/examples/SelectCheckbox.tsx +++ b/packages/react-core/src/next/components/Select/examples/SelectCheckbox.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Select, SelectOption, SelectList } from '@patternfly/react-core/next'; -import { MenuToggle, MenuToggleElement } from '@patternfly/react-core'; +import { MenuToggle, MenuToggleElement, Badge } from '@patternfly/react-core'; export const SelectCheckbox: React.FunctionComponent = () => { const [isOpen, setIsOpen] = React.useState(false); @@ -34,6 +34,7 @@ export const SelectCheckbox: React.FunctionComponent = () => { } > Filter by status + {selectedItems.length > 0 && {selectedItems.length}} ); diff --git a/packages/react-core/src/next/components/Select/examples/SelectMultiSingle.tsx b/packages/react-core/src/next/components/Select/examples/SelectMultiSingle.tsx index 6e466840f73..c3a17d9b52b 100644 --- a/packages/react-core/src/next/components/Select/examples/SelectMultiSingle.tsx +++ b/packages/react-core/src/next/components/Select/examples/SelectMultiSingle.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Select, SelectOption, SelectList } from '@patternfly/react-core/next'; -import { MenuToggle, MenuToggleElement } from '@patternfly/react-core'; +import { MenuToggle, MenuToggleElement, Badge } from '@patternfly/react-core'; export const SelectMultiSingle: React.FunctionComponent = () => { const [isOpen, setIsOpen] = React.useState(false); @@ -34,6 +34,7 @@ export const SelectMultiSingle: React.FunctionComponent = () => { } > Select a value + {selectedItems.length > 0 && {selectedItems.length}} ); diff --git a/packages/react-core/src/next/components/Select/examples/SelectMultiTypeahead.tsx b/packages/react-core/src/next/components/Select/examples/SelectMultiTypeahead.tsx index be9dab0b069..66f608b1d50 100644 --- a/packages/react-core/src/next/components/Select/examples/SelectMultiTypeahead.tsx +++ b/packages/react-core/src/next/components/Select/examples/SelectMultiTypeahead.tsx @@ -13,9 +13,12 @@ import { import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; const initialSelectOptions: SelectOptionProps[] = [ - { itemId: 'Option 1', children: 'Option 1' }, - { itemId: 'Option 2', children: 'Option 2' }, - { itemId: 'Option 3', children: 'Option 3' } + { 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 = () => { @@ -132,6 +135,7 @@ export const SelectMultiTypeahead: React.FunctionComponent = () => { id="multi-typeahead-select-input" autoComplete="off" innerRef={textInputRef} + placeholder="Select a state" > {selected.map((selection, index) => ( diff --git a/packages/react-core/src/next/components/Select/examples/SelectTypeahead.tsx b/packages/react-core/src/next/components/Select/examples/SelectTypeahead.tsx index b9c2d03285f..19f242e8ddf 100644 --- a/packages/react-core/src/next/components/Select/examples/SelectTypeahead.tsx +++ b/packages/react-core/src/next/components/Select/examples/SelectTypeahead.tsx @@ -11,15 +11,19 @@ import { import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; const initialSelectOptions: SelectOptionProps[] = [ - { itemId: 'Option 1', children: 'Option 1' }, - { itemId: 'Option 2', children: 'Option 2' }, - { itemId: 'Option 3', children: 'Option 3' } + { 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('Select a value'); + 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); @@ -30,11 +34,11 @@ export const SelectBasic: React.FunctionComponent = () => { let newSelectOptions: SelectOptionProps[] = initialSelectOptions; // Filter menu items based on the text input value when one exists - if (inputValue) { + if (filterValue) { newSelectOptions = initialSelectOptions.filter(menuItem => String(menuItem.children) .toLowerCase() - .includes(inputValue.toLowerCase()) + .includes(filterValue.toLowerCase()) ); // When no options are found after filtering, display 'No results found' @@ -44,7 +48,7 @@ export const SelectBasic: React.FunctionComponent = () => { } setSelectOptions(newSelectOptions); - }, [inputValue]); + }, [filterValue]); const onToggleClick = () => { setIsOpen(!isOpen); @@ -56,6 +60,7 @@ export const SelectBasic: React.FunctionComponent = () => { if (itemId) { setInputValue(itemId as string); + setFilterValue(itemId as string); setSelected(itemId as string); } setIsOpen(false); @@ -64,6 +69,7 @@ export const SelectBasic: React.FunctionComponent = () => { const onTextInputChange = (value: string) => { setInputValue(value); + setFilterValue(value); }; const handleMenuArrowKeys = (key: string) => { @@ -133,11 +139,20 @@ export const SelectBasic: React.FunctionComponent = () => { id="typeahead-select-input" autoComplete="off" innerRef={textInputRef} + placeholder="Select a state" /> {!!inputValue && ( - )} @@ -153,7 +168,10 @@ export const SelectBasic: React.FunctionComponent = () => { isOpen={isOpen} selected={selected} onSelect={onSelect} - onOpenChange={() => setIsOpen(false)} + onOpenChange={() => { + setIsOpen(false); + setFilterValue(''); + }} toggle={toggle} > From faaa32f499fbef4ba1ce4abce9c3d9c415be8a07 Mon Sep 17 00:00:00 2001 From: Katie McFaul Date: Mon, 3 Oct 2022 17:40:46 -0400 Subject: [PATCH 7/7] remove example, update structure on grouped --- .../next/components/Select/examples/Select.md | 5 -- .../Select/examples/SelectGrouped.tsx | 14 +++-- .../Select/examples/SelectMultiSingle.tsx | 58 ------------------- 3 files changed, 8 insertions(+), 69 deletions(-) delete mode 100644 packages/react-core/src/next/components/Select/examples/SelectMultiSingle.tsx diff --git a/packages/react-core/src/next/components/Select/examples/Select.md b/packages/react-core/src/next/components/Select/examples/Select.md index 5e14e28d286..ae17b80c6d4 100644 --- a/packages/react-core/src/next/components/Select/examples/Select.md +++ b/packages/react-core/src/next/components/Select/examples/Select.md @@ -21,11 +21,6 @@ import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; ```ts file="./SelectGrouped.tsx" ``` -### Multiple single - -```ts file="./SelectMultiSingle.tsx" -``` - ### Checkbox ```ts file="./SelectCheckbox.tsx" diff --git a/packages/react-core/src/next/components/Select/examples/SelectGrouped.tsx b/packages/react-core/src/next/components/Select/examples/SelectGrouped.tsx index 5fed49c44af..852df961785 100644 --- a/packages/react-core/src/next/components/Select/examples/SelectGrouped.tsx +++ b/packages/react-core/src/next/components/Select/examples/SelectGrouped.tsx @@ -44,18 +44,20 @@ export const SelectBasic: React.FunctionComponent = () => { onOpenChange={isOpen => setIsOpen(isOpen)} toggle={toggle} > - - + + Option 1 Option 2 Option 3 - - + + + + Option 4 Option 5 Option 6 - - + + ); }; diff --git a/packages/react-core/src/next/components/Select/examples/SelectMultiSingle.tsx b/packages/react-core/src/next/components/Select/examples/SelectMultiSingle.tsx deleted file mode 100644 index c3a17d9b52b..00000000000 --- a/packages/react-core/src/next/components/Select/examples/SelectMultiSingle.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React from 'react'; -import { Select, SelectOption, SelectList } from '@patternfly/react-core/next'; -import { MenuToggle, MenuToggleElement, Badge } from '@patternfly/react-core'; - -export const SelectMultiSingle: 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 string)) { - setSelectedItems(selectedItems.filter(id => id !== itemId)); - } else { - setSelectedItems([...selectedItems, itemId as string]); - } - }; - - const toggle = (toggleRef: React.Ref) => ( - - Select a value - {selectedItems.length > 0 && {selectedItems.length}} - - ); - - return ( - - ); -};