diff --git a/packages/react-core/src/components/DatePicker/__tests__/__snapshots__/DatePicker.test.tsx.snap b/packages/react-core/src/components/DatePicker/__tests__/__snapshots__/DatePicker.test.tsx.snap index b3a9a269c4b..735e0f44695 100644 --- a/packages/react-core/src/components/DatePicker/__tests__/__snapshots__/DatePicker.test.tsx.snap +++ b/packages/react-core/src/components/DatePicker/__tests__/__snapshots__/DatePicker.test.tsx.snap @@ -114,39 +114,37 @@ exports[`With popover opened 1`] = ` > Month -
- -
+ +
-`; \ No newline at end of file +`; diff --git a/packages/react-core/src/components/Dropdown/Dropdown.tsx b/packages/react-core/src/components/Dropdown/Dropdown.tsx index fa2688d35f8..f3d2b3b4d4a 100644 --- a/packages/react-core/src/components/Dropdown/Dropdown.tsx +++ b/packages/react-core/src/components/Dropdown/Dropdown.tsx @@ -19,6 +19,13 @@ export interface DropdownPopperProps { enableFlip?: boolean; } +export interface DropdownToggleProps { + /** Dropdown toggle node. */ + toggleNode: React.ReactNode; + /** Reference to the toggle. */ + toggleRef?: React.RefObject; +} + /** * See the Menu documentation for additional props that may be passed. */ @@ -27,16 +34,14 @@ export interface DropdownProps extends MenuProps, OUIAProps { children?: React.ReactNode; /** Classes applied to root element of dropdown. */ className?: string; - /** Renderer for a custom dropdown toggle. Forwards a ref to the toggle. */ - toggle: (toggleRef: React.RefObject) => React.ReactNode; + /** Dropdown toggle. The toggle should either be a renderer function which forwards the given toggle ref, or a direct ReactNode that should be passed along with the toggleRef property. */ + toggle: DropdownToggleProps | ((toggleRef: React.RefObject) => React.ReactNode); /** Flag to indicate if menu is opened.*/ isOpen?: boolean; + /** Flag indicating the toggle should be focused after a selection. If this use case is too restrictive, the optional toggleRef property with a node toggle may be used to control focus. */ + shouldFocusToggleOnSelect?: boolean; /** Function callback called when user selects item. */ - onSelect?: ( - event?: React.MouseEvent, - itemId?: string | number, - toggleRef?: React.RefObject - ) => void; + 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; @@ -62,6 +67,7 @@ const DropdownBase: React.FunctionComponent = ({ onSelect, isOpen, toggle, + shouldFocusToggleOnSelect = false, onOpenChange, isPlain, isScrollable, @@ -73,10 +79,15 @@ const DropdownBase: React.FunctionComponent = ({ ...props }: DropdownProps) => { const localMenuRef = React.useRef(); - const toggleRef = React.useRef(); + const localToggleRef = React.useRef(); const ouiaProps = useOUIAProps(Dropdown.displayName, ouiaId, ouiaSafe); const menuRef = (innerRef as React.RefObject) || localMenuRef; + const toggleRef = + typeof toggle === 'function' || (typeof toggle !== 'function' && !toggle.toggleRef) + ? localToggleRef + : (toggle?.toggleRef as React.RefObject); + React.useEffect(() => { const handleMenuKeys = (event: KeyboardEvent) => { // Close the menu on tab or escape if onOpenChange is provided @@ -119,13 +130,16 @@ const DropdownBase: React.FunctionComponent = ({ window.removeEventListener('keydown', handleMenuKeys); window.removeEventListener('click', handleClick); }; - }, [isOpen, menuRef, onOpenChange]); + }, [isOpen, menuRef, toggleRef, onOpenChange]); const menu = ( onSelect && onSelect(event, itemId, toggleRef)} + onSelect={(event, itemId) => { + onSelect && onSelect(event, itemId); + shouldFocusToggleOnSelect && toggleRef.current.focus(); + }} isPlain={isPlain} isScrollable={isScrollable} {...props} @@ -136,7 +150,7 @@ const DropdownBase: React.FunctionComponent = ({ ); return ( { setIsOpen(!isOpen); }; - const onSelect = ( - _event: React.MouseEvent | undefined, - itemId: string | number | undefined, - toggleRef: React.RefObject - ) => { + const onSelect = (_event: React.MouseEvent | undefined, itemId: string | number | undefined) => { // eslint-disable-next-line no-console console.log('selected', itemId); setIsOpen(false); - toggleRef?.current.focus(); }; return ( @@ -30,6 +25,7 @@ export const DropdownBasic: React.FunctionComponent = () => { )} ouiaId="BasicDropdown" + shouldFocusToggleOnSelect > diff --git a/packages/react-core/src/components/Dropdown/examples/DropdownWithDescriptions.tsx b/packages/react-core/src/components/Dropdown/examples/DropdownWithDescriptions.tsx index ce30700be51..2b7b4d7d6a6 100644 --- a/packages/react-core/src/components/Dropdown/examples/DropdownWithDescriptions.tsx +++ b/packages/react-core/src/components/Dropdown/examples/DropdownWithDescriptions.tsx @@ -8,15 +8,10 @@ export const DropdownWithDescriptions: React.FunctionComponent = () => { setIsOpen(!isOpen); }; - const onSelect = ( - _event: React.MouseEvent | undefined, - itemId: string | number | undefined, - toggleRef: React.RefObject - ) => { + const onSelect = (_event: React.MouseEvent | undefined, itemId: string | number | undefined) => { // eslint-disable-next-line no-console console.log('selected', itemId); setIsOpen(false); - toggleRef?.current.focus(); }; return ( @@ -29,6 +24,7 @@ export const DropdownWithDescriptions: React.FunctionComponent = () => { Dropdown )} + shouldFocusToggleOnSelect > diff --git a/packages/react-core/src/components/Dropdown/examples/DropdownWithGroups.tsx b/packages/react-core/src/components/Dropdown/examples/DropdownWithGroups.tsx index a846e06e089..619528ed40f 100644 --- a/packages/react-core/src/components/Dropdown/examples/DropdownWithGroups.tsx +++ b/packages/react-core/src/components/Dropdown/examples/DropdownWithGroups.tsx @@ -16,15 +16,10 @@ export const DropdownWithGroups: React.FunctionComponent = () => { setIsOpen(!isOpen); }; - const onSelect = ( - _event: React.MouseEvent | undefined, - itemId: string | number | undefined, - toggleRef: React.RefObject - ) => { + const onSelect = (_event: React.MouseEvent | undefined, itemId: string | number | undefined) => { // eslint-disable-next-line no-console console.log('selected', itemId); setIsOpen(false); - toggleRef?.current.focus(); }; return ( @@ -37,6 +32,7 @@ export const DropdownWithGroups: React.FunctionComponent = () => { Dropdown )} + shouldFocusToggleOnSelect > diff --git a/packages/react-core/src/components/Dropdown/examples/DropdownWithKebabToggle.tsx b/packages/react-core/src/components/Dropdown/examples/DropdownWithKebabToggle.tsx index a3fb6202e75..0b41bc43b10 100644 --- a/packages/react-core/src/components/Dropdown/examples/DropdownWithKebabToggle.tsx +++ b/packages/react-core/src/components/Dropdown/examples/DropdownWithKebabToggle.tsx @@ -9,15 +9,10 @@ export const DropdownWithKebab: React.FunctionComponent = () => { setIsOpen(!isOpen); }; - const onSelect = ( - _event: React.MouseEvent | undefined, - itemId: string | number | undefined, - toggleRef: React.RefObject - ) => { + const onSelect = (_event: React.MouseEvent | undefined, itemId: string | number | undefined) => { // eslint-disable-next-line no-console console.log('selected', itemId); setIsOpen(false); - toggleRef?.current.focus(); }; return ( @@ -36,6 +31,7 @@ export const DropdownWithKebab: React.FunctionComponent = () => { )} + shouldFocusToggleOnSelect > diff --git a/packages/react-core/src/components/Select/Select.tsx b/packages/react-core/src/components/Select/Select.tsx index 3df1f197043..4a492d812d5 100644 --- a/packages/react-core/src/components/Select/Select.tsx +++ b/packages/react-core/src/components/Select/Select.tsx @@ -1,9 +1,35 @@ import React from 'react'; import { css } from '@patternfly/react-styles'; import { Menu, MenuContent, MenuProps } from '../Menu'; -import { Popper, PopperProps } from '../../helpers/Popper/Popper'; +import { Popper } from '../../helpers/Popper/Popper'; import { getOUIAProps, OUIAProps, getDefaultOUIAId } from '../../helpers'; +export interface SelectPopperProps { + /** Vertical direction of the popper. If enableFlip is set to true, this will set the initial direction before the popper flips. */ + direction?: 'up' | 'down'; + /** Horizontal position of the popper */ + position?: 'right' | 'left' | 'center'; + /** Custom width of the popper. If the value is "trigger", it will set the width to the select toggle's width */ + width?: string | 'trigger'; + /** Minimum width of the popper. If the value is "trigger", it will set the min width to the select toggle's width */ + minWidth?: string | 'trigger'; + /** Maximum width of the popper. If the value is "trigger", it will set the max width to the select toggle's width */ + maxWidth?: string | 'trigger'; + /** Enable to flip the popper when it reaches the boundary */ + enableFlip?: boolean; +} + +export interface SelectToggleProps { + /** Select toggle node. */ + toggleNode: React.ReactNode; + /** Reference to the toggle. */ + toggleRef?: React.RefObject; +} + +/** + * See the Menu documentation for additional props that may be passed. + */ + export interface SelectProps extends MenuProps, OUIAProps { /** Anything which can be rendered in a select */ children?: React.ReactNode; @@ -13,13 +39,17 @@ export interface SelectProps extends MenuProps, OUIAProps { 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; + /** Select toggle. The toggle should either be a renderer function which forwards the given toggle ref, or a direct ReactNode that should be passed along with the toggleRef property. */ + toggle: SelectToggleProps | ((toggleRef: React.RefObject) => React.ReactNode); + /** Flag indicating the toggle should be focused after a selection. If this use case is too restrictive, the optional toggleRef property with a node toggle may be used to control focus. */ + shouldFocusToggleOnSelect?: boolean; /** 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. */ + * Triggered by clicking outside of the menu, or by pressing any keys specificed in onOpenChangeKeys. */ onOpenChange?: (isOpen: boolean) => void; + /** @beta Keys that trigger onOpenChange, defaults to tab and escape. It is highly recommended to include Escape in the array, while Tab may be omitted if the menu contains non-menu items that are focusable. */ + onOpenChangeKeys?: string[]; /** Indicates if the select should be without the outer box-shadow */ isPlain?: boolean; /** @hide Forwarded ref */ @@ -29,7 +59,7 @@ export interface SelectProps extends MenuProps, OUIAProps { /** @beta Determines the accessible role of the select. For a checkbox select pass in "menu". */ role?: string; /** Additional properties to pass to the popper */ - popperProps?: Partial; + popperProps?: SelectPopperProps; } const SelectBase: React.FunctionComponent = ({ @@ -39,7 +69,9 @@ const SelectBase: React.FunctionComponent = ({ isOpen, selected, toggle, + shouldFocusToggleOnSelect = false, onOpenChange, + onOpenChangeKeys = ['Escape', 'Tab'], isPlain, innerRef, zIndex = 9999, @@ -48,18 +80,24 @@ const SelectBase: React.FunctionComponent = ({ ...props }: SelectProps & OUIAProps) => { const localMenuRef = React.useRef(); - const toggleRef = React.useRef(); - const containerRef = React.useRef(); + const localToggleRef = React.useRef(); const menuRef = (innerRef as React.RefObject) || localMenuRef; + const toggleRef = + typeof toggle === 'function' || (typeof toggle !== 'function' && !toggle.toggleRef) + ? localToggleRef + : (toggle?.toggleRef as React.RefObject); + React.useEffect(() => { const handleMenuKeys = (event: KeyboardEvent) => { // 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) + isOpen && + onOpenChange && + (menuRef.current?.contains(event.target as Node) || toggleRef.current?.contains(event.target as Node)) ) { - if (event.key === 'Escape' || event.key === 'Tab') { + if (onOpenChangeKeys.includes(event.key)) { + event.preventDefault(); onOpenChange(false); toggleRef.current?.focus(); } @@ -90,14 +128,17 @@ const SelectBase: React.FunctionComponent = ({ window.removeEventListener('keydown', handleMenuKeys); window.removeEventListener('click', handleClick); }; - }, [isOpen, menuRef, onOpenChange]); + }, [isOpen, menuRef, toggleRef, onOpenChange, onOpenChangeKeys]); const menu = ( onSelect && onSelect(event, itemId)} + onSelect={(event, itemId) => { + onSelect && onSelect(event, itemId); + shouldFocusToggleOnSelect && toggleRef.current.focus(); + }} isPlain={isPlain} selected={selected} {...getOUIAProps( @@ -111,18 +152,15 @@ const SelectBase: React.FunctionComponent = ({ ); return ( -
- -
+ ); }; diff --git a/packages/react-core/src/components/Select/SelectGroup.tsx b/packages/react-core/src/components/Select/SelectGroup.tsx index 645f5b2aa00..744de685390 100644 --- a/packages/react-core/src/components/Select/SelectGroup.tsx +++ b/packages/react-core/src/components/Select/SelectGroup.tsx @@ -2,6 +2,9 @@ import React from 'react'; import { css } from '@patternfly/react-styles'; import { MenuGroupProps, MenuGroup } from '../Menu'; +/** + * See the MenuGroup section of the Menu documentation for additional props that may be passed. + */ export interface SelectGroupProps extends Omit { /** Anything which can be rendered in a select group */ children: React.ReactNode; diff --git a/packages/react-core/src/components/Select/SelectOption.tsx b/packages/react-core/src/components/Select/SelectOption.tsx index 72f4fd2e0ae..5c941f39e7e 100644 --- a/packages/react-core/src/components/Select/SelectOption.tsx +++ b/packages/react-core/src/components/Select/SelectOption.tsx @@ -2,11 +2,17 @@ import React from 'react'; import { css } from '@patternfly/react-styles'; import { MenuItemProps, MenuItem } from '../Menu'; +/** + * See the MenuItem section of the Menu documentation for additional props that may be passed. + */ + 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; + /** @hide Forwarded ref */ + innerRef?: React.Ref; /** Identifies the component in the Select onSelect callback */ itemId?: any; /** Indicates the option has a checkbox */ @@ -17,15 +23,29 @@ export interface SelectOptionProps extends Omit { isSelected?: boolean; /** Indicates the option is focused */ isFocused?: boolean; + /** Render an external link icon on focus or hover, and set the link's + * "target" attribute to a value of "_blank". + */ + isExternalLink?: boolean; + /** Render option with icon */ + icon?: React.ReactNode; + /** Description of the option */ + description?: React.ReactNode; } -export const SelectOption: React.FunctionComponent = ({ +const SelectOptionBase: React.FunctionComponent = ({ children, className, + innerRef, ...props }: SelectOptionProps) => ( - + {children} ); + +export const SelectOption = React.forwardRef((props: SelectOptionProps, ref: React.Ref) => ( + +)); + SelectOption.displayName = 'SelectOption'; diff --git a/packages/react-core/src/components/Select/examples/Select.md b/packages/react-core/src/components/Select/examples/Select.md index 782430b7170..3ebabaca5ba 100644 --- a/packages/react-core/src/components/Select/examples/Select.md +++ b/packages/react-core/src/components/Select/examples/Select.md @@ -3,35 +3,82 @@ id: Select section: components subsection: menus cssPrefix: pf-c-select -propComponents: ['Select', 'SelectOption', 'SelectGroup', 'SelectList', 'MenuToggle'] +propComponents: + ['Select', 'SelectOption', 'SelectGroup', 'SelectList', 'MenuToggle', 'SelectToggleProps', 'SelectPopperProps'] ouia: true --- import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; +import BellIcon from '@patternfly/react-icons/dist/esm/icons/bell-icon'; ## Examples +`Select` builds off of the Menu component suite to wrap commonly used properties and functions for a select menu. See the [Menu documentation](/components/menus/menu) for a full list of properties that may be passed through `Select` to further customize the select menu, or the [custom menu examples](/components/menus/custom-menus) for additional examples of fully functional menus. + ### Single ```ts file="./SelectBasic.tsx" + +``` + +### Option variations + +Showcases different option variants and customizations that are commonly used in a select menu. For a more complete list, see the [Menu documentation](/components/menus/menu). + +```ts file="./SelectOptionVariations.tsx" + ``` ### Grouped single ```ts file="./SelectGrouped.tsx" + ``` ### Checkbox ```ts file="./SelectCheckbox.tsx" + ``` ### Typeahead ```ts file="./SelectTypeahead.tsx" + +``` + +### Typeahead with create option + +```ts file="./SelectTypeaheadCreatable.tsx" + ``` -### Multiple Typeahead +### Multiple typeahead with chips ```ts file="./SelectMultiTypeahead.tsx" + +``` + +### Multiple typeahead with create option + +```ts file="./SelectMultiTypeaheadCreatable.tsx" + +``` + +### Multiple typeahead with checkboxes + +```ts file="./SelectMultiTypeaheadCheckbox.tsx" + +``` + +### View more + +```ts file="./SelectViewMore.tsx" + +``` + +### Footer + +```ts file="./SelectFooter.tsx" + ``` diff --git a/packages/react-core/src/components/Select/examples/SelectBasic.tsx b/packages/react-core/src/components/Select/examples/SelectBasic.tsx index f4084a53049..9a7f56af78c 100644 --- a/packages/react-core/src/components/Select/examples/SelectBasic.tsx +++ b/packages/react-core/src/components/Select/examples/SelectBasic.tsx @@ -1,10 +1,10 @@ import React from 'react'; -import { Select, SelectOption, SelectList, MenuToggle, MenuToggleElement } from '@patternfly/react-core'; +import { Select, SelectOption, SelectList, MenuToggle, MenuToggleElement, Checkbox } 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 [isDisabled, setIsDisabled] = React.useState(false); const onToggleClick = () => { setIsOpen(!isOpen); @@ -23,6 +23,7 @@ export const SelectBasic: React.FunctionComponent = () => { ref={toggleRef} onClick={onToggleClick} isExpanded={isOpen} + isDisabled={isDisabled} style={ { width: '200px' @@ -34,20 +35,29 @@ export const SelectBasic: React.FunctionComponent = () => { ); return ( - + + setIsDisabled(checked)} + style={{ marginBottom: 20 }} + /> + + ); }; diff --git a/packages/react-core/src/components/Select/examples/SelectCheckbox.tsx b/packages/react-core/src/components/Select/examples/SelectCheckbox.tsx index 70b6a1b9d3b..c6c2fbe0e85 100644 --- a/packages/react-core/src/components/Select/examples/SelectCheckbox.tsx +++ b/packages/react-core/src/components/Select/examples/SelectCheckbox.tsx @@ -4,7 +4,6 @@ import { Select, SelectOption, SelectList, MenuToggle, MenuToggleElement, Badge 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); @@ -15,7 +14,7 @@ export const SelectCheckbox: React.FunctionComponent = () => { console.log('selected', itemId); if (selectedItems.includes(itemId as number)) { - setSelectedItems(selectedItems.filter(id => id !== itemId)); + setSelectedItems(selectedItems.filter((id) => id !== itemId)); } else { setSelectedItems([...selectedItems, itemId as number]); } @@ -41,7 +40,6 @@ export const SelectCheckbox: React.FunctionComponent = () => { setIsOpen(isOpen)} + onOpenChangeKeys={['Escape']} + toggle={toggle} + id="menu-with-footer" + onSelect={onSelect} + selected={selected} + > + + Option 1 + Option 2 + Option 3 + + + + + + ); +}; diff --git a/packages/react-core/src/components/Select/examples/SelectGrouped.tsx b/packages/react-core/src/components/Select/examples/SelectGrouped.tsx index 184188499ca..03621ee9ffb 100644 --- a/packages/react-core/src/components/Select/examples/SelectGrouped.tsx +++ b/packages/react-core/src/components/Select/examples/SelectGrouped.tsx @@ -1,10 +1,17 @@ import React from 'react'; -import { Select, SelectOption, SelectList, SelectGroup, MenuToggle, MenuToggleElement } from '@patternfly/react-core'; +import { + Select, + SelectOption, + SelectList, + SelectGroup, + MenuToggle, + MenuToggleElement, + Divider +} from '@patternfly/react-core'; -export const SelectBasic: React.FunctionComponent = () => { +export const SelectGrouped: 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); @@ -35,13 +42,13 @@ export const SelectBasic: React.FunctionComponent = () => { return ( onSelect(selection as string)} diff --git a/packages/react-core/src/components/Select/examples/SelectMultiTypeaheadCheckbox.tsx b/packages/react-core/src/components/Select/examples/SelectMultiTypeaheadCheckbox.tsx new file mode 100644 index 00000000000..b57aa0e8193 --- /dev/null +++ b/packages/react-core/src/components/Select/examples/SelectMultiTypeaheadCheckbox.tsx @@ -0,0 +1,204 @@ +import React from 'react'; +import { + Select, + SelectOption, + SelectList, + SelectOptionProps, + 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 SelectMultiTypeaheadCheckbox: 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 [activeItem, setActiveItem] = React.useState(null); + const [placeholder, setPlaceholder] = React.useState('0 items selected'); + 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: false, children: `No results found for "${inputValue}"`, itemId: 'no results' } + ]; + } + + // Open the menu when the input value changes and the new value is not empty + if (!isOpen) { + setIsOpen(true); + } + } + + setSelectOptions(newSelectOptions); + setFocusedItemIndex(null); + setActiveItem(null); + }, [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 focusedItem = selectOptions.filter((option) => !option.isDisabled)[indexToFocus]; + setActiveItem(`select-multi-typeahead-checkbox-${focusedItem.itemId.replace(' ', '-')}`); + } + }; + + 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 if (isOpen && focusedItem.itemId !== 'no results') { + onSelect(focusedItem.itemId as string); + } + break; + case 'Tab': + case 'Escape': + setIsOpen(false); + setActiveItem(null); + break; + case 'ArrowUp': + case 'ArrowDown': + event.preventDefault(); + handleMenuArrowKeys(event.key); + break; + } + }; + + const onToggleClick = () => { + setIsOpen(!isOpen); + }; + + const onTextInputChange = (_event: React.FormEvent, value: string) => { + setInputValue(value); + }; + + const onSelect = (itemId: string) => { + // eslint-disable-next-line no-console + console.log('selected', itemId); + + if (itemId && itemId !== 'no results') { + setSelected( + selected.includes(itemId) ? selected.filter((selection) => selection !== itemId) : [...selected, itemId] + ); + } + + textInputRef.current?.focus(); + }; + + React.useEffect(() => { + setPlaceholder(`${selected.length} items selected`); + }, [selected]); + + const toggle = (toggleRef: React.Ref) => ( + + + + + {selected.length > 0 && ( + + )} + + + + ); + + return ( + + ); +}; diff --git a/packages/react-core/src/components/Select/examples/SelectMultiTypeaheadCreatable.tsx b/packages/react-core/src/components/Select/examples/SelectMultiTypeaheadCreatable.tsx new file mode 100644 index 00000000000..089e456ac31 --- /dev/null +++ b/packages/react-core/src/components/Select/examples/SelectMultiTypeaheadCreatable.tsx @@ -0,0 +1,222 @@ +import React from 'react'; +import { + Select, + SelectOption, + SelectList, + SelectOptionProps, + MenuToggle, + MenuToggleElement, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, + ChipGroup, + Chip, + Button +} from '@patternfly/react-core'; +import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; + +let 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 SelectMultiTypeaheadCreatable: 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 [activeItem, setActiveItem] = React.useState(null); + const [onCreation, setOnCreation] = React.useState(false); // Boolean to refresh filter state after new option is created + 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 creation option + if (!newSelectOptions.length) { + newSelectOptions = [{ isDisabled: false, children: `Create new option "${inputValue}"`, itemId: 'create' }]; + } + + // Open the menu when the input value changes and the new value is not empty + if (!isOpen) { + setIsOpen(true); + } + } + + setSelectOptions(newSelectOptions); + setFocusedItemIndex(null); + setActiveItem(null); + }, [inputValue, onCreation]); + + 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 focusedItem = selectOptions.filter((option) => !option.isDisabled)[indexToFocus]; + setActiveItem(`select-multi-create-typeahead-${focusedItem.itemId.replace(' ', '-')}`); + } + }; + + 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 if (isOpen && focusedItem.itemId !== 'no results') { + onSelect(focusedItem.itemId as string); + } + break; + case 'Tab': + case 'Escape': + setIsOpen(false); + setActiveItem(null); + break; + case 'ArrowUp': + case 'ArrowDown': + event.preventDefault(); + handleMenuArrowKeys(event.key); + break; + } + }; + + const onToggleClick = () => { + setIsOpen(!isOpen); + }; + + const onTextInputChange = (_event: React.FormEvent, value: string) => { + setInputValue(value); + }; + + const onSelect = (itemId: string) => { + if (itemId) { + if (itemId === 'create') { + if (!initialSelectOptions.some((item) => item.itemId === inputValue)) { + initialSelectOptions = [...initialSelectOptions, { itemId: inputValue, children: inputValue }]; + } + setSelected( + selected.includes(inputValue) + ? selected.filter((selection) => selection !== inputValue) + : [...selected, inputValue] + ); + setOnCreation(!onCreation); + } else { + // eslint-disable-next-line no-console + console.log('selected', itemId); + setSelected( + selected.includes(itemId) ? selected.filter((selection) => selection !== itemId) : [...selected, itemId] + ); + } + } + + textInputRef.current?.focus(); + }; + + 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/components/Select/examples/SelectOptionVariations.tsx b/packages/react-core/src/components/Select/examples/SelectOptionVariations.tsx new file mode 100644 index 00000000000..8bc3d1d35de --- /dev/null +++ b/packages/react-core/src/components/Select/examples/SelectOptionVariations.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { Select, SelectOption, SelectList, MenuToggle, MenuToggleElement } from '@patternfly/react-core'; +import BellIcon from '@patternfly/react-icons/dist/esm/icons/bell-icon'; + +export const SelectOptionVariations: React.FunctionComponent = () => { + const [isOpen, setIsOpen] = React.useState(false); + const [selected, setSelected] = React.useState('Select a value'); + + 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/components/Select/examples/SelectTypeahead.tsx b/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx index b360398d10c..71446f45ddc 100644 --- a/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx +++ b/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx @@ -26,28 +26,25 @@ 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 [activeItem, setActiveItem] = 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()) + 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: false, children: `No results found for "${inputValue}"`, itemId: 'no results' } + { isDisabled: false, children: `No results found for "${filterValue}"`, itemId: 'no results' } ]; } @@ -60,7 +57,7 @@ export const SelectBasic: React.FunctionComponent = () => { setSelectOptions(newSelectOptions); setActiveItem(null); setFocusedItemIndex(null); - }, [inputValue]); + }, [filterValue]); const onToggleClick = () => { setIsOpen(!isOpen); @@ -72,6 +69,7 @@ export const SelectBasic: React.FunctionComponent = () => { if (itemId && itemId !== 'no results') { setInputValue(itemId as string); + setFilterValue(''); setSelected(itemId as string); } setIsOpen(false); @@ -81,6 +79,7 @@ export const SelectBasic: React.FunctionComponent = () => { const onTextInputChange = (_event: React.FormEvent, value: string) => { setInputValue(value); + setFilterValue(value); }; const handleMenuArrowKeys = (key: string) => { @@ -106,13 +105,13 @@ export const SelectBasic: React.FunctionComponent = () => { } setFocusedItemIndex(indexToFocus); - const focusedItem = selectOptions.filter(option => !option.isDisabled)[indexToFocus]; + const focusedItem = selectOptions.filter((option) => !option.isDisabled)[indexToFocus]; setActiveItem(`select-typeahead-${focusedItem.itemId.replace(' ', '-')}`); } }; const onInputKeyDown = (event: React.KeyboardEvent) => { - const enabledMenuItems = selectOptions.filter(option => !option.isDisabled); + const enabledMenuItems = selectOptions.filter((option) => !option.isDisabled); const [firstMenuItem] = enabledMenuItems; const focusedItem = focusedItemIndex ? enabledMenuItems[focusedItemIndex] : firstMenuItem; @@ -121,10 +120,11 @@ export const SelectBasic: React.FunctionComponent = () => { case 'Enter': if (isOpen && focusedItem.itemId !== 'no results') { setInputValue(String(focusedItem.children)); + setFilterValue(''); setSelected(String(focusedItem.children)); } - setIsOpen(prevIsOpen => !prevIsOpen); + setIsOpen((prevIsOpen) => !prevIsOpen); setFocusedItemIndex(null); setActiveItem(null); @@ -167,6 +167,7 @@ export const SelectBasic: React.FunctionComponent = () => { onClick={() => { setSelected(''); setInputValue(''); + setFilterValue(''); textInputRef?.current?.focus(); }} aria-label="Clear input value" @@ -182,7 +183,6 @@ export const SelectBasic: React.FunctionComponent = () => { return ( { + setIsOpen(false); + }} + toggle={toggle} + > + + {selectOptions.map((option, index) => ( + setSelected(option.itemId)} + id={`select-typeahead-${option.itemId.replace(' ', '-')}`} + {...option} + ref={null} + /> + ))} + + + ); +}; diff --git a/packages/react-core/src/components/Select/examples/SelectViewMore.tsx b/packages/react-core/src/components/Select/examples/SelectViewMore.tsx new file mode 100644 index 00000000000..740a64b1c45 --- /dev/null +++ b/packages/react-core/src/components/Select/examples/SelectViewMore.tsx @@ -0,0 +1,146 @@ +import React from 'react'; +import { Select, SelectOption, SelectList, MenuToggle, Spinner } from '@patternfly/react-core'; + +export const SelectViewMore: React.FunctionComponent = () => { + const [isOpen, setIsOpen] = React.useState(false); + const [selected, setSelected] = React.useState('Select a value'); + const [activeItem, setActiveItem] = React.useState(0); + const [isLoading, setIsLoading] = React.useState(false); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [selectOptions, setSelectOptions] = React.useState([ + + Option 1 + , + + Option 2 + , + + Option 3 + , + + Option 4 + , + + Option 5 + , + + Option 6 + , + + Option 7 + , + + Option 8 + , + + Option 9 + , + + Final Option 10 + + ]); + const [numOptions, setNumOptions] = React.useState(3); + const [visibleOptions, setVisibleOptions] = React.useState(selectOptions.slice(0, numOptions)); + const activeItemRef = React.useRef(null); + const viewMoreRef = React.useRef(null); + const toggleRef = React.useRef(null); + + React.useEffect(() => { + activeItemRef.current?.focus(); + }, [visibleOptions]); + + const simulateNetworkCall = (networkCallback: () => void) => { + setTimeout(networkCallback, 2000); + }; + + const getNextValidItem = (startingIndex: number, maxLength: number) => { + let validItem; + for (let i = startingIndex; i < maxLength; i++) { + if (selectOptions[i].props.isDisabled) { + continue; + } else { + validItem = selectOptions[i]; + break; + } + } + return validItem; + }; + + const loadMoreOptions = () => { + const newLength = numOptions + 3 <= selectOptions.length ? numOptions + 3 : selectOptions.length; + const prevPosition = numOptions; + const nextValidItem = getNextValidItem(prevPosition, newLength); + + setNumOptions(newLength); + setIsLoading(false); + setActiveItem(nextValidItem.props.itemId); + setVisibleOptions(selectOptions.slice(0, newLength)); + }; + + const onViewMoreClick = () => { + setIsLoading(true); + simulateNetworkCall(() => { + loadMoreOptions(); + }); + }; + + 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 !== 'loader') { + setSelected(itemId as string); + setIsOpen(false); + toggleRef?.current?.focus(); // Only focus the toggle when a non-loader option is selected + } + }; + + const toggle = ( + + {selected} + + ); + + return ( + + ); +}; diff --git a/packages/react-core/src/demos/ComposableMenu/ComposableMenu.md b/packages/react-core/src/demos/ComposableMenu/ComposableMenu.md index c06bda7770b..b221de57461 100644 --- a/packages/react-core/src/demos/ComposableMenu/ComposableMenu.md +++ b/packages/react-core/src/demos/ComposableMenu/ComposableMenu.md @@ -32,36 +32,43 @@ Custom menus can be constructed using a composable approach by combining the [Me ### Composable simple dropdown ```ts file="./examples/ComposableSimpleDropdown.tsx" + ``` ### Composable actions menu ```ts file="./examples/ComposableActionsMenu.tsx" + ``` ### Composable simple select ```ts file="./examples/ComposableSimpleSelect.tsx" + ``` ### Composable simple checkbox select ```ts file="./examples/ComposableSimpleCheckboxSelect.tsx" + ``` ### Composable typeahead select ```ts file="./examples/ComposableTypeaheadSelect.tsx" + ``` ### Composable multiple typeahead select ```ts file="./examples/ComposableMultipleTypeaheadSelect.tsx" + ``` ### Composable drilldown menu ```ts isBeta file="./examples/ComposableDrilldownMenu.tsx" + ``` ### Composable tree view menu @@ -69,6 +76,7 @@ Custom menus can be constructed using a composable approach by combining the [Me When rendering a menu-like element that does not contain MenuItem components, [Panel](/components/panel) allows more flexible control and customization. ```ts file="./examples/ComposableTreeViewMenu.tsx" + ``` ### Composable flyout @@ -76,29 +84,35 @@ When rendering a menu-like element that does not contain MenuItem components, [P The flyout will automatically position to the left or top if it would otherwise go outside the window. The menu must be placed in a container outside the main content like Popper, [Popover](/components/popover) or [Tooltip](/components/tooltip) since it may go over the side nav. ```ts isBeta file="./examples/ComposableFlyout.tsx" + ``` ### Composable application launcher ```ts file="./examples/ComposableApplicationLauncher.tsx" + ``` ### Composable context selector ```ts file="./examples/ComposableContextSelector.tsx" + ``` ### Composable options menu variants ```ts file="./examples/ComposableOptionsMenuVariants.tsx" + ``` ### Composable dropdown variants ```ts file="./examples/ComposableDropdwnVariants.tsx" + ``` ### Composable date select ```ts file="./examples/ComposableDateSelect.tsx" + ``` diff --git a/packages/react-core/src/helpers/Popper/Popper.tsx b/packages/react-core/src/helpers/Popper/Popper.tsx index b6ad58ada3e..75cfcec7687 100644 --- a/packages/react-core/src/helpers/Popper/Popper.tsx +++ b/packages/react-core/src/helpers/Popper/Popper.tsx @@ -68,7 +68,7 @@ export interface PopperProps { position?: 'right' | 'left' | 'center'; /** Instead of direction and position can set the placement of the popper */ placement?: Placement; - /** Custsom width of the popper. If the value is "trigger", it will set the width to the trigger element's width */ + /** Custom width of the popper. If the value is "trigger", it will set the width to the trigger element's width */ width?: string | 'trigger'; /** Minimum width of the popper. If the value is "trigger", it will set the min width to the trigger element's width */ minWidth?: string | 'trigger';