From 75fe3c839b56771ace169e7b5f098c312439c493 Mon Sep 17 00:00:00 2001 From: Boyko Date: Mon, 2 Mar 2020 13:17:39 +0200 Subject: [PATCH] Select: scroll into view when navigate with up/down arrows (#22503) * scroll into view when move item up/down Signed-off-by: blalov * update test snapshots Signed-off-by: blalov --- .../CustomScrollbar/CustomScrollbar.tsx | 6 +- .../components/Forms/Select/SelectBase.tsx | 5 +- .../components/Forms/Select/SelectMenu.tsx | 32 ++++++----- .../src/components/Select/Select.tsx | 13 +++-- .../src/components/Select/SelectOption.tsx | 9 ++- .../Select/withSelectArrowNavigation.tsx | 56 +++++++++++++++++++ .../__snapshots__/TeamMemberRow.test.tsx.snap | 10 +++- .../__snapshots__/ConfigEditor.test.tsx.snap | 50 +++++++++++++---- .../AnalyticsConfig.test.tsx.snap | 15 ++++- .../AzureCredentialsForm.test.tsx.snap | 30 ++++++++-- .../__snapshots__/ConfigEditor.test.tsx.snap | 20 +++++-- .../PromQueryEditor.test.tsx.snap | 10 +++- 12 files changed, 202 insertions(+), 54 deletions(-) create mode 100644 packages/grafana-ui/src/components/Select/withSelectArrowNavigation.tsx diff --git a/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx index 96e8809a6eb..0a407bc0ff4 100644 --- a/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx +++ b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx @@ -1,4 +1,4 @@ -import React, { Component } from 'react'; +import React, { Component, RefObject } from 'react'; import isNil from 'lodash/isNil'; import classNames from 'classnames'; import Scrollbars from 'react-custom-scrollbars'; @@ -17,6 +17,7 @@ interface Props { setScrollTop: (event: any) => void; autoHeightMin?: number | string; updateAfterMountMs?: number; + scrollRef?: RefObject; } /** @@ -37,7 +38,7 @@ export class CustomScrollbar extends Component { constructor(props: Props) { super(props); - this.ref = React.createRef(); + this.ref = props.scrollRef || React.createRef(); } updateScroll() { @@ -51,7 +52,6 @@ export class CustomScrollbar extends Component { componentDidMount() { this.updateScroll(); - // this logic is to make scrollbar visible when content is added body after mount if (this.props.updateAfterMountMs) { setTimeout(() => this.updateAfterMount(), this.props.updateAfterMountMs); diff --git a/packages/grafana-ui/src/components/Forms/Select/SelectBase.tsx b/packages/grafana-ui/src/components/Forms/Select/SelectBase.tsx index 4e9e010a3d6..bea9239b2e5 100644 --- a/packages/grafana-ui/src/components/Forms/Select/SelectBase.tsx +++ b/packages/grafana-ui/src/components/Forms/Select/SelectBase.tsx @@ -24,6 +24,7 @@ import { SingleValue } from './SingleValue'; import { MultiValueContainer, MultiValueRemove } from './MultiValue'; import { useTheme } from '../../../themes'; import { getSelectStyles } from './getSelectStyles'; +import { withSelectArrowNavigation } from '../../Select/withSelectArrowNavigation'; type SelectValue = T | SelectableValue | T[] | Array>; @@ -268,10 +269,10 @@ export function SelectBase({ defaultOptions, }; } - + const NavigatableSelect = withSelectArrowNavigation(ReactSelectComponent); return ( <> - ; innerProps: {}; + scrollRef: RefObject; } -export const SelectMenu = React.forwardRef>((props, ref) => { - const theme = useTheme(); - const styles = getSelectStyles(theme); - const { children, maxHeight, innerRef, innerProps } = props; +export const SelectMenu = forwardRef>( + (props, ref = props.innerRef) => { + const theme = useTheme(); + const styles = getSelectStyles(theme); + const { children, maxHeight, innerProps, scrollRef } = props; - return ( -
- - {children} - -
- ); -}); + return ( +
+ + {children} + +
+ ); + } +); SelectMenu.displayName = 'SelectMenu'; diff --git a/packages/grafana-ui/src/components/Select/Select.tsx b/packages/grafana-ui/src/components/Select/Select.tsx index e59032b6d1d..cfffa665e7d 100644 --- a/packages/grafana-ui/src/components/Select/Select.tsx +++ b/packages/grafana-ui/src/components/Select/Select.tsx @@ -27,6 +27,8 @@ import { PopoverContent } from '../Tooltip/Tooltip'; import { Tooltip } from '../Tooltip/Tooltip'; import { SelectableValue } from '@grafana/data'; +import { withSelectArrowNavigation } from './withSelectArrowNavigation'; + /** * Changes in new selects: * - noOptionsMessage & loadingMessage is of string type @@ -42,7 +44,7 @@ interface AsyncProps extends LegacyCommonProps, Omit, value?: SelectableValue; } -interface LegacySelectProps extends LegacyCommonProps { +export interface LegacySelectProps extends LegacyCommonProps { tooltipContent?: PopoverContent; noOptionsMessage?: () => string; isDisabled?: boolean; @@ -52,7 +54,7 @@ interface LegacySelectProps extends LegacyCommonProps { export const MenuList = (props: any) => { return ( - + {props.children} @@ -125,6 +127,7 @@ export class Select extends PureComponent> { SelectComponent = Creatable; creatableOptions.formatCreateLabel = formatCreateLabel ?? ((input: string) => input); } + const NavigatableSelect = withSelectArrowNavigation(SelectComponent); const selectClassNames = classNames('gf-form-input', 'gf-form-input--form-dropdown', widthClass, className); const selectComponents = { ...Select.defaultProps.components, ...components }; @@ -132,7 +135,7 @@ export class Select extends PureComponent> { {(onOpenMenuInternal, onCloseMenuInternal) => { return ( - extends PureComponent> { } } +const NavigatableAsyncSelect = withSelectArrowNavigation(ReactAsyncSelect); + export class AsyncSelect extends PureComponent> { static defaultProps: Partial> = { className: '', @@ -226,7 +231,7 @@ export class AsyncSelect extends PureComponent> { {(onOpenMenuInternal, onCloseMenuInternal) => { return ( - { }; } -export const SelectOption = (props: ExtendedOptionProps) => { +export const SelectOption = forwardRef((props: ExtendedOptionProps, ref) => { const { children, isSelected, data } = props; - return ( - +
{data.imgUrl && }
@@ -28,6 +27,6 @@ export const SelectOption = (props: ExtendedOptionProps) => {
); -}; +}); export default SelectOption; diff --git a/packages/grafana-ui/src/components/Select/withSelectArrowNavigation.tsx b/packages/grafana-ui/src/components/Select/withSelectArrowNavigation.tsx new file mode 100644 index 00000000000..710d191e484 --- /dev/null +++ b/packages/grafana-ui/src/components/Select/withSelectArrowNavigation.tsx @@ -0,0 +1,56 @@ +import React, { useRef, Component, createRef, RefObject, ComponentType, KeyboardEvent } from 'react'; +import { ExtendedOptionProps } from './SelectOption'; + +const scrollIntoView = (optionRef: RefObject | null, scrollRef: RefObject) => { + if (!optionRef || !optionRef.current || !scrollRef || !scrollRef.current || !scrollRef.current.container) { + return; + } + + const { container, scrollTop } = scrollRef.current; + const option = optionRef.current; + const containerRect = container.getBoundingClientRect(); + const optionRect = option.getBoundingClientRect(); + + if (optionRect.bottom > containerRect.bottom) { + scrollTop(option.offsetTop + option.clientHeight - container.offsetHeight); + } else if (optionRect.top < containerRect.top) { + scrollTop(option.offsetTop); + } +}; + +export const withSelectArrowNavigation =

(WrappedComponent: ComponentType

) => { + return class Select extends Component

{ + focusedOptionRef: RefObject | null = null; + scrollRef = createRef(); + render() { + const { components } = this.props; + return ( + { + return ; + }, + Option: (props: ExtendedOptionProps) => { + const innerRef = useRef(null); + if (props.isFocused) { + this.focusedOptionRef = innerRef; + } + return ; + }, + }} + onKeyDown={(e: KeyboardEvent) => { + const { onKeyDown } = this.props; + onKeyDown && onKeyDown(e); + if (e.keyCode === 38 || e.keyCode === 40) { + setTimeout(() => { + scrollIntoView(this.focusedOptionRef, this.scrollRef); + }); + } + }} + /> + ); + } + }; +}; diff --git a/public/app/features/teams/__snapshots__/TeamMemberRow.test.tsx.snap b/public/app/features/teams/__snapshots__/TeamMemberRow.test.tsx.snap index 1c70ef18bf4..6e7db70f6ee 100644 --- a/public/app/features/teams/__snapshots__/TeamMemberRow.test.tsx.snap +++ b/public/app/features/teams/__snapshots__/TeamMemberRow.test.tsx.snap @@ -97,7 +97,10 @@ exports[`Render when feature toggle editorsCanAdmin is turned off should not ren "Group": [Function], "IndicatorsContainer": [Function], "MenuList": [Function], - "Option": [Function], + "Option": Object { + "$$typeof": Symbol(react.forward_ref), + "render": [Function], + }, "SingleValue": [Function], } } @@ -187,7 +190,10 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render p "Group": [Function], "IndicatorsContainer": [Function], "MenuList": [Function], - "Option": [Function], + "Option": Object { + "$$typeof": Symbol(react.forward_ref), + "render": [Function], + }, "SingleValue": [Function], } } diff --git a/public/app/plugins/datasource/cloudwatch/components/__snapshots__/ConfigEditor.test.tsx.snap b/public/app/plugins/datasource/cloudwatch/components/__snapshots__/ConfigEditor.test.tsx.snap index 6f27bc6bd76..1330a9aa86c 100644 --- a/public/app/plugins/datasource/cloudwatch/components/__snapshots__/ConfigEditor.test.tsx.snap +++ b/public/app/plugins/datasource/cloudwatch/components/__snapshots__/ConfigEditor.test.tsx.snap @@ -31,7 +31,10 @@ exports[`Render should disable access key id field 1`] = ` "Group": [Function], "IndicatorsContainer": [Function], "MenuList": [Function], - "Option": [Function], + "Option": Object { + "$$typeof": Symbol(react.forward_ref), + "render": [Function], + }, "SingleValue": [Function], } } @@ -138,7 +141,10 @@ exports[`Render should disable access key id field 1`] = ` "Group": [Function], "IndicatorsContainer": [Function], "MenuList": [Function], - "Option": [Function], + "Option": Object { + "$$typeof": Symbol(react.forward_ref), + "render": [Function], + }, "SingleValue": [Function], } } @@ -211,7 +217,10 @@ exports[`Render should render component 1`] = ` "Group": [Function], "IndicatorsContainer": [Function], "MenuList": [Function], - "Option": [Function], + "Option": Object { + "$$typeof": Symbol(react.forward_ref), + "render": [Function], + }, "SingleValue": [Function], } } @@ -318,7 +327,10 @@ exports[`Render should render component 1`] = ` "Group": [Function], "IndicatorsContainer": [Function], "MenuList": [Function], - "Option": [Function], + "Option": Object { + "$$typeof": Symbol(react.forward_ref), + "render": [Function], + }, "SingleValue": [Function], } } @@ -391,7 +403,10 @@ exports[`Render should should show access key and secret access key fields 1`] = "Group": [Function], "IndicatorsContainer": [Function], "MenuList": [Function], - "Option": [Function], + "Option": Object { + "$$typeof": Symbol(react.forward_ref), + "render": [Function], + }, "SingleValue": [Function], } } @@ -498,7 +513,10 @@ exports[`Render should should show access key and secret access key fields 1`] = "Group": [Function], "IndicatorsContainer": [Function], "MenuList": [Function], - "Option": [Function], + "Option": Object { + "$$typeof": Symbol(react.forward_ref), + "render": [Function], + }, "SingleValue": [Function], } } @@ -571,7 +589,10 @@ exports[`Render should should show arn role field 1`] = ` "Group": [Function], "IndicatorsContainer": [Function], "MenuList": [Function], - "Option": [Function], + "Option": Object { + "$$typeof": Symbol(react.forward_ref), + "render": [Function], + }, "SingleValue": [Function], } } @@ -678,7 +699,10 @@ exports[`Render should should show arn role field 1`] = ` "Group": [Function], "IndicatorsContainer": [Function], "MenuList": [Function], - "Option": [Function], + "Option": Object { + "$$typeof": Symbol(react.forward_ref), + "render": [Function], + }, "SingleValue": [Function], } } @@ -751,7 +775,10 @@ exports[`Render should should show credentials profile name field 1`] = ` "Group": [Function], "IndicatorsContainer": [Function], "MenuList": [Function], - "Option": [Function], + "Option": Object { + "$$typeof": Symbol(react.forward_ref), + "render": [Function], + }, "SingleValue": [Function], } } @@ -858,7 +885,10 @@ exports[`Render should should show credentials profile name field 1`] = ` "Group": [Function], "IndicatorsContainer": [Function], "MenuList": [Function], - "Option": [Function], + "Option": Object { + "$$typeof": Symbol(react.forward_ref), + "render": [Function], + }, "SingleValue": [Function], } } diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/__snapshots__/AnalyticsConfig.test.tsx.snap b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/__snapshots__/AnalyticsConfig.test.tsx.snap index 1766d027d2e..126998be172 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/__snapshots__/AnalyticsConfig.test.tsx.snap +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/__snapshots__/AnalyticsConfig.test.tsx.snap @@ -52,7 +52,10 @@ exports[`Render should disable log analytics credentials form 1`] = ` "Group": [Function], "IndicatorsContainer": [Function], "MenuList": [Function], - "Option": [Function], + "Option": Object { + "$$typeof": Symbol(react.forward_ref), + "render": [Function], + }, "SingleValue": [Function], } } @@ -148,7 +151,10 @@ exports[`Render should enable azure log analytics load workspaces button 1`] = ` "Group": [Function], "IndicatorsContainer": [Function], "MenuList": [Function], - "Option": [Function], + "Option": Object { + "$$typeof": Symbol(react.forward_ref), + "render": [Function], + }, "SingleValue": [Function], } } @@ -244,7 +250,10 @@ exports[`Render should render component 1`] = ` "Group": [Function], "IndicatorsContainer": [Function], "MenuList": [Function], - "Option": [Function], + "Option": Object { + "$$typeof": Symbol(react.forward_ref), + "render": [Function], + }, "SingleValue": [Function], } } diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/__snapshots__/AzureCredentialsForm.test.tsx.snap b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/__snapshots__/AzureCredentialsForm.test.tsx.snap index caf4a184fea..0653a34b3af 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/__snapshots__/AzureCredentialsForm.test.tsx.snap +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/__snapshots__/AzureCredentialsForm.test.tsx.snap @@ -27,7 +27,10 @@ exports[`Render should disable azure monitor secret input 1`] = ` "Group": [Function], "IndicatorsContainer": [Function], "MenuList": [Function], - "Option": [Function], + "Option": Object { + "$$typeof": Symbol(react.forward_ref), + "render": [Function], + }, "SingleValue": [Function], } } @@ -173,7 +176,10 @@ exports[`Render should disable azure monitor secret input 1`] = ` "Group": [Function], "IndicatorsContainer": [Function], "MenuList": [Function], - "Option": [Function], + "Option": Object { + "$$typeof": Symbol(react.forward_ref), + "render": [Function], + }, "SingleValue": [Function], } } @@ -244,7 +250,10 @@ exports[`Render should enable azure monitor load subscriptions button 1`] = ` "Group": [Function], "IndicatorsContainer": [Function], "MenuList": [Function], - "Option": [Function], + "Option": Object { + "$$typeof": Symbol(react.forward_ref), + "render": [Function], + }, "SingleValue": [Function], } } @@ -380,7 +389,10 @@ exports[`Render should enable azure monitor load subscriptions button 1`] = ` "Group": [Function], "IndicatorsContainer": [Function], "MenuList": [Function], - "Option": [Function], + "Option": Object { + "$$typeof": Symbol(react.forward_ref), + "render": [Function], + }, "SingleValue": [Function], } } @@ -451,7 +463,10 @@ exports[`Render should render component 1`] = ` "Group": [Function], "IndicatorsContainer": [Function], "MenuList": [Function], - "Option": [Function], + "Option": Object { + "$$typeof": Symbol(react.forward_ref), + "render": [Function], + }, "SingleValue": [Function], } } @@ -587,7 +602,10 @@ exports[`Render should render component 1`] = ` "Group": [Function], "IndicatorsContainer": [Function], "MenuList": [Function], - "Option": [Function], + "Option": Object { + "$$typeof": Symbol(react.forward_ref), + "render": [Function], + }, "SingleValue": [Function], } } diff --git a/public/app/plugins/datasource/influxdb/components/__snapshots__/ConfigEditor.test.tsx.snap b/public/app/plugins/datasource/influxdb/components/__snapshots__/ConfigEditor.test.tsx.snap index bedce4b6188..824cb2fa535 100644 --- a/public/app/plugins/datasource/influxdb/components/__snapshots__/ConfigEditor.test.tsx.snap +++ b/public/app/plugins/datasource/influxdb/components/__snapshots__/ConfigEditor.test.tsx.snap @@ -125,7 +125,10 @@ exports[`Render should disable basic auth password input 1`] = ` "Group": [Function], "IndicatorsContainer": [Function], "MenuList": [Function], - "Option": [Function], + "Option": Object { + "$$typeof": Symbol(react.forward_ref), + "render": [Function], + }, "SingleValue": [Function], } } @@ -342,7 +345,10 @@ exports[`Render should hide basic auth fields when switch off 1`] = ` "Group": [Function], "IndicatorsContainer": [Function], "MenuList": [Function], - "Option": [Function], + "Option": Object { + "$$typeof": Symbol(react.forward_ref), + "render": [Function], + }, "SingleValue": [Function], } } @@ -559,7 +565,10 @@ exports[`Render should hide white listed cookies input when browser access chose "Group": [Function], "IndicatorsContainer": [Function], "MenuList": [Function], - "Option": [Function], + "Option": Object { + "$$typeof": Symbol(react.forward_ref), + "render": [Function], + }, "SingleValue": [Function], } } @@ -776,7 +785,10 @@ exports[`Render should render component 1`] = ` "Group": [Function], "IndicatorsContainer": [Function], "MenuList": [Function], - "Option": [Function], + "Option": Object { + "$$typeof": Symbol(react.forward_ref), + "render": [Function], + }, "SingleValue": [Function], } } diff --git a/public/app/plugins/datasource/prometheus/components/__snapshots__/PromQueryEditor.test.tsx.snap b/public/app/plugins/datasource/prometheus/components/__snapshots__/PromQueryEditor.test.tsx.snap index 1f9d676b29c..5d82ac46737 100644 --- a/public/app/plugins/datasource/prometheus/components/__snapshots__/PromQueryEditor.test.tsx.snap +++ b/public/app/plugins/datasource/prometheus/components/__snapshots__/PromQueryEditor.test.tsx.snap @@ -86,7 +86,10 @@ exports[`Render PromQueryEditor with basic options should render 1`] = ` "Group": [Function], "IndicatorsContainer": [Function], "MenuList": [Function], - "Option": [Function], + "Option": Object { + "$$typeof": Symbol(react.forward_ref), + "render": [Function], + }, "SingleValue": [Function], } } @@ -153,7 +156,10 @@ exports[`Render PromQueryEditor with basic options should render 1`] = ` "Group": [Function], "IndicatorsContainer": [Function], "MenuList": [Function], - "Option": [Function], + "Option": Object { + "$$typeof": Symbol(react.forward_ref), + "render": [Function], + }, "SingleValue": [Function], } }