diff --git a/package.json b/package.json index 8e0d30c17d8..caf7b40e41e 100644 --- a/package.json +++ b/package.json @@ -111,5 +111,6 @@ "packages/**" ] }, - "dependencies": {} + "dependencies": {}, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/packages/react-core/src/components/MenuToggle/MenuToggle.tsx b/packages/react-core/src/components/MenuToggle/MenuToggle.tsx index 9e8fef7a78f..05c7b4a1b92 100644 --- a/packages/react-core/src/components/MenuToggle/MenuToggle.tsx +++ b/packages/react-core/src/components/MenuToggle/MenuToggle.tsx @@ -129,6 +129,7 @@ class MenuToggleBase extends React.Component { aria-expanded={isExpanded} onClick={onClick} aria-label={ariaLabel || 'Menu toggle'} + tabIndex={-1} > {toggleControls} diff --git a/packages/react-core/src/components/Select/Select.tsx b/packages/react-core/src/components/Select/Select.tsx index 4a46ab93b12..82bcda7bf99 100644 --- a/packages/react-core/src/components/Select/Select.tsx +++ b/packages/react-core/src/components/Select/Select.tsx @@ -53,6 +53,8 @@ export interface SelectProps extends MenuProps, OUIAProps { 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; + /** @beta Flag indicating the first menu item should be focused after opening the menu. */ + shouldFocusFirstItemOnOpen?: boolean; /** Function callback when user selects an option. */ onSelect?: (event?: React.MouseEvent, value?: string | number) => void; /** Callback to allow the select component to change the open state of the menu. @@ -86,6 +88,7 @@ const SelectBase: React.FunctionComponent = ({ selected, toggle, shouldFocusToggleOnSelect = false, + shouldFocusFirstItemOnOpen = true, onOpenChange, onOpenChangeKeys = ['Escape', 'Tab'], isPlain, @@ -125,7 +128,7 @@ const SelectBase: React.FunctionComponent = ({ const handleClick = (event: MouseEvent) => { // toggle was opened, focus on first menu item - if (isOpen && toggleRef.current?.contains(event.target as Node)) { + if (isOpen && shouldFocusFirstItemOnOpen && toggleRef.current?.contains(event.target as Node)) { setTimeout(() => { const firstElement = menuRef?.current?.querySelector('li button:not(:disabled),li input:not(:disabled)'); firstElement && (firstElement as HTMLElement).focus(); diff --git a/packages/react-core/src/components/Select/examples/SelectMultiTypeahead.tsx b/packages/react-core/src/components/Select/examples/SelectMultiTypeahead.tsx index 9df0c8978e7..57f2b4d27f6 100644 --- a/packages/react-core/src/components/Select/examples/SelectMultiTypeahead.tsx +++ b/packages/react-core/src/components/Select/examples/SelectMultiTypeahead.tsx @@ -30,9 +30,11 @@ export const SelectMultiTypeahead: React.FunctionComponent = () => { const [selected, setSelected] = React.useState([]); const [selectOptions, setSelectOptions] = React.useState(initialSelectOptions); const [focusedItemIndex, setFocusedItemIndex] = React.useState(null); - const [activeItem, setActiveItem] = React.useState(null); + const [activeItemId, setActiveItemId] = React.useState(null); const textInputRef = React.useRef(); + const NO_RESULTS = 'no results'; + React.useEffect(() => { let newSelectOptions: SelectOptionProps[] = initialSelectOptions; @@ -45,7 +47,7 @@ export const SelectMultiTypeahead: React.FunctionComponent = () => { // When no options are found after filtering, display 'No results found' if (!newSelectOptions.length) { newSelectOptions = [ - { isDisabled: false, children: `No results found for "${inputValue}"`, value: 'no results' } + { isAriaDisabled: true, children: `No results found for "${inputValue}"`, value: NO_RESULTS } ]; } @@ -56,56 +58,113 @@ export const SelectMultiTypeahead: React.FunctionComponent = () => { } setSelectOptions(newSelectOptions); - setFocusedItemIndex(null); - setActiveItem(null); }, [inputValue]); + const createItemId = (value: any) => `select-multi-typeahead-${value.replace(' ', '-')}`; + + const setActiveAndFocusedItem = (itemIndex: number) => { + setFocusedItemIndex(itemIndex); + const focusedItem = selectOptions[itemIndex]; + setActiveItemId(createItemId(focusedItem.value)); + }; + + const resetActiveAndFocusedItem = () => { + setFocusedItemIndex(null); + setActiveItemId(null); + }; + + const closeMenu = () => { + setIsOpen(false); + resetActiveAndFocusedItem(); + }; + + const onInputClick = () => { + if (!isOpen) { + setIsOpen(true); + } else if (!inputValue) { + closeMenu(); + } + }; + + const onSelect = (value: string) => { + if (value && value !== NO_RESULTS) { + // eslint-disable-next-line no-console + console.log('selected', value); + + setSelected( + selected.includes(value) ? selected.filter((selection) => selection !== value) : [...selected, value] + ); + } + + textInputRef.current?.focus(); + }; + + const onTextInputChange = (_event: React.FormEvent, value: string) => { + setInputValue(value); + resetActiveAndFocusedItem(); + }; + const handleMenuArrowKeys = (key: string) => { - let indexToFocus; + let indexToFocus = 0; - 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) { + if (!isOpen) { + setIsOpen(true); + } + + if (selectOptions.every((option) => option.isDisabled)) { + return; + } + + 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; + } + + // Skip disabled options + while (selectOptions[indexToFocus].isDisabled) { + indexToFocus--; + if (indexToFocus === -1) { 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) { + 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; + } + + // Skip disabled options + while (selectOptions[indexToFocus].isDisabled) { + indexToFocus++; + if (indexToFocus === selectOptions.length) { indexToFocus = 0; - } else { - indexToFocus = focusedItemIndex + 1; } } - - setFocusedItemIndex(indexToFocus); - const focusedItem = selectOptions.filter((option) => !option.isDisabled)[indexToFocus]; - setActiveItem(`select-multi-typeahead-${focusedItem.value.replace(' ', '-')}`); } + + setActiveAndFocusedItem(indexToFocus); }; const onInputKeyDown = (event: React.KeyboardEvent) => { - const enabledMenuItems = selectOptions.filter((menuItem) => !menuItem.isDisabled); - const [firstMenuItem] = enabledMenuItems; - const focusedItem = focusedItemIndex ? enabledMenuItems[focusedItemIndex] : firstMenuItem; + const focusedItem = focusedItemIndex !== null ? selectOptions[focusedItemIndex] : null; switch (event.key) { - // Select the first available option case 'Enter': + if (isOpen && focusedItem && focusedItem.value !== NO_RESULTS && !focusedItem.isAriaDisabled) { + onSelect(focusedItem.value); + } + if (!isOpen) { - setIsOpen((prevIsOpen) => !prevIsOpen); - } else if (isOpen && focusedItem.value !== 'no results') { - onSelect(focusedItem.value as string); + setIsOpen(true); } - break; - case 'Tab': - case 'Escape': - setIsOpen(false); - setActiveItem(null); + break; case 'ArrowUp': case 'ArrowDown': @@ -117,24 +176,17 @@ export const SelectMultiTypeahead: React.FunctionComponent = () => { const onToggleClick = () => { setIsOpen(!isOpen); + textInputRef?.current?.focus(); }; - const onTextInputChange = (_event: React.FormEvent, value: string) => { - setInputValue(value); + const onClearButtonClick = () => { + setSelected([]); + setInputValue(''); + resetActiveAndFocusedItem(); + textInputRef?.current?.focus(); }; - const onSelect = (value: string) => { - // eslint-disable-next-line no-console - console.log('selected', value); - - if (value && value !== 'no results') { - setSelected( - selected.includes(value) ? selected.filter((selection) => selection !== value) : [...selected, value] - ); - } - - textInputRef.current?.focus(); - }; + const getChildren = (value: string) => initialSelectOptions.find((option) => option.value === value)?.children; const toggle = (toggleRef: React.Ref) => ( { { onSelect(selection); }} > - {selection} + {getChildren(selection)} ))} - - {selected.length > 0 && ( - - )} + + @@ -198,9 +240,12 @@ export const SelectMultiTypeahead: React.FunctionComponent = () => { id="multi-typeahead-select" isOpen={isOpen} selected={selected} - onSelect={(ev, selection) => onSelect(selection as string)} - onOpenChange={() => setIsOpen(false)} + onSelect={(_event, selection) => onSelect(selection as string)} + onOpenChange={(isOpen) => { + !isOpen && closeMenu(); + }} toggle={toggle} + shouldFocusFirstItemOnOpen={false} > {selectOptions.map((option, index) => ( @@ -208,7 +253,7 @@ export const SelectMultiTypeahead: React.FunctionComponent = () => { key={option.value || option.children} isFocused={focusedItemIndex === index} className={option.className} - id={`select-multi-typeahead-${option.value.replace(' ', '-')}`} + id={createItemId(option.value)} {...option} ref={null} /> diff --git a/packages/react-core/src/components/Select/examples/SelectMultiTypeaheadCheckbox.tsx b/packages/react-core/src/components/Select/examples/SelectMultiTypeaheadCheckbox.tsx index 7a88b6f0a1a..da38e0930e1 100644 --- a/packages/react-core/src/components/Select/examples/SelectMultiTypeaheadCheckbox.tsx +++ b/packages/react-core/src/components/Select/examples/SelectMultiTypeaheadCheckbox.tsx @@ -28,10 +28,12 @@ export const SelectMultiTypeaheadCheckbox: React.FunctionComponent = () => { const [selected, setSelected] = React.useState([]); const [selectOptions, setSelectOptions] = React.useState(initialSelectOptions); const [focusedItemIndex, setFocusedItemIndex] = React.useState(null); - const [activeItem, setActiveItem] = React.useState(null); + const [activeItemId, setActiveItemId] = React.useState(null); const [placeholder, setPlaceholder] = React.useState('0 items selected'); const textInputRef = React.useRef(); + const NO_RESULTS = 'no results'; + React.useEffect(() => { let newSelectOptions: SelectOptionProps[] = initialSelectOptions; @@ -45,9 +47,9 @@ export const SelectMultiTypeaheadCheckbox: React.FunctionComponent = () => { if (!newSelectOptions.length) { newSelectOptions = [ { - isDisabled: false, + isAriaDisabled: true, children: `No results found for "${inputValue}"`, - value: 'no results', + value: NO_RESULTS, hasCheckbox: false } ]; @@ -60,56 +62,99 @@ export const SelectMultiTypeaheadCheckbox: React.FunctionComponent = () => { } setSelectOptions(newSelectOptions); - setFocusedItemIndex(null); - setActiveItem(null); }, [inputValue]); + React.useEffect(() => { + setPlaceholder(`${selected.length} item${selected.length !== 1 ? 's' : ''} selected`); + }, [selected]); + + const createItemId = (value: any) => `select-multi-typeahead-${value.replace(' ', '-')}`; + + const setActiveAndFocusedItem = (itemIndex: number) => { + setFocusedItemIndex(itemIndex); + const focusedItem = selectOptions[itemIndex]; + setActiveItemId(createItemId(focusedItem.value)); + }; + + const resetActiveAndFocusedItem = () => { + setFocusedItemIndex(null); + setActiveItemId(null); + }; + + const closeMenu = () => { + setIsOpen(false); + resetActiveAndFocusedItem(); + }; + + const onInputClick = () => { + if (!isOpen) { + setIsOpen(true); + } else if (!inputValue) { + closeMenu(); + } + }; + const handleMenuArrowKeys = (key: string) => { - let indexToFocus; + let indexToFocus = 0; + + if (!isOpen) { + setIsOpen(true); + } + + if (selectOptions.every((option) => option.isDisabled)) { + return; + } + + 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 (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) { + // Skip disabled options + while (selectOptions[indexToFocus].isDisabled) { + indexToFocus--; + if (indexToFocus === -1) { 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; + } - 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) { + // Skip disabled options + while (selectOptions[indexToFocus].isDisabled) { + indexToFocus++; + if (indexToFocus === selectOptions.length) { indexToFocus = 0; - } else { - indexToFocus = focusedItemIndex + 1; } } - - setFocusedItemIndex(indexToFocus); - const focusedItem = selectOptions.filter((option) => !option.isDisabled)[indexToFocus]; - setActiveItem(`select-multi-typeahead-checkbox-${focusedItem.value.replace(' ', '-')}`); } + + setActiveAndFocusedItem(indexToFocus); }; const onInputKeyDown = (event: React.KeyboardEvent) => { - const enabledMenuItems = selectOptions.filter((menuItem) => !menuItem.isDisabled); - const [firstMenuItem] = enabledMenuItems; - const focusedItem = focusedItemIndex ? enabledMenuItems[focusedItemIndex] : firstMenuItem; + const focusedItem = focusedItemIndex !== null ? selectOptions[focusedItemIndex] : null; switch (event.key) { - // Select the first available option case 'Enter': + if (isOpen && focusedItem && focusedItem.value !== NO_RESULTS && !focusedItem.isAriaDisabled) { + onSelect(focusedItem.value); + } + if (!isOpen) { - setIsOpen((prevIsOpen) => !prevIsOpen); - } else if (isOpen && focusedItem.value !== 'no results') { - onSelect(focusedItem.value as string); + setIsOpen(true); } - break; - case 'Tab': - case 'Escape': - setIsOpen(false); - setActiveItem(null); + break; case 'ArrowUp': case 'ArrowDown': @@ -121,17 +166,19 @@ export const SelectMultiTypeaheadCheckbox: React.FunctionComponent = () => { const onToggleClick = () => { setIsOpen(!isOpen); + textInputRef?.current?.focus(); }; const onTextInputChange = (_event: React.FormEvent, value: string) => { setInputValue(value); + resetActiveAndFocusedItem(); }; const onSelect = (value: string) => { - // eslint-disable-next-line no-console - console.log('selected', value); + if (value && value !== NO_RESULTS) { + // eslint-disable-next-line no-console + console.log('selected', value); - if (value && value !== 'no results') { setSelected( selected.includes(value) ? selected.filter((selection) => selection !== value) : [...selected, value] ); @@ -140,9 +187,12 @@ export const SelectMultiTypeaheadCheckbox: React.FunctionComponent = () => { textInputRef.current?.focus(); }; - React.useEffect(() => { - setPlaceholder(`${selected.length} items selected`); - }, [selected]); + const onClearButtonClick = () => { + setSelected([]); + setInputValue(''); + resetActiveAndFocusedItem(); + textInputRef?.current?.focus(); + }; const toggle = (toggleRef: React.Ref) => ( { - - {selected.length > 0 && ( - - )} + + @@ -193,19 +233,22 @@ export const SelectMultiTypeaheadCheckbox: React.FunctionComponent = () => { id="multi-typeahead-checkbox-select" isOpen={isOpen} selected={selected} - onSelect={(ev, selection) => onSelect(selection as string)} - onOpenChange={() => setIsOpen(false)} + onSelect={(_event, selection) => onSelect(selection as string)} + onOpenChange={(isOpen) => { + !isOpen && closeMenu(); + }} toggle={toggle} + shouldFocusFirstItemOnOpen={false} > - + {selectOptions.map((option, index) => ( diff --git a/packages/react-core/src/components/Select/examples/SelectMultiTypeaheadCreatable.tsx b/packages/react-core/src/components/Select/examples/SelectMultiTypeaheadCreatable.tsx index 9be7a6d6152..16f3afb2c63 100644 --- a/packages/react-core/src/components/Select/examples/SelectMultiTypeaheadCreatable.tsx +++ b/packages/react-core/src/components/Select/examples/SelectMultiTypeaheadCreatable.tsx @@ -30,10 +30,12 @@ export const SelectMultiTypeaheadCreatable: React.FunctionComponent = () => { const [selected, setSelected] = React.useState([]); const [selectOptions, setSelectOptions] = React.useState(initialSelectOptions); const [focusedItemIndex, setFocusedItemIndex] = React.useState(null); - const [activeItem, setActiveItem] = React.useState(null); + const [activeItemId, setActiveItemId] = React.useState(null); const [onCreation, setOnCreation] = React.useState(false); // Boolean to refresh filter state after new option is created const textInputRef = React.useRef(); + const CREATE_NEW = 'create'; + React.useEffect(() => { let newSelectOptions: SelectOptionProps[] = initialSelectOptions; @@ -43,9 +45,9 @@ export const SelectMultiTypeaheadCreatable: React.FunctionComponent = () => { 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}"`, value: 'create' }]; + // If no option matches the filter exactly, display creation option + if (!initialSelectOptions.some((option) => option.value === inputValue)) { + newSelectOptions = [...newSelectOptions, { children: `Create new option "${inputValue}"`, value: CREATE_NEW }]; } // Open the menu when the input value changes and the new value is not empty @@ -55,56 +57,125 @@ export const SelectMultiTypeaheadCreatable: React.FunctionComponent = () => { } setSelectOptions(newSelectOptions); - setFocusedItemIndex(null); - setActiveItem(null); }, [inputValue, onCreation]); + const createItemId = (value: any) => `select-multi-create-typeahead-${value.replace(' ', '-')}`; + + const setActiveAndFocusedItem = (itemIndex: number) => { + setFocusedItemIndex(itemIndex); + const focusedItem = selectOptions[itemIndex]; + setActiveItemId(createItemId(focusedItem.value)); + }; + + const resetActiveAndFocusedItem = () => { + setFocusedItemIndex(null); + setActiveItemId(null); + }; + + const closeMenu = () => { + setIsOpen(false); + resetActiveAndFocusedItem(); + }; + + const onInputClick = () => { + if (!isOpen) { + setIsOpen(true); + } else if (!inputValue) { + closeMenu(); + } + }; + + const onSelect = (value: string) => { + if (value) { + if (value === CREATE_NEW) { + if (!initialSelectOptions.some((item) => item.value === inputValue)) { + initialSelectOptions = [...initialSelectOptions, { value: inputValue, children: inputValue }]; + } + setSelected( + selected.includes(inputValue) + ? selected.filter((selection) => selection !== inputValue) + : [...selected, inputValue] + ); + setOnCreation(!onCreation); + resetActiveAndFocusedItem(); + } else { + // eslint-disable-next-line no-console + console.log('selected', value); + setSelected( + selected.includes(value) ? selected.filter((selection) => selection !== value) : [...selected, value] + ); + } + } + + textInputRef.current?.focus(); + }; + + const onTextInputChange = (_event: React.FormEvent, value: string) => { + setInputValue(value); + resetActiveAndFocusedItem(); + }; + const handleMenuArrowKeys = (key: string) => { - let indexToFocus; + let indexToFocus = 0; + + if (!isOpen) { + setIsOpen(true); + } - 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) { + if (selectOptions.every((option) => option.isDisabled)) { + return; + } + + 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; + } + + // Skip disabled options + while (selectOptions[indexToFocus].isDisabled) { + indexToFocus--; + if (indexToFocus === -1) { 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) { + 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; + } + + // Skip disabled options + while (selectOptions[indexToFocus].isDisabled) { + indexToFocus++; + if (indexToFocus === selectOptions.length) { indexToFocus = 0; - } else { - indexToFocus = focusedItemIndex + 1; } } - - setFocusedItemIndex(indexToFocus); - const focusedItem = selectOptions.filter((option) => !option.isDisabled)[indexToFocus]; - setActiveItem(`select-multi-create-typeahead-${focusedItem.value.replace(' ', '-')}`); } + + setActiveAndFocusedItem(indexToFocus); }; const onInputKeyDown = (event: React.KeyboardEvent) => { - const enabledMenuItems = selectOptions.filter((menuItem) => !menuItem.isDisabled); - const [firstMenuItem] = enabledMenuItems; - const focusedItem = focusedItemIndex ? enabledMenuItems[focusedItemIndex] : firstMenuItem; + const focusedItem = focusedItemIndex !== null ? selectOptions[focusedItemIndex] : null; switch (event.key) { - // Select the first available option case 'Enter': - if (!isOpen) { - setIsOpen((prevIsOpen) => !prevIsOpen); - } else if (isOpen && focusedItem.value !== 'no results') { + if (isOpen && focusedItem && !focusedItem.isAriaDisabled) { onSelect(focusedItem.value as string); } - break; - case 'Tab': - case 'Escape': - setIsOpen(false); - setActiveItem(null); + + if (!isOpen) { + setIsOpen(true); + } + break; case 'ArrowUp': case 'ArrowDown': @@ -116,35 +187,17 @@ export const SelectMultiTypeaheadCreatable: React.FunctionComponent = () => { const onToggleClick = () => { setIsOpen(!isOpen); + textInputRef?.current?.focus(); }; - const onTextInputChange = (_event: React.FormEvent, value: string) => { - setInputValue(value); + const onClearButtonClick = () => { + setSelected([]); + setInputValue(''); + resetActiveAndFocusedItem(); + textInputRef?.current?.focus(); }; - const onSelect = (value: string) => { - if (value) { - if (value === 'create') { - if (!initialSelectOptions.some((item) => item.value === inputValue)) { - initialSelectOptions = [...initialSelectOptions, { value: 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', value); - setSelected( - selected.includes(value) ? selected.filter((selection) => selection !== value) : [...selected, value] - ); - } - } - - textInputRef.current?.focus(); - }; + const getChildren = (value: string) => initialSelectOptions.find((option) => option.value === value)?.children; const toggle = (toggleRef: React.Ref) => ( { { onSelect(selection); }} > - {selection} + {getChildren(selection)} ))} - - {selected.length > 0 && ( - - )} + + @@ -208,9 +251,12 @@ export const SelectMultiTypeaheadCreatable: React.FunctionComponent = () => { id="multi-create-typeahead-select" isOpen={isOpen} selected={selected} - onSelect={(ev, selection) => onSelect(selection as string)} - onOpenChange={() => setIsOpen(false)} + onSelect={(_event, selection) => onSelect(selection as string)} + onOpenChange={(isOpen) => { + !isOpen && closeMenu(); + }} toggle={toggle} + shouldFocusFirstItemOnOpen={false} > {selectOptions.map((option, index) => ( @@ -218,7 +264,7 @@ export const SelectMultiTypeaheadCreatable: React.FunctionComponent = () => { key={option.value || option.children} isFocused={focusedItemIndex === index} className={option.className} - id={`select-multi-create-typeahead-${option.value.replace(' ', '-')}`} + id={createItemId(option.value)} {...option} ref={null} /> diff --git a/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx b/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx index 8d21541211f..2c67674115e 100644 --- a/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx +++ b/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx @@ -22,16 +22,18 @@ const initialSelectOptions: SelectOptionProps[] = [ { value: 'North Carolina', children: 'North Carolina' } ]; -export const SelectBasic: React.FunctionComponent = () => { +export const SelectTypeahead: 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 [activeItemId, setActiveItemId] = React.useState(null); const textInputRef = React.useRef(); + const NO_RESULTS = 'no results'; + React.useEffect(() => { let newSelectOptions: SelectOptionProps[] = initialSelectOptions; @@ -44,7 +46,7 @@ export const SelectBasic: React.FunctionComponent = () => { // When no options are found after filtering, display 'No results found' if (!newSelectOptions.length) { newSelectOptions = [ - { isDisabled: false, children: `No results found for "${filterValue}"`, value: 'no results' } + { isAriaDisabled: true, children: `No results found for "${filterValue}"`, value: NO_RESULTS } ]; } @@ -55,84 +57,124 @@ export const SelectBasic: React.FunctionComponent = () => { } setSelectOptions(newSelectOptions); - setActiveItem(null); - setFocusedItemIndex(null); }, [filterValue]); - const onToggleClick = () => { - setIsOpen(!isOpen); + const createItemId = (value: any) => `select-typeahead-${value.replace(' ', '-')}`; + + const setActiveAndFocusedItem = (itemIndex: number) => { + setFocusedItemIndex(itemIndex); + const focusedItem = selectOptions[itemIndex]; + setActiveItemId(createItemId(focusedItem.value)); }; - const onSelect = (_event: React.MouseEvent | undefined, value: string | number | undefined) => { + const resetActiveAndFocusedItem = () => { + setFocusedItemIndex(null); + setActiveItemId(null); + }; + + const closeMenu = () => { + setIsOpen(false); + resetActiveAndFocusedItem(); + }; + + const onInputClick = () => { + if (!isOpen) { + setIsOpen(true); + } else if (!inputValue) { + closeMenu(); + } + }; + + const selectOption = (value: string | number, content: string | number) => { // eslint-disable-next-line no-console - console.log('selected', value); + console.log('selected', content); + + setInputValue(String(content)); + setFilterValue(''); + setSelected(String(value)); - if (value && value !== 'no results') { - setInputValue(value as string); - setFilterValue(''); - setSelected(value as string); + closeMenu(); + }; + + const onSelect = (_event: React.MouseEvent | undefined, value: string | number | undefined) => { + if (value && value !== NO_RESULTS) { + const optionText = selectOptions.find((option) => option.value === value)?.children; + selectOption(value, optionText as string); } - setIsOpen(false); - setFocusedItemIndex(null); - setActiveItem(null); }; const onTextInputChange = (_event: React.FormEvent, value: string) => { setInputValue(value); setFilterValue(value); + + resetActiveAndFocusedItem(); + + if (value !== selected) { + setSelected(''); + } }; const handleMenuArrowKeys = (key: string) => { - let indexToFocus; + let indexToFocus = 0; + + if (!isOpen) { + setIsOpen(true); + } + + if (selectOptions.every((option) => option.isDisabled)) { + return; + } - 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) { + 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; + } + + // Skip disabled options + while (selectOptions[indexToFocus].isDisabled) { + indexToFocus--; + if (indexToFocus === -1) { 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; + } - 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) { + // Skip disabled options + while (selectOptions[indexToFocus].isDisabled) { + indexToFocus++; + if (indexToFocus === selectOptions.length) { indexToFocus = 0; - } else { - indexToFocus = focusedItemIndex + 1; } } - - setFocusedItemIndex(indexToFocus); - const focusedItem = selectOptions.filter((option) => !option.isDisabled)[indexToFocus]; - setActiveItem(`select-typeahead-${focusedItem.value.replace(' ', '-')}`); } + + setActiveAndFocusedItem(indexToFocus); }; const onInputKeyDown = (event: React.KeyboardEvent) => { - const enabledMenuItems = selectOptions.filter((option) => !option.isDisabled); - const [firstMenuItem] = enabledMenuItems; - const focusedItem = focusedItemIndex ? enabledMenuItems[focusedItemIndex] : firstMenuItem; + const focusedItem = focusedItemIndex !== null ? selectOptions[focusedItemIndex] : null; switch (event.key) { - // Select the first available option case 'Enter': - if (isOpen && focusedItem.value !== 'no results') { - setInputValue(String(focusedItem.children)); - setFilterValue(''); - setSelected(String(focusedItem.children)); + if (isOpen && focusedItem && focusedItem.value !== NO_RESULTS && !focusedItem.isAriaDisabled) { + selectOption(focusedItem.value, focusedItem.children as string); } - setIsOpen((prevIsOpen) => !prevIsOpen); - setFocusedItemIndex(null); - setActiveItem(null); + if (!isOpen) { + setIsOpen(true); + } - break; - case 'Tab': - case 'Escape': - setIsOpen(false); - setActiveItem(null); break; case 'ArrowUp': case 'ArrowDown': @@ -142,6 +184,19 @@ export const SelectBasic: React.FunctionComponent = () => { } }; + const onToggleClick = () => { + setIsOpen(!isOpen); + textInputRef?.current?.focus(); + }; + + const onClearButtonClick = () => { + setSelected(''); + setInputValue(''); + setFilterValue(''); + resetActiveAndFocusedItem(); + textInputRef?.current?.focus(); + }; + const toggle = (toggleRef: React.Ref) => ( { - - {!!inputValue && ( - - )} + + @@ -193,10 +237,11 @@ export const SelectBasic: React.FunctionComponent = () => { isOpen={isOpen} selected={selected} onSelect={onSelect} - onOpenChange={() => { - setIsOpen(false); + onOpenChange={(isOpen) => { + !isOpen && closeMenu(); }} toggle={toggle} + shouldFocusFirstItemOnOpen={false} > {selectOptions.map((option, index) => ( @@ -204,8 +249,7 @@ export const SelectBasic: React.FunctionComponent = () => { key={option.value || option.children} isFocused={focusedItemIndex === index} className={option.className} - onClick={() => setSelected(option.value)} - id={`select-typeahead-${option.value.replace(' ', '-')}`} + id={createItemId(option.value)} {...option} ref={null} /> diff --git a/packages/react-core/src/components/Select/examples/SelectTypeaheadCreatable.tsx b/packages/react-core/src/components/Select/examples/SelectTypeaheadCreatable.tsx index b079b1fb507..7b0d9df194d 100644 --- a/packages/react-core/src/components/Select/examples/SelectTypeaheadCreatable.tsx +++ b/packages/react-core/src/components/Select/examples/SelectTypeaheadCreatable.tsx @@ -29,10 +29,11 @@ export const SelectTypeaheadCreatable: React.FunctionComponent = () => { const [filterValue, setFilterValue] = 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 [activeItemId, setActiveItemId] = React.useState(null); const textInputRef = React.useRef(); + const CREATE_NEW = 'create'; + React.useEffect(() => { let newSelectOptions: SelectOptionProps[] = initialSelectOptions; @@ -42,9 +43,9 @@ export const SelectTypeaheadCreatable: React.FunctionComponent = () => { String(menuItem.children).toLowerCase().includes(filterValue.toLowerCase()) ); - // When no options are found after filtering, display creation option - if (!newSelectOptions.length) { - newSelectOptions = [{ isDisabled: false, children: `Create new option "${filterValue}"`, value: 'create' }]; + // If no option matches the filter exactly, display creation option + if (!initialSelectOptions.some((option) => option.value === filterValue)) { + newSelectOptions = [...newSelectOptions, { children: `Create new option "${filterValue}"`, value: CREATE_NEW }]; } // Open the menu when the input value changes and the new value is not empty @@ -54,96 +55,133 @@ export const SelectTypeaheadCreatable: React.FunctionComponent = () => { } setSelectOptions(newSelectOptions); - setActiveItem(null); + }, [filterValue]); + + const createItemId = (value: any) => `select-typeahead-${value.replace(' ', '-')}`; + + const setActiveAndFocusedItem = (itemIndex: number) => { + setFocusedItemIndex(itemIndex); + const focusedItem = selectOptions[itemIndex]; + setActiveItemId(createItemId(focusedItem.value)); + }; + + const resetActiveAndFocusedItem = () => { setFocusedItemIndex(null); - }, [filterValue, onCreation]); + setActiveItemId(null); + }; - const onToggleClick = () => { - setIsOpen(!isOpen); + const closeMenu = () => { + setIsOpen(false); + resetActiveAndFocusedItem(); }; - const onSelect = (_event: React.MouseEvent | undefined, value: string | number | undefined) => { + const onInputClick = () => { + if (!isOpen) { + setIsOpen(true); + } else if (!inputValue) { + closeMenu(); + } + }; + + const selectOption = (value: string | number, content: string | number) => { // eslint-disable-next-line no-console + console.log('selected', content); + + setInputValue(String(content)); + setFilterValue(''); + setSelected(String(value)); + + closeMenu(); + }; + const onSelect = (_event: React.MouseEvent | undefined, value: string | number | undefined) => { if (value) { - if (value === 'create') { - if (!initialSelectOptions.some((item) => item.value === filterValue)) { + if (value === CREATE_NEW) { + if (!initialSelectOptions.some((item) => item.children === filterValue)) { initialSelectOptions = [...initialSelectOptions, { value: filterValue, children: filterValue }]; } setSelected(filterValue); - setOnCreation(!onCreation); setFilterValue(''); + closeMenu(); } else { - // eslint-disable-next-line no-console - console.log('selected', value); - setInputValue(value as string); - setFilterValue(''); - setSelected(value as string); + const optionText = selectOptions.find((option) => option.value === value)?.children; + selectOption(value, optionText as string); } } - - setIsOpen(false); - setFocusedItemIndex(null); - setActiveItem(null); }; const onTextInputChange = (_event: React.FormEvent, value: string) => { setInputValue(value); setFilterValue(value); + + resetActiveAndFocusedItem(); + + if (value !== selected) { + setSelected(''); + } }; const handleMenuArrowKeys = (key: string) => { - let indexToFocus; + let indexToFocus = 0; + + if (!isOpen) { + setIsOpen(true); + } - 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) { + if (selectOptions.every((option) => option.isDisabled)) { + return; + } + + 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; + } + + // Skip disabled options + while (selectOptions[indexToFocus].isDisabled) { + indexToFocus--; + if (indexToFocus === -1) { 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; + } - 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) { + // Skip disabled options + while (selectOptions[indexToFocus].isDisabled) { + indexToFocus++; + if (indexToFocus === selectOptions.length) { indexToFocus = 0; - } else { - indexToFocus = focusedItemIndex + 1; } } - - setFocusedItemIndex(indexToFocus); - const focusedItem = selectOptions.filter((option) => !option.isDisabled)[indexToFocus]; - setActiveItem(`select-create-typeahead-${focusedItem.value.replace(' ', '-')}`); } + + setActiveAndFocusedItem(indexToFocus); }; const onInputKeyDown = (event: React.KeyboardEvent) => { - const enabledMenuItems = selectOptions.filter((option) => !option.isDisabled); - const [firstMenuItem] = enabledMenuItems; - const focusedItem = focusedItemIndex ? enabledMenuItems[focusedItemIndex] : firstMenuItem; + const focusedItem = focusedItemIndex !== null ? selectOptions[focusedItemIndex] : null; switch (event.key) { - // Select the first available option case 'Enter': - if (isOpen) { + if (isOpen && focusedItem && !focusedItem.isAriaDisabled) { onSelect(undefined, focusedItem.value as string); - setIsOpen((prevIsOpen) => !prevIsOpen); - setFocusedItemIndex(null); - setActiveItem(null); } - setIsOpen((prevIsOpen) => !prevIsOpen); - setFocusedItemIndex(null); - setActiveItem(null); + if (!isOpen) { + setIsOpen(true); + } - break; - case 'Tab': - case 'Escape': - setIsOpen(false); - setActiveItem(null); break; case 'ArrowUp': case 'ArrowDown': @@ -153,6 +191,19 @@ export const SelectTypeaheadCreatable: React.FunctionComponent = () => { } }; + const onToggleClick = () => { + setIsOpen(!isOpen); + textInputRef?.current?.focus(); + }; + + const onClearButtonClick = () => { + setSelected(''); + setInputValue(''); + setFilterValue(''); + resetActiveAndFocusedItem(); + textInputRef?.current?.focus(); + }; + const toggle = (toggleRef: React.Ref) => ( { - - {!!inputValue && ( - - )} + + @@ -204,10 +244,11 @@ export const SelectTypeaheadCreatable: React.FunctionComponent = () => { isOpen={isOpen} selected={selected} onSelect={onSelect} - onOpenChange={() => { - setIsOpen(false); + onOpenChange={(isOpen) => { + !isOpen && closeMenu(); }} toggle={toggle} + shouldFocusFirstItemOnOpen={false} > {selectOptions.map((option, index) => ( @@ -215,8 +256,7 @@ export const SelectTypeaheadCreatable: React.FunctionComponent = () => { key={option.value || option.children} isFocused={focusedItemIndex === index} className={option.className} - onClick={() => setSelected(option.value)} - id={`select-typeahead-${option.value.replace(' ', '-')}`} + id={createItemId(option.value)} {...option} ref={null} /> diff --git a/packages/react-integration/demo-app-ts/package.json b/packages/react-integration/demo-app-ts/package.json index f403a0c965c..3b6add51d50 100644 --- a/packages/react-integration/demo-app-ts/package.json +++ b/packages/react-integration/demo-app-ts/package.json @@ -9,7 +9,7 @@ "serve:demo-app": "node scripts/serve" }, "dependencies": { - "@patternfly/react-core": "^5.3.2", + "@patternfly/react-core": "^5.3.3", "react": "^18", "react-dom": "^18", "react-router": "^5.3.3", diff --git a/packages/react-templates/src/components/Dropdown/SimpleDropdown.tsx b/packages/react-templates/src/components/Dropdown/SimpleDropdown.tsx new file mode 100644 index 00000000000..31e770ea1a0 --- /dev/null +++ b/packages/react-templates/src/components/Dropdown/SimpleDropdown.tsx @@ -0,0 +1,139 @@ +import React from 'react'; +import { + Dropdown, + DropdownItem, + DropdownList, + DropdownItemProps, + DropdownProps +} from '@patternfly/react-core/dist/esm/components/Dropdown'; +import { MenuToggle, MenuToggleElement, MenuToggleProps } from '@patternfly/react-core/dist/esm/components/MenuToggle'; +import { Divider } from '@patternfly/react-core/dist/esm/components/Divider'; +import { OUIAProps } from '@patternfly/react-core/dist/esm/helpers'; + +export interface SimpleDropdownItem extends Omit { + /** Content of the dropdown item. If the isDivider prop is true, this prop will be ignored. */ + content?: React.ReactNode; + /** Unique identifier for the dropdown item, which is used in the dropdown onSelect callback */ + value: string | number; + /** Callback for when the dropdown item is clicked. */ + onClick?: (event?: any) => void; + /** URL to redirect to when the dropdown item is clicked. */ + to?: string; + /** Flag indicating whether the dropdown item should render as a divider. If true, the item will be rendered without + * the dropdown item wrapper. + */ + isDivider?: boolean; +} + +export interface SimpleDropdownProps extends Omit, OUIAProps { + /** Initial items of the dropdown. */ + initialItems?: SimpleDropdownItem[]; + /** @hide Forwarded ref */ + innerRef?: React.Ref; + /** Flag indicating the dropdown should be disabled. */ + isDisabled?: boolean; + /** Flag indicated whether the dropdown toggle should take up the full width of its parent. */ + isToggleFullWidth?: boolean; + /** Callback triggered when any dropdown item is clicked. */ + onSelect?: (event?: React.MouseEvent, value?: string | number) => void; + /** Callback triggered when the dropdown toggle opens or closes. */ + onToggle?: (nextIsOpen: boolean) => void; + /** Flag indicating the dropdown toggle should be focused after a dropdown item is clicked. */ + shouldFocusToggleOnSelect?: boolean; + /** Adds an accessible name to the dropdown toggle. Required when the dropdown toggle does not + * have any text content. + */ + toggleAriaLabel?: string; + /** Content of the toggle. */ + toggleContent: React.ReactNode; + /** Variant style of the dropdown toggle. */ + toggleVariant?: 'default' | 'plain' | 'plainText'; + /** Width of the toggle. */ + toggleWidth?: string; + /** Additional props passed to the toggle. */ + toggleProps?: MenuToggleProps; +} + +const SimpleDropdownBase: React.FunctionComponent = ({ + innerRef, + initialItems, + onSelect: onSelectProp, + onToggle: onToggleProp, + isDisabled, + toggleAriaLabel, + toggleContent, + isToggleFullWidth, + toggleVariant = 'default', + toggleWidth, + toggleProps, + shouldFocusToggleOnSelect, + ...props +}: SimpleDropdownProps) => { + const [isOpen, setIsOpen] = React.useState(false); + + const onSelect = (event: React.MouseEvent, value: string | number) => { + onSelectProp && onSelectProp(event, value); + onToggleProp && onToggleProp(false); + setIsOpen(false); + }; + + const onToggle = () => { + onToggleProp && onToggleProp(!isOpen); + setIsOpen(!isOpen); + }; + + const dropdownToggle = (toggleRef: React.Ref) => ( + + {toggleContent} + + ); + + const dropdownSimpleItems = initialItems?.map((item) => { + const { content, onClick, to, value, isDivider, ...itemProps } = item; + + return isDivider ? ( + + ) : ( + + {content} + + ); + }); + + return ( + { + onToggleProp && onToggleProp(isOpen); + setIsOpen(isOpen); + }} + ref={innerRef} + {...props} + > + {dropdownSimpleItems} + + ); +}; + +export const SimpleDropdown = React.forwardRef((props: SimpleDropdownProps, ref: React.Ref) => ( + +)); + +SimpleDropdown.displayName = 'SimpleDropdown'; diff --git a/packages/react-templates/src/components/Dropdown/__tests__/SimpleDropdown.test.tsx b/packages/react-templates/src/components/Dropdown/__tests__/SimpleDropdown.test.tsx new file mode 100644 index 00000000000..15a141e518f --- /dev/null +++ b/packages/react-templates/src/components/Dropdown/__tests__/SimpleDropdown.test.tsx @@ -0,0 +1,266 @@ +import * as React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SimpleDropdown } from '../SimpleDropdown'; +import styles from '@patternfly/react-styles/css/components/MenuToggle/menu-toggle'; + +describe('Dropdown toggle', () => { + test('Renders dropdown toggle as not disabled when isDisabled is not true', () => { + render(); + + expect(screen.getByRole('button', { name: 'Dropdown' })).not.toBeDisabled(); + }); + + test('Renders dropdown toggle as disabled when isDisabled is true', () => { + render(); + + expect(screen.getByRole('button', { name: 'Dropdown' })).toBeDisabled(); + }); + + test('Passes toggleVariant', () => { + render(); + + expect(screen.getByRole('button', { name: 'Dropdown' })).toHaveClass(styles.modifiers.plain); + }); + + test('Passes toggleWidth', () => { + render(); + + expect(screen.getByRole('button', { name: 'Dropdown' })).toHaveAttribute('style', 'width: 500px;'); + }); + + test('Passes additional toggleProps', () => { + render(); + + expect(screen.getByRole('button', { name: 'Dropdown' })).toHaveAttribute('id', 'toggle'); + }); + + test('Passes toggleAriaLabel', () => { + render(); + + expect(screen.getByRole('button')).toHaveAccessibleName('Aria label content'); + }); + + test('Calls onToggle with next isOpen state when dropdown toggle is clicked', async () => { + const onToggle = jest.fn(); + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole('button', { name: 'Dropdown' }); + await user.click(toggle); + expect(onToggle).toHaveBeenCalledWith(true); + }); + + test('Does not call onToggle when dropdown toggle is not clicked', async () => { + const onToggle = jest.fn(); + const items = [{ content: 'Action', value: 1 }]; + const user = userEvent.setup(); + render( +
+ + +
+ ); + + const btn = screen.getByRole('button', { name: 'Actual' }); + await user.click(btn); + expect(onToggle).not.toHaveBeenCalled(); + }); + + test('Calls toggle onSelect when item is clicked', async () => { + const onSelect = jest.fn(); + const items = [{ content: 'Action', value: 1 }]; + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole('button', { name: 'Dropdown' }); + await user.click(toggle); + + const actionItem = screen.getByRole('menuitem', { name: 'Action' }); + await user.click(actionItem); + expect(onSelect).toHaveBeenCalledTimes(1); + }); + + test('Does not call toggle onSelect when item is not clicked', async () => { + const onSelect = jest.fn(); + const items = [{ content: 'Action', value: 1 }]; + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole('button', { name: 'Dropdown' }); + await user.click(toggle); + await user.click(toggle); + expect(onSelect).not.toHaveBeenCalled(); + }); + + test('Does not pass isToggleFullWidth to menu toggle by default', () => { + render(); + + expect(screen.getByRole('button', { name: 'Dropdown' })).not.toHaveClass(styles.modifiers.fullWidth); + }); + + test('Passes isToggleFullWidth to menu toggle when passed in', () => { + render(); + + expect(screen.getByRole('button', { name: 'Dropdown' })).toHaveClass(styles.modifiers.fullWidth); + }); + + test('Does not focus toggle on item select by default', async () => { + const items = [{ content: 'Action', value: 1 }]; + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole('button', { name: 'Dropdown' }); + await user.click(toggle); + const actionItem = screen.getByRole('menuitem', { name: 'Action' }); + await user.click(actionItem); + + expect(toggle).not.toHaveFocus(); + }); + + test('Focuses toggle on item select when shouldFocusToggleOnSelect is true', async () => { + const items = [{ content: 'Action', value: 1 }]; + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole('button', { name: 'Dropdown' }); + await user.click(toggle); + const actionItem = screen.getByRole('menuitem', { name: 'Action' }); + await user.click(actionItem); + + expect(toggle).toHaveFocus(); + }); + + test('Matches snapshot', () => { + const { asFragment } = render(); + + expect(asFragment()).toMatchSnapshot(); + }); +}); + +describe('Dropdown items', () => { + test('Renders with items', async () => { + const items = [ + { content: 'Action', value: 1 }, + { value: 'separator', isDivider: true } + ]; + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole('button', { name: 'Dropdown' }); + await user.click(toggle); + + const actionItem = screen.getByRole('menuitem', { name: 'Action' }); + const dividerItem = screen.getByRole('separator'); + expect(actionItem).toBeInTheDocument(); + expect(dividerItem).toBeInTheDocument(); + }); + + test('Renders with a link item', async () => { + const items = [{ content: 'Link', value: 1, to: '#' }]; + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole('button', { name: 'Dropdown' }); + await user.click(toggle); + + const linkItem = screen.getByRole('menuitem', { name: 'Link' }); + expect(linkItem.getAttribute('href')).toBe('#'); + }); + + test('Renders with items not disabled by default', async () => { + const items = [{ content: 'Action', value: 1 }]; + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole('button', { name: 'Dropdown' }); + await user.click(toggle); + + const actionItem = screen.getByRole('menuitem', { name: 'Action' }); + expect(actionItem).not.toBeDisabled(); + }); + + test('Renders with a disabled item', async () => { + const items = [{ content: 'Action', value: 1, isDisabled: true }]; + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole('button', { name: 'Dropdown' }); + await user.click(toggle); + + const actionItem = screen.getByRole('menuitem', { name: 'Action' }); + expect(actionItem).toBeDisabled(); + }); + + test('Spreads props on item', async () => { + const items = [{ content: 'Action', value: 1, id: 'Test' }]; + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole('button', { name: 'Dropdown' }); + await user.click(toggle); + + const actionItem = screen.getByRole('menuitem', { name: 'Action' }); + expect(actionItem.getAttribute('id')).toBe('Test'); + }); + + test('Calls item onClick when clicked', async () => { + const onClick = jest.fn(); + const items = [{ content: 'Action', value: 1, onClick }]; + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole('button', { name: 'Dropdown' }); + await user.click(toggle); + + const actionItem = screen.getByRole('menuitem', { name: 'Action' }); + await user.click(actionItem); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + test('Does not call item onClick when not clicked', async () => { + const onClick = jest.fn(); + const items = [ + { content: 'Action', value: 1, onClick }, + { content: 'Action 2', value: 2 } + ]; + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole('button', { name: 'Dropdown' }); + await user.click(toggle); + + const actionItem = screen.getByRole('menuitem', { name: 'Action 2' }); + await user.click(actionItem); + expect(onClick).not.toHaveBeenCalled(); + }); + + test('Does not call item onClick when clicked and item is disabled', async () => { + const onClick = jest.fn(); + const items = [{ content: 'Action', value: 1, onClick, isDisabled: true }]; + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole('button', { name: 'Dropdown' }); + await user.click(toggle); + + const actionItem = screen.getByRole('menuitem', { name: 'Action' }); + await user.click(actionItem); + expect(onClick).not.toHaveBeenCalled(); + }); + + test('Matches snapshot', async () => { + const items = [ + { content: 'Action', value: 1, ouiaId: '1' }, + { value: 'separator', isDivider: true, ouiaId: '2' }, + { content: 'Link', value: 'separator', to: '#', ouiaId: '3' } + ]; + const user = userEvent.setup(); + const { asFragment } = render(); + + const toggle = screen.getByRole('button', { name: 'Dropdown' }); + await user.click(toggle); + + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/packages/react-templates/src/components/Dropdown/__tests__/__snapshots__/SimpleDropdown.test.tsx.snap b/packages/react-templates/src/components/Dropdown/__tests__/__snapshots__/SimpleDropdown.test.tsx.snap new file mode 100644 index 00000000000..c1f0b5cab98 --- /dev/null +++ b/packages/react-templates/src/components/Dropdown/__tests__/__snapshots__/SimpleDropdown.test.tsx.snap @@ -0,0 +1,147 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Dropdown items Matches snapshot 1`] = ` + + +
+
+ +
+
+
+`; + +exports[`Dropdown toggle Matches snapshot 1`] = ` + + + +`; diff --git a/packages/react-templates/src/components/Dropdown/examples/DropdownTemplates.md b/packages/react-templates/src/components/Dropdown/examples/DropdownTemplates.md new file mode 100644 index 00000000000..92382f1b179 --- /dev/null +++ b/packages/react-templates/src/components/Dropdown/examples/DropdownTemplates.md @@ -0,0 +1,24 @@ +--- +id: Dropdown +section: components +subsection: menus +template: true +beta: true +propComponents: ['SimpleDropdown', 'SimpleDropdownItem'] +--- + +Note: Templates live in their own package at [@patternfly/react-templates](https://www.npmjs.com/package/@patternfly/react-templates)! + +For custom use cases, please see the dropdown component suite from [@patternfly/react-core](https://www.npmjs.com/package/@patternfly/react-core). + +import { Checkbox, Flex, FlexItem } from '@patternfly/react-core'; +import { SimpleDropdown } from '@patternfly/react-templates'; +import EllipsisVIcon from '@patternfly/react-icons/dist/esm/icons/ellipsis-v-icon'; + +## Examples + +### Simple + +```ts file="./SimpleDropdownExample.tsx" + +``` diff --git a/packages/react-templates/src/components/Dropdown/examples/SimpleDropdownExample.tsx b/packages/react-templates/src/components/Dropdown/examples/SimpleDropdownExample.tsx new file mode 100644 index 00000000000..449cd238a58 --- /dev/null +++ b/packages/react-templates/src/components/Dropdown/examples/SimpleDropdownExample.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { Checkbox, Flex, FlexItem } from '@patternfly/react-core'; +import { SimpleDropdown, SimpleDropdownItem } from '@patternfly/react-templates'; +import EllipsisVIcon from '@patternfly/react-icons/dist/esm/icons/ellipsis-v-icon'; + +export const SimpleDropdownExample: React.FunctionComponent = () => { + const [isDisabled, setIsDisabled] = React.useState(false); + + const items: SimpleDropdownItem[] = [ + // eslint-disable-next-line no-console + { content: 'Action', value: 1, onClick: () => console.log('Action clicked') }, + // Prevent default click behavior on link for example purposes + { content: 'Link', value: 2, to: '#', onClick: (event: any) => event.preventDefault() }, + { content: 'Disabled Action', value: 3, isDisabled: true }, + { value: 'separator', isDivider: true }, + // eslint-disable-next-line no-console + { content: 'Second action', value: 4, onClick: () => console.log('Second action clicked') } + ]; + + return ( + + , checked: boolean) => setIsDisabled(checked)} + style={{ marginBottom: 20 }} + /> + + + + + + + + + + + + ); +}; diff --git a/packages/react-templates/src/components/Dropdown/index.ts b/packages/react-templates/src/components/Dropdown/index.ts new file mode 100644 index 00000000000..8eff180db33 --- /dev/null +++ b/packages/react-templates/src/components/Dropdown/index.ts @@ -0,0 +1 @@ +export * from './SimpleDropdown'; diff --git a/packages/react-templates/src/components/Select/CheckboxSelect.tsx b/packages/react-templates/src/components/Select/CheckboxSelect.tsx index df62017676b..c71006ac5bc 100644 --- a/packages/react-templates/src/components/Select/CheckboxSelect.tsx +++ b/packages/react-templates/src/components/Select/CheckboxSelect.tsx @@ -3,10 +3,12 @@ import { Badge, MenuToggle, MenuToggleElement, + MenuToggleProps, Select, SelectList, SelectOption, - SelectOptionProps + SelectOptionProps, + SelectProps } from '@patternfly/react-core'; export interface CheckboxSelectOption extends Omit { @@ -16,7 +18,7 @@ export interface CheckboxSelectOption extends Omit value: string | number; } -export interface CheckboxSelectProps { +export interface CheckboxSelectProps extends Omit { /** @hide Forwarded ref */ innerRef?: React.Ref; /** Initial options of the select. */ @@ -27,8 +29,12 @@ export interface CheckboxSelectProps { onToggle?: (nextIsOpen: boolean) => void; /** Flag indicating the select should be disabled. */ isDisabled?: boolean; - /** Content of the toggle. Defaults to the selected option. */ + /** Content of the toggle. Defaults to a string with badge count of selected options. */ toggleContent?: React.ReactNode; + /** Width of the toggle. */ + toggleWidth?: string; + /** Additional props passed to the toggle. */ + toggleProps?: MenuToggleProps; } const CheckboxSelectBase: React.FunctionComponent = ({ @@ -38,6 +44,8 @@ const CheckboxSelectBase: React.FunctionComponent = ({ onSelect: passedOnSelect, onToggle, toggleContent, + toggleWidth = '200px', + toggleProps, ...props }: CheckboxSelectProps) => { const [isOpen, setIsOpen] = React.useState(false); @@ -47,7 +55,7 @@ const CheckboxSelectBase: React.FunctionComponent = ({ const { content, value, ...props } = option; const isSelected = selected.includes(`${value}`); return ( - + {content} ); @@ -83,9 +91,10 @@ const CheckboxSelectBase: React.FunctionComponent = ({ isDisabled={isDisabled} style={ { - width: '200px' + width: toggleWidth } as React.CSSProperties } + {...toggleProps} > {toggleContent || defaultToggleContent} @@ -93,11 +102,13 @@ const CheckboxSelectBase: React.FunctionComponent = ({ return ( setIsOpen(isOpen)} + onOpenChange={(isOpen) => { + onToggle && onToggle(isOpen); + setIsOpen(isOpen); + }} toggle={toggle} shouldFocusToggleOnSelect ref={innerRef} @@ -93,8 +106,8 @@ const SelectSimpleBase: React.FunctionComponent = ({ ); }; -export const SelectSimple = React.forwardRef((props: SelectSimpleProps, ref: React.Ref) => ( - +export const SimpleSelect = React.forwardRef((props: SimpleSelectProps, ref: React.Ref) => ( + )); -SelectSimple.displayName = 'SelectSimple'; +SimpleSelect.displayName = 'SimpleSelect'; diff --git a/packages/react-templates/src/components/Select/TypeaheadSelect.tsx b/packages/react-templates/src/components/Select/TypeaheadSelect.tsx new file mode 100644 index 00000000000..eeb9bf37af1 --- /dev/null +++ b/packages/react-templates/src/components/Select/TypeaheadSelect.tsx @@ -0,0 +1,326 @@ +import React from 'react'; +import { + Select, + SelectOption, + SelectList, + SelectOptionProps, + MenuToggle, + MenuToggleElement, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, + Button, + MenuToggleProps, + SelectProps +} from '@patternfly/react-core'; +import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; + +export interface TypeaheadSelectOption extends Omit { + /** Content of the select option. */ + content: string | number; + /** Value of the select option. */ + value: string | number; +} + +export interface TypeaheadSelectProps extends Omit { + /** @hide Forwarded ref */ + innerRef?: React.Ref; + /** Initial options of the select. */ + initialOptions: TypeaheadSelectOption[]; + /** Callback triggered on selection. */ + onSelect?: ( + _event: React.MouseEvent | React.KeyboardEvent, + selection: string | number + ) => void; + /** Callback triggered when the select opens or closes. */ + onToggle?: (nextIsOpen: boolean) => void; + /** Callback triggered when the text in the input field changes. */ + onInputChange?: (newValue: string) => void; + /** Placeholder text for the select input. */ + placeholder?: string; + /** Message to display when no options match the filter. */ + noOptionsFoundMessage?: string | ((filter: string) => string); + /** Flag indicating the select should be disabled. */ + isDisabled?: boolean; + /** Width of the toggle. */ + toggleWidth?: string; + /** Additional props passed to the toggle. */ + toggleProps?: MenuToggleProps; +} + +export const TypeaheadSelectBase: React.FunctionComponent = ({ + innerRef, + initialOptions, + onSelect, + onToggle, + onInputChange, + placeholder = 'Select an option', + noOptionsFoundMessage = (filter) => `No results found for "${filter}"`, + isDisabled, + toggleWidth, + toggleProps, + ...props +}: TypeaheadSelectProps) => { + 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(initialOptions); + const [focusedItemIndex, setFocusedItemIndex] = React.useState(null); + const [activeItemId, setActiveItemId] = React.useState(null); + const textInputRef = React.useRef(); + + const NO_RESULTS = 'no results'; + + React.useEffect(() => { + let newSelectOptions: TypeaheadSelectOption[] = initialOptions; + + // Filter menu items based on the text input value when one exists + if (filterValue) { + newSelectOptions = initialOptions.filter((option) => + String(option.content).toLowerCase().includes(filterValue.toLowerCase()) + ); + + // When no options are found after filtering, display 'No results found' + if (!newSelectOptions.length) { + newSelectOptions = [ + { + isAriaDisabled: true, + content: + typeof noOptionsFoundMessage === 'string' ? noOptionsFoundMessage : noOptionsFoundMessage(filterValue), + value: NO_RESULTS + } + ]; + } + + // Open the menu when the input value changes and the new value is not empty + if (!isOpen) { + openMenu(); + } + } + + setSelectOptions(newSelectOptions); + }, [filterValue, initialOptions]); + + const setActiveAndFocusedItem = (itemIndex: number) => { + setFocusedItemIndex(itemIndex); + const focusedItem = selectOptions[itemIndex]; + setActiveItemId(focusedItem.value as string); + }; + + const resetActiveAndFocusedItem = () => { + setFocusedItemIndex(null); + setActiveItemId(null); + }; + + const openMenu = () => { + onToggle && onToggle(true); + setIsOpen(true); + }; + + const closeMenu = () => { + onToggle && onToggle(false); + setIsOpen(false); + resetActiveAndFocusedItem(); + }; + + const onInputClick = () => { + if (!isOpen) { + openMenu(); + } else if (!inputValue) { + closeMenu(); + } + }; + + const selectOption = ( + _event: React.MouseEvent | React.KeyboardEvent | undefined, + option: TypeaheadSelectOption + ) => { + onSelect && onSelect(_event, option.value); + + setInputValue(String(option.content)); + setFilterValue(''); + setSelected(String(option.value)); + + closeMenu(); + }; + + const _onSelect = (_event: React.MouseEvent | undefined, value: string | number | undefined) => { + if (value && value !== NO_RESULTS) { + const optionToSelect = selectOptions.find((option) => option.value === value); + selectOption(_event, optionToSelect); + } + }; + + const onTextInputChange = (_event: React.FormEvent, value: string) => { + setInputValue(value); + onInputChange && onInputChange(value); + setFilterValue(value); + + resetActiveAndFocusedItem(); + + if (value !== selected) { + setSelected(''); + } + }; + + const handleMenuArrowKeys = (key: string) => { + let indexToFocus = 0; + + if (!isOpen) { + openMenu(); + } + + if (selectOptions.every((option) => option.isDisabled)) { + return; + } + + 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; + } + + // Skip disabled options + while (selectOptions[indexToFocus].isDisabled) { + indexToFocus--; + if (indexToFocus === -1) { + indexToFocus = selectOptions.length - 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; + } + + // Skip disabled options + while (selectOptions[indexToFocus].isDisabled) { + indexToFocus++; + if (indexToFocus === selectOptions.length) { + indexToFocus = 0; + } + } + } + + setActiveAndFocusedItem(indexToFocus); + }; + + const onInputKeyDown = (event: React.KeyboardEvent) => { + const focusedItem = focusedItemIndex !== null ? selectOptions[focusedItemIndex] : null; + + switch (event.key) { + case 'Enter': + if (isOpen && focusedItem && focusedItem.value !== NO_RESULTS && !focusedItem.isAriaDisabled) { + selectOption(event, focusedItem); + } + + if (!isOpen) { + onToggle && onToggle(true); + setIsOpen(true); + } + + break; + case 'ArrowUp': + case 'ArrowDown': + event.preventDefault(); + handleMenuArrowKeys(event.key); + break; + } + }; + + const onToggleClick = () => { + onToggle && onToggle(!isOpen); + setIsOpen(!isOpen); + textInputRef?.current?.focus(); + }; + + const onClearButtonClick = () => { + setSelected(''); + setInputValue(''); + onInputChange && onInputChange(''); + setFilterValue(''); + resetActiveAndFocusedItem(); + textInputRef?.current?.focus(); + }; + + const toggle = (toggleRef: React.Ref) => ( + + + + + + + + + + ); + + return ( + + ); +}; +TypeaheadSelectBase.displayName = 'TypeaheadSelectBase'; + +export const TypeaheadSelect = React.forwardRef((props: TypeaheadSelectProps, ref: React.Ref) => ( + +)); + +TypeaheadSelect.displayName = 'TypeaheadSelect'; diff --git a/packages/react-templates/src/components/Select/CheckboxSelect.test.tsx b/packages/react-templates/src/components/Select/__tests__/CheckboxSelect.test.tsx similarity index 88% rename from packages/react-templates/src/components/Select/CheckboxSelect.test.tsx rename to packages/react-templates/src/components/Select/__tests__/CheckboxSelect.test.tsx index e47a40783fe..1c1292af863 100644 --- a/packages/react-templates/src/components/Select/CheckboxSelect.test.tsx +++ b/packages/react-templates/src/components/Select/__tests__/CheckboxSelect.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { render, screen, waitForElementToBeRemoved } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { CheckboxSelect } from './CheckboxSelect'; +import { CheckboxSelect } from '../CheckboxSelect'; import styles from '@patternfly/react-styles/css/components/Badge/badge'; test('renders checkbox select with options', async () => { @@ -156,6 +156,34 @@ test('displays custom toggle content', async () => { expect(toggleButton).toBeInTheDocument(); }); +test('Passes toggleWidth', () => { + const initialOptions = [ + { content: 'Option 1', value: 'option1' }, + { content: 'Option 2', value: 'option2' }, + { content: 'Option 3', value: 'option3' } + ]; + + render(); + + const toggleButton = screen.getByRole('button', { name: 'Custom Toggle' }); + expect(toggleButton).toHaveAttribute('style', 'width: 500px;'); +}); + +test('Passes additional toggleProps', () => { + const initialOptions = [ + { content: 'Option 1', value: 'option1' }, + { content: 'Option 2', value: 'option2' }, + { content: 'Option 3', value: 'option3' } + ]; + + render( + + ); + + const toggleButton = screen.getByRole('button', { name: 'Custom Toggle' }); + expect(toggleButton).toHaveAttribute('id', 'toggle'); +}); + test('calls the onToggle callback when the select opens or closes', async () => { const initialOptions = [ { content: 'Option 1', value: 'option1' }, diff --git a/packages/react-templates/src/components/Select/CheckboxSelectSnapshots.test.tsx b/packages/react-templates/src/components/Select/__tests__/CheckboxSelectSnapshots.test.tsx similarity index 94% rename from packages/react-templates/src/components/Select/CheckboxSelectSnapshots.test.tsx rename to packages/react-templates/src/components/Select/__tests__/CheckboxSelectSnapshots.test.tsx index 5e305d00042..b0b52331ae6 100644 --- a/packages/react-templates/src/components/Select/CheckboxSelectSnapshots.test.tsx +++ b/packages/react-templates/src/components/Select/__tests__/CheckboxSelectSnapshots.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { CheckboxSelect } from './CheckboxSelect'; +import { CheckboxSelect } from '../CheckboxSelect'; jest.mock('@patternfly/react-core/dist/js/helpers/GenerateId/GenerateId', () => ({ GenerateId: ({ children }) => children('generated-id') diff --git a/packages/react-templates/src/components/Select/__tests__/SimpleSelect.test.tsx b/packages/react-templates/src/components/Select/__tests__/SimpleSelect.test.tsx new file mode 100644 index 00000000000..ef02b577870 --- /dev/null +++ b/packages/react-templates/src/components/Select/__tests__/SimpleSelect.test.tsx @@ -0,0 +1,243 @@ +import * as React from 'react'; +import { render, screen, waitForElementToBeRemoved } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SimpleSelect } from '../SimpleSelect'; + +test('renders checkbox select with options', async () => { + const initialOptions = [ + { content: 'Option 1', value: 'option1' }, + { content: 'Option 2', value: 'option2' }, + { content: 'Option 3', value: 'option3' } + ]; + + const user = userEvent.setup(); + + render(); + + const toggle = screen.getByRole('button', { name: 'Select a value' }); + + await user.click(toggle); + + const option1 = screen.getByText('Option 1'); + const option2 = screen.getByText('Option 2'); + const option3 = screen.getByText('Option 3'); + + expect(option1).toBeInTheDocument(); + expect(option2).toBeInTheDocument(); + expect(option3).toBeInTheDocument(); +}); + +test('selects options when clicked', async () => { + const initialOptions = [ + { content: 'Option 1', value: 'option1' }, + { content: 'Option 2', value: 'option2' }, + { content: 'Option 3', value: 'option3' } + ]; + + const user = userEvent.setup(); + + render(); + + const toggle = screen.getByRole('button', { name: 'Select a value' }); + + await user.click(toggle); + + const option1 = screen.getByText('Option 1'); + await user.click(option1); + + expect(option1).toBeInTheDocument(); +}); + +test('calls the onSelect callback with the selected value when an option is selected', async () => { + const initialOptions = [ + { content: 'Option 1', value: 'option1' }, + { content: 'Option 2', value: 'option2' }, + { content: 'Option 3', value: 'option3' } + ]; + + const user = userEvent.setup(); + const onSelectMock = jest.fn(); + + render(); + + const toggle = screen.getByRole('button', { name: 'Select a value' }); + + await user.click(toggle); + + expect(onSelectMock).not.toHaveBeenCalled(); + + const option1 = screen.getByText('Option 1'); + + await user.click(option1); + + expect(onSelectMock).toHaveBeenCalledTimes(1); + expect(onSelectMock).toHaveBeenCalledWith(expect.anything(), 'option1'); +}); + +test('toggles the select menu when the toggle button is clicked', async () => { + const initialOptions = [ + { content: 'Option 1', value: 'option1' }, + { content: 'Option 2', value: 'option2' }, + { content: 'Option 3', value: 'option3' } + ]; + + const user = userEvent.setup(); + + render(); + + const toggleButton = screen.getByRole('button', { name: 'Select a value' }); + + await user.click(toggleButton); + + expect(screen.getByRole('listbox')).toBeInTheDocument(); + + await user.click(toggleButton); + + await waitForElementToBeRemoved(() => screen.queryByRole('listbox')); + + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); +}); + +test('displays custom toggle content', async () => { + const initialOptions = [ + { content: 'Option 1', value: 'option1' }, + { content: 'Option 2', value: 'option2' }, + { content: 'Option 3', value: 'option3' } + ]; + + render(); + + const toggleButton = screen.getByRole('button', { name: 'Custom Toggle' }); + + expect(toggleButton).toBeInTheDocument(); +}); + +test('Passes toggleWidth', () => { + const initialOptions = [ + { content: 'Option 1', value: 'option1' }, + { content: 'Option 2', value: 'option2' }, + { content: 'Option 3', value: 'option3' } + ]; + + render(); + + const toggleButton = screen.getByRole('button', { name: 'Custom Toggle' }); + expect(toggleButton).toHaveAttribute('style', 'width: 500px;'); +}); + +test('Passes additional toggleProps', () => { + const initialOptions = [ + { content: 'Option 1', value: 'option1' }, + { content: 'Option 2', value: 'option2' }, + { content: 'Option 3', value: 'option3' } + ]; + + render(); + + const toggleButton = screen.getByRole('button', { name: 'Custom Toggle' }); + expect(toggleButton).toHaveAttribute('id', 'toggle'); +}); + +test('calls the onToggle callback when the select opens or closes', async () => { + const initialOptions = [ + { content: 'Option 1', value: 'option1' }, + { content: 'Option 2', value: 'option2' }, + { content: 'Option 3', value: 'option3' } + ]; + + const user = userEvent.setup(); + const onToggleMock = jest.fn(); + + render(); + + const toggle = screen.getByRole('button', { name: 'Select a value' }); + + await user.click(toggle); + + expect(onToggleMock).toHaveBeenCalledTimes(1); + expect(onToggleMock).toHaveBeenCalledWith(true); + + await user.click(toggle); + + expect(onToggleMock).toHaveBeenCalledTimes(2); + expect(onToggleMock).toHaveBeenCalledWith(false); +}); + +test('does not call the onToggle callback when the toggle is not clicked', async () => { + const initialOptions = [ + { content: 'Option 1', value: 'option1' }, + { content: 'Option 2', value: 'option2' }, + { content: 'Option 3', value: 'option3' } + ]; + + const onToggleMock = jest.fn(); + + render(); + + expect(onToggleMock).not.toHaveBeenCalled(); +}); + +test('disables the select when isDisabled prop is true', async () => { + const initialOptions = [ + { content: 'Option 1', value: 'option1' }, + { content: 'Option 2', value: 'option2' }, + { content: 'Option 3', value: 'option3' } + ]; + + const user = userEvent.setup(); + + render(); + + const toggleButton = screen.getByRole('button', { name: 'Select a value' }); + + expect(toggleButton).toBeDisabled(); + + await user.click(toggleButton); + + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); +}); + +test('passes other SelectOption props to the SelectOption component', async () => { + const initialOptions = [{ content: 'Option 1', value: 'option1', isDisabled: true }]; + + const user = userEvent.setup(); + + render(); + + const toggle = screen.getByRole('button', { name: 'Select a value' }); + + await user.click(toggle); + + const option1 = screen.getByRole('option', { name: 'Option 1' }); + + expect(option1).toBeDisabled(); +}); + +jest.mock('@patternfly/react-core/dist/js/helpers/GenerateId/GenerateId', () => ({ + GenerateId: ({ children }) => children('generated-id') +})); + +test('checkbox select with no props snapshot', () => { + const { asFragment } = render(); + + expect(asFragment()).toMatchSnapshot(); +}); + +test('Matches snapshot', async () => { + const initialOptions = [ + { content: 'Option 1', value: 'option1' }, + { content: 'Option 2', value: 'option2' }, + { content: 'Option 3', value: 'option3' } + ]; + + const user = userEvent.setup(); + + render(); + + const { asFragment } = render(); + + const toggle = screen.getByRole('button', { name: 'Select' }); + await user.click(toggle); + + expect(asFragment()).toMatchSnapshot(); +}); diff --git a/packages/react-templates/src/components/Select/__snapshots__/CheckboxSelectSnapshots.test.tsx.snap b/packages/react-templates/src/components/Select/__tests__/__snapshots__/CheckboxSelectSnapshots.test.tsx.snap similarity index 99% rename from packages/react-templates/src/components/Select/__snapshots__/CheckboxSelectSnapshots.test.tsx.snap rename to packages/react-templates/src/components/Select/__tests__/__snapshots__/CheckboxSelectSnapshots.test.tsx.snap index 6d8189bf009..f0d7630a5cd 100644 --- a/packages/react-templates/src/components/Select/__snapshots__/CheckboxSelectSnapshots.test.tsx.snap +++ b/packages/react-templates/src/components/Select/__tests__/__snapshots__/CheckboxSelectSnapshots.test.tsx.snap @@ -81,7 +81,6 @@ exports[`opened checkbox select snapshot 1`] = ` data-popper-escaped="true" data-popper-placement="bottom-start" data-popper-reference-hidden="true" - id="checkbox-select" style="position: absolute; left: 0px; top: 0px; z-index: 9999; opacity: 1; transition: opacity 0ms cubic-bezier(.54, 1.5, .38, 1.11); min-width: 0px; transform: translate(0px, 0px);" >
+ +
+
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+ +`; + +exports[`checkbox select with no props snapshot 1`] = ` + + + +`; diff --git a/packages/react-templates/src/components/Select/examples/CheckboxSelectDemo.tsx b/packages/react-templates/src/components/Select/examples/CheckboxSelectDemo.tsx index 676bd922868..f096f1887c0 100644 --- a/packages/react-templates/src/components/Select/examples/CheckboxSelectDemo.tsx +++ b/packages/react-templates/src/components/Select/examples/CheckboxSelectDemo.tsx @@ -4,7 +4,7 @@ import { CheckboxSelect, CheckboxSelectOption } from '@patternfly/react-template export const SelectBasic: React.FunctionComponent = () => { const initialOptions: CheckboxSelectOption[] = [ { content: 'Option 1', value: 'option-1' }, - { content: 'Option 2', value: 'option-2' }, + { content: 'Option 2', value: 'option-2', description: 'Option with description' }, { content: 'Option 3', value: 'option-3', isDisabled: true }, { content: 'Option 4', value: 'option-4' } ]; diff --git a/packages/react-templates/src/components/Select/examples/SelectTemplates.md b/packages/react-templates/src/components/Select/examples/SelectTemplates.md index db7af312d29..237d67df510 100644 --- a/packages/react-templates/src/components/Select/examples/SelectTemplates.md +++ b/packages/react-templates/src/components/Select/examples/SelectTemplates.md @@ -4,25 +4,32 @@ section: components subsection: menus template: true beta: true -propComponents: ['SelectSimple', 'CheckboxSelect'] +propComponents: ['SimpleSelect', 'CheckboxSelect', 'TypeaheadSelect'] --- Note: Templates live in their own package at [@patternfly/react-templates](https://www.npmjs.com/package/@patternfly/react-templates)! For custom use cases, please see the select component suite from [@patternfly/react-core](https://www.npmjs.com/package/@patternfly/react-core). -import { SelectOption, Checkbox } from '@patternfly/react-core'; -import { SelectSimple, CheckboxSelect } from '@patternfly/react-templates'; +import { Checkbox } from '@patternfly/react-core'; +import { SimpleSelect, CheckboxSelect, TypeaheadSelect } from '@patternfly/react-templates'; ## Select template examples ### Simple -```ts file="SelectSimpleDemo.tsx" +```ts file="SimpleSelectDemo.tsx" ``` ### Checkbox ```ts file="CheckboxSelectDemo.tsx" + +``` + +### Typeahead + +```ts file="TypeaheadSelectDemo.tsx" + ``` diff --git a/packages/react-templates/src/components/Select/examples/SelectSimpleDemo.tsx b/packages/react-templates/src/components/Select/examples/SimpleSelectDemo.tsx similarity index 59% rename from packages/react-templates/src/components/Select/examples/SelectSimpleDemo.tsx rename to packages/react-templates/src/components/Select/examples/SimpleSelectDemo.tsx index da98bbcfcf2..3a2d67f277a 100644 --- a/packages/react-templates/src/components/Select/examples/SelectSimpleDemo.tsx +++ b/packages/react-templates/src/components/Select/examples/SimpleSelectDemo.tsx @@ -1,14 +1,14 @@ import React from 'react'; import { Checkbox } from '@patternfly/react-core'; -import { SelectSimple, SelectSimpleOption } from '@patternfly/react-templates'; +import { SimpleSelect, SimpleSelectOption } from '@patternfly/react-templates'; export const SelectSimpleDemo: React.FunctionComponent = () => { const [isDisabled, setIsDisabled] = React.useState(false); - const initialOptions: SelectSimpleOption[] = [ - { content: 'Option 1', value: 'option1' }, - { content: 'Option 2', value: 'option2' }, - { content: 'Option 3', value: 'option3' } + const initialOptions: SimpleSelectOption[] = [ + { content: 'Option 1', value: 'Option 1' }, + { content: 'Option 2', value: 'Option 2', description: 'Option with description' }, + { content: 'Option 3', value: 'Option 3' } ]; return ( @@ -20,7 +20,7 @@ export const SelectSimpleDemo: React.FunctionComponent = () => { onChange={(_event, checked) => setIsDisabled(checked)} style={{ marginBottom: 20 }} /> - + ); }; diff --git a/packages/react-templates/src/components/Select/examples/TypeaheadSelectDemo.tsx b/packages/react-templates/src/components/Select/examples/TypeaheadSelectDemo.tsx new file mode 100644 index 00000000000..765fda790b2 --- /dev/null +++ b/packages/react-templates/src/components/Select/examples/TypeaheadSelectDemo.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { TypeaheadSelect, TypeaheadSelectOption } from '@patternfly/react-templates'; + +export const SelectTypeaheadDemo: React.FunctionComponent = () => { + const initialOptions: TypeaheadSelectOption[] = [ + { content: 'Alabama', value: 'option1' }, + { content: 'Florida', value: 'option2' }, + { content: 'New Jersey', value: 'option3' }, + { content: 'New Mexico', value: 'option4' }, + { content: 'New York', value: 'option5' }, + { content: 'North Carolina', value: 'option6' } + ]; + + return ( + `No state was found for "${filter}"`} + /> + ); +}; diff --git a/packages/react-templates/src/components/Select/index.ts b/packages/react-templates/src/components/Select/index.ts index c8752c8faa5..b5a5cb3446e 100644 --- a/packages/react-templates/src/components/Select/index.ts +++ b/packages/react-templates/src/components/Select/index.ts @@ -1,2 +1,3 @@ -export * from './SelectSimple'; +export * from './SimpleSelect'; export * from './CheckboxSelect'; +export * from './TypeaheadSelect'; diff --git a/packages/react-templates/src/components/index.ts b/packages/react-templates/src/components/index.ts index 7868ecbae29..938364392c1 100644 --- a/packages/react-templates/src/components/index.ts +++ b/packages/react-templates/src/components/index.ts @@ -1 +1,2 @@ +export * from './Dropdown'; export * from './Select';