From f34626162b9bd49a50ae5e143b9885ff8128d1d6 Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Date: Thu, 29 Aug 2024 13:00:09 -0400 Subject: [PATCH] Fix for select components causing scroll container to jump --- frontend/src/components/MultiSelection.tsx | 109 ++++++++------- frontend/src/components/SimpleSelect.tsx | 117 +++++++++------- frontend/src/components/TypeaheadSelect.tsx | 59 ++++---- .../fields/DropdownFormField.tsx | 131 ++++++++++-------- .../src/utilities/WithScrollContainer.tsx | 47 +++++++ 5 files changed, 274 insertions(+), 189 deletions(-) create mode 100644 frontend/src/utilities/WithScrollContainer.tsx diff --git a/frontend/src/components/MultiSelection.tsx b/frontend/src/components/MultiSelection.tsx index 0f141bf77d..5a1b7829ac 100644 --- a/frontend/src/components/MultiSelection.tsx +++ b/frontend/src/components/MultiSelection.tsx @@ -15,8 +15,10 @@ import { HelperTextItem, SelectGroup, Divider, + SelectPopperProps, } from '@patternfly/react-core'; -import { TimesIcon } from '@patternfly/react-icons/dist/esm/icons/times-icon'; +import { TimesIcon } from '@patternfly/react-icons'; +import { WithScrollContainer } from '~/utilities/WithScrollContainer'; export type SelectionOptions = { id: number | string; @@ -42,6 +44,7 @@ type MultiSelectionProps = { selectionRequired?: boolean; noSelectedOptionsMessage?: string; toggleTestId?: string; + popperProps?: SelectPopperProps; }; export const MultiSelection: React.FC = ({ @@ -56,6 +59,7 @@ export const MultiSelection: React.FC = ({ toggleTestId, selectionRequired, noSelectedOptionsMessage = 'One or more options must be selected', + popperProps = {}, }) => { const [isOpen, setIsOpen] = React.useState(false); const [inputValue, setInputValue] = React.useState(''); @@ -255,28 +259,50 @@ export const MultiSelection: React.FC = ({ ); return ( - <> - { + const selectedOption = allOptions.find((option) => option.id === selection); + onSelect(selectedOption); + }} + onOpenChange={() => setOpen(false)} + toggle={toggle} + popperProps={{ appendTo: scrollContainer, ...popperProps }} + > + {visibleOptions.length === 0 && inputValue ? ( - {g.values.map((option) => ( + No results found + + ) : null} + {selectGroups.map((g, index) => ( + <> + + + {g.values.map((option) => ( + + {option.name} + + ))} + + + {index < selectGroups.length - 1 || selectOptions.length ? : null} + + ))} + {selectOptions.length ? ( + + {selectOptions.map((option) => ( = ({ ))} - - {index < selectGroups.length - 1 || selectOptions.length ? : null} - - ))} - {selectOptions.length ? ( - - {selectOptions.map((option) => ( - - {option.name} - - ))} - - ) : null} - - {noSelectedItems && selectionRequired && ( - - - {noSelectedOptionsMessage} - - + ) : null} + + {noSelectedItems && selectionRequired && ( + + + {noSelectedOptionsMessage} + + + )} + )} - + ); }; diff --git a/frontend/src/components/SimpleSelect.tsx b/frontend/src/components/SimpleSelect.tsx index 27960895ff..a1d7b62e06 100644 --- a/frontend/src/components/SimpleSelect.tsx +++ b/frontend/src/components/SimpleSelect.tsx @@ -9,6 +9,7 @@ import { Divider, MenuToggleProps, } from '@patternfly/react-core'; +import { WithScrollContainer } from '~/utilities/WithScrollContainer'; import './SimpleSelect.scss'; @@ -56,6 +57,7 @@ const SimpleSelect: React.FC = ({ icon, dataTestId, toggleProps, + popperProps = {}, ...props }) => { const [open, setOpen] = React.useState(false); @@ -70,41 +72,66 @@ const SimpleSelect: React.FC = ({ const selectedLabel = selectedOption?.label ?? placeholder; return ( - { + onChange( + String(selectValue), + !!selectValue && (findOptionForKey(String(selectValue))?.isPlaceholder ?? false), + ); + setOpen(false); + }} + onOpenChange={setOpen} + toggle={(toggleRef) => ( + setOpen(!open)} + icon={icon} + isExpanded={open} + isDisabled={isDisabled} + isFullWidth={isFullWidth} + {...toggleProps} + > + {toggleLabel || ( + + )} + + )} + shouldFocusToggleOnSelect > - {toggleLabel || } - - )} - shouldFocusToggleOnSelect - > - {groupedOptions?.map((group, index) => ( - <> - {index > 0 ? : null} - + {groupedOptions?.map((group, index) => ( + <> + {index > 0 ? : null} + + + {group.options.map( + ({ key, label, dropdownLabel, description, isDisabled: optionDisabled }) => ( + + {dropdownLabel || label} + + ), + )} + + + + )) ?? null} + {options?.length ? ( - {group.options.map( + {groupedOptions?.length ? : null} + {options.map( ({ key, label, dropdownLabel, description, isDisabled: optionDisabled }) => ( = ({ ), )} - - - )) ?? null} - {options?.length ? ( - - {groupedOptions?.length ? : null} - {options.map(({ key, label, dropdownLabel, description, isDisabled: optionDisabled }) => ( - - {dropdownLabel || label} - - ))} - - ) : null} - + ) : null} + + )} + ); }; diff --git a/frontend/src/components/TypeaheadSelect.tsx b/frontend/src/components/TypeaheadSelect.tsx index 28932e5436..daccd4dbe5 100644 --- a/frontend/src/components/TypeaheadSelect.tsx +++ b/frontend/src/components/TypeaheadSelect.tsx @@ -12,8 +12,10 @@ import { Button, MenuToggleProps, SelectProps, + SelectPopperProps, } from '@patternfly/react-core'; import { TimesIcon } from '@patternfly/react-icons'; +import { WithScrollContainer } from '~/utilities/WithScrollContainer'; export interface TypeaheadSelectOption extends Omit { /** Content of the select option. */ @@ -66,6 +68,7 @@ export interface TypeaheadSelectProps extends Omit `No results found for "${filter}"`; @@ -92,6 +95,7 @@ const TypeaheadSelect: React.FunctionComponent = ({ isDisabled, toggleWidth, toggleProps, + popperProps = {}, ...props }: TypeaheadSelectProps) => { const [isOpen, setIsOpen] = React.useState(false); @@ -395,31 +399,36 @@ const TypeaheadSelect: React.FunctionComponent = ({ ); return ( - + + {(scrollContainer) => ( + + )} + ); }; diff --git a/frontend/src/concepts/connectionTypes/fields/DropdownFormField.tsx b/frontend/src/concepts/connectionTypes/fields/DropdownFormField.tsx index f720dc985b..785f0cf4ee 100644 --- a/frontend/src/concepts/connectionTypes/fields/DropdownFormField.tsx +++ b/frontend/src/concepts/connectionTypes/fields/DropdownFormField.tsx @@ -3,6 +3,7 @@ import { Badge, MenuToggle, Select, SelectList, SelectOption } from '@patternfly import { DropdownField } from '~/concepts/connectionTypes/types'; import { FieldProps } from '~/concepts/connectionTypes/fields/types'; import DefaultValueTextRenderer from '~/concepts/connectionTypes/fields/DefaultValueTextRenderer'; +import { WithScrollContainer } from '~/utilities/WithScrollContainer'; const DropdownFormField: React.FC> = ({ id, @@ -16,73 +17,81 @@ const DropdownFormField: React.FC> = ({ const [isOpen, setIsOpen] = React.useState(false); const isMulti = field.properties.variant === 'multi'; const selected = isPreview ? field.properties.defaultValue : value; + return ( - { + if (isMulti) { + if (selected?.includes(String(v))) { + onChange(selected.filter((s) => s !== v)); + } else { + onChange([...(selected || []), String(v)]); + } + } else { + onChange([String(v)]); + setIsOpen(false); + } } - } else { - onChange([String(v)]); - setIsOpen(false); - } - } - } - onOpenChange={(open) => setIsOpen(open)} - toggle={(toggleRef) => ( - { - setIsOpen((open) => !open); + } + onOpenChange={(open) => setIsOpen(open)} + popperProps={{ + appendTo: scrollContainer, }} - isExpanded={isOpen} - > - {isMulti ? ( - <> - Select {field.name}{' '} - - {(isPreview ? field.properties.defaultValue?.length : value?.length) ?? 0}{' '} - selected - - - ) : ( - (isPreview - ? field.properties.items?.find( - (i) => i.value === field.properties.defaultValue?.[0], - )?.label - : field.properties.items?.find((i) => value?.includes(i.value))?.label) || - `Select ${field.name}` + toggle={(toggleRef) => ( + { + setIsOpen((open) => !open); + }} + isExpanded={isOpen} + > + {isMulti ? ( + <> + Select {field.name}{' '} + + {(isPreview ? field.properties.defaultValue?.length : value?.length) ?? 0}{' '} + selected + + + ) : ( + (isPreview + ? field.properties.items?.find( + (i) => i.value === field.properties.defaultValue?.[0], + )?.label + : field.properties.items?.find((i) => value?.includes(i.value))?.label) || + `Select ${field.name}` + )} + )} - + > + + {field.properties.items?.map((i) => ( + + {i.label} + + ))} + + )} - > - - {field.properties.items?.map((i) => ( - - {i.label} - - ))} - - + ); }; diff --git a/frontend/src/utilities/WithScrollContainer.tsx b/frontend/src/utilities/WithScrollContainer.tsx new file mode 100644 index 0000000000..5a5d91eec9 --- /dev/null +++ b/frontend/src/utilities/WithScrollContainer.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; + +const isHTMLElement = (n: Node): n is HTMLElement => n.nodeType === Node.ELEMENT_NODE; + +export const getParentScrollableElement = (node: HTMLElement | null): HTMLElement | undefined => { + let parentNode: Node | null = node; + while (parentNode) { + if (isHTMLElement(parentNode)) { + let { overflow } = parentNode.style; + if (!overflow.includes('scroll') && !overflow.includes('auto')) { + overflow = window.getComputedStyle(parentNode).overflow; + } + if (overflow.includes('scroll') || overflow.includes('auto')) { + return parentNode; + } + } + parentNode = parentNode.parentNode; + } + return undefined; +}; + +type WithScrollContainerProps = { + children: (scrollContainer: HTMLElement | 'inline') => React.ReactElement | null; +}; + +export const WithScrollContainer: React.FC = ({ children }) => { + const [scrollContainer, setScrollContainer] = React.useState(); + const ref = React.useCallback((node: HTMLElement | null) => { + if (node) { + setScrollContainer(getParentScrollableElement(node)); + } + }, []); + return scrollContainer ? children(scrollContainer) : {children('inline')}; +}; + +export const useScrollContainer = (): [HTMLElement | undefined, (node: HTMLElement) => void] => { + const [scrollContainer, setScrollContainer] = React.useState(); + const elementRef = React.useCallback((node: HTMLElement | null) => { + if (node === null) { + setScrollContainer(undefined); + } + if (node) { + setScrollContainer(getParentScrollableElement(node)); + } + }, []); + return [scrollContainer, elementRef]; +};