Przeglądaj źródła

Add fields keyboard navigation support

You can now using "Tab" to navigate through all field components (text
inputs, dropdowns, switches, numeric steppers etc.).

When navigating over a dropdown you can use the "Up" and "Down" arrow
keys followed by "Enter" or "Space" to select items.

You can use "Up" and "Down" arrow keys to select values for the numeric
stepper.

You can select items in a list with selectable items (checkbox
components) using "Tab" and "Space" keys.
Sergiu Miclea 7 lat temu
rodzic
commit
345bf6b55f

+ 9 - 4
src/components/atoms/AutocompleteInput/AutocompleteInput.jsx

@@ -14,7 +14,7 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 // @flow
 
-import React from 'react'
+import * as React from 'react'
 import styled, { css } from 'styled-components'
 
 import arrowImage from './images/arrow'
@@ -75,12 +75,13 @@ type Props = {
   customRef?: (ref: HTMLElement) => void,
   innerRef?: (ref: HTMLElement) => void,
   onChange: (value: string) => void,
-  onClick?: () => void,
   disabled?: boolean,
   disabledLoading?: boolean,
   width?: number,
   large?: boolean,
   onFocus?: () => void,
+  onBlur?: () => void,
+  onInputKeyDown?: (e: SyntheticKeyboardEvent<HTMLInputElement>) => void,
   highlight?: boolean,
   embedded?: boolean,
 }
@@ -92,6 +93,8 @@ class AutocompleteInput extends React.Component<Props, State> {
     textInputFocus: false,
   }
 
+  textInputRef: HTMLElement
+
   render() {
     let disabled = this.props.disabled || this.props.disabledLoading
     return (
@@ -126,13 +129,15 @@ class AutocompleteInput extends React.Component<Props, State> {
           lineHeight="30px"
           placeholder="Type to search ..."
           onFocus={() => { if (this.props.onFocus) { this.props.onFocus() } this.setState({ textInputFocus: true }) }}
-          onBlur={() => { this.setState({ textInputFocus: false }) }}
+          onBlur={() => { if (this.props.onBlur) { this.props.onBlur() } this.setState({ textInputFocus: false }) }}
+          innerRef={ref => { this.textInputRef = ref }}
+          onInputKeyDown={this.props.onInputKeyDown}
         />
         <Arrow
           data-test-id="acInput-arrow"
           disabled={disabled}
           dangerouslySetInnerHTML={{ __html: arrowImage }}
-          onClick={this.props.onClick}
+          onClick={() => { this.textInputRef.focus() }}
         />
       </Wrapper>
     )

+ 19 - 1
src/components/atoms/Checkbox/Checkbox.jsx

@@ -14,7 +14,7 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 // @flow
 
-import React from 'react'
+import * as React from 'react'
 import { observer } from 'mobx-react'
 import styled, { css } from 'styled-components'
 
@@ -48,6 +48,10 @@ const Wrapper = styled.div`
       transform: scale(1);
     }
   ` : ''}
+  :focus {
+    border: 1px solid ${Palette.primary};
+    outline: none;
+  }
 `
 
 type Props = {
@@ -56,6 +60,8 @@ type Props = {
   disabled?: boolean,
   onChange?: (checked: boolean) => void,
   'data-test-id'?: string,
+  onMouseDown?: (e: SyntheticMouseEvent<HTMLDivElement>) => void,
+  onMouseUp?: (e: SyntheticMouseEvent<HTMLDivElement>) => void,
 }
 @observer
 class Checkbox extends React.Component<Props> {
@@ -67,6 +73,14 @@ class Checkbox extends React.Component<Props> {
     this.props.onChange(!this.props.checked)
   }
 
+  handleKeyDown(e: SyntheticKeyboardEvent<HTMLDivElement>) {
+    if (e.key !== ' ') {
+      return
+    }
+    e.preventDefault()
+    this.handleClick()
+  }
+
   render() {
     return (
       <Wrapper
@@ -75,6 +89,10 @@ class Checkbox extends React.Component<Props> {
         onClick={() => { this.handleClick() }}
         checked={this.props.checked}
         disabled={this.props.disabled}
+        tabIndex={0}
+        onKeyDown={e => { this.handleKeyDown(e) }}
+        onMouseDown={this.props.onMouseDown}
+        onMouseUp={this.props.onMouseUp}
       >
         <CheckmarkImage />
       </Wrapper>

+ 8 - 0
src/components/atoms/DropdownButton/DropdownButton.jsx

@@ -72,6 +72,10 @@ const getArrowColor = props => {
     return 'white'
   }
 
+  if (props.outline) {
+    return Palette.primary
+  }
+
   return Palette.black
 }
 const getWidth = props => {
@@ -96,6 +100,9 @@ const borderColor = props => {
   if (props.secondary) {
     return Palette.secondaryLight
   }
+  if (props.outline) {
+    return Palette.primary
+  }
   return Palette.grayscale[3]
 }
 const backgroundHover = props => {
@@ -160,6 +167,7 @@ type Props = {
   highlight?: boolean,
   secondary?: boolean,
   centered?: boolean,
+  outline?: boolean,
 }
 class DropdownButton extends React.Component<Props> {
   render() {

+ 27 - 3
src/components/atoms/Pagination/Pagination.jsx

@@ -14,7 +14,7 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 // @flow
 
-import React from 'react'
+import * as React from 'react'
 import { observer } from 'mobx-react'
 import styled, { css } from 'styled-components'
 
@@ -34,6 +34,9 @@ const pageStyle = css`
   justify-content: center;
   align-items: center;
   background: ${Palette.grayscale[1]};
+  :focus {
+    ${props => props.disabled ? css`outline: none;` : ''}
+  }
 `
 const pageButtonStyle = css`
   width: 32px;
@@ -76,6 +79,23 @@ type Props = {
 
 @observer
 class Pagination extends React.Component<Props> {
+  goTo(type: 'previous' | 'next') {
+    if (type === 'previous' && !this.props.previousDisabled) {
+      this.props.onPreviousClick()
+    }
+    if (type === 'next' && !this.props.nextDisabled) {
+      this.props.onNextClick()
+    }
+  }
+
+  handleKeyDown(e: SyntheticKeyboardEvent<HTMLDivElement>, type: 'previous' | 'next') {
+    if (e.key !== ' ') {
+      return
+    }
+    e.preventDefault()
+    this.goTo(type)
+  }
+
   render() {
     return (
       <Wrapper
@@ -85,7 +105,9 @@ class Pagination extends React.Component<Props> {
       >
         <PagePrevious
           disabled={this.props.previousDisabled}
-          onClick={() => { if (!this.props.previousDisabled) { this.props.onPreviousClick() } }}
+          tabIndex={0}
+          onClick={() => { this.goTo('previous') }}
+          onKeyDown={e => { this.handleKeyDown(e, 'previous') }}
         >
           <Arrow orientation="left" disabled={this.props.previousDisabled} color={Palette.black} thick />
         </PagePrevious>
@@ -96,8 +118,10 @@ class Pagination extends React.Component<Props> {
           ) : null}
         </PageNumber>
         <PageNext
-          onClick={() => { if (!this.props.nextDisabled) { this.props.onNextClick() } }}
           disabled={this.props.nextDisabled}
+          tabIndex={0}
+          onClick={() => { this.goTo('next') }}
+          onKeyDown={e => { this.handleKeyDown(e, 'next') }}
         >
           <Arrow disabled={this.props.nextDisabled} color={Palette.black} thick />
         </PageNext>

+ 26 - 3
src/components/atoms/RadioInput/RadioInput.jsx

@@ -14,7 +14,7 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 // @flow
 
-import React from 'react'
+import * as React from 'react'
 import { observer } from 'mobx-react'
 import styled from 'styled-components'
 
@@ -56,15 +56,38 @@ type Props = {
   label: string,
   disabledLoading?: boolean,
   disabled?: boolean,
+  onChange: (checked: boolean) => void,
 }
 @observer
 class RadioInput extends React.Component<Props> {
+  handleKeyDown(evt: SyntheticKeyboardEvent<HTMLDivElement>) {
+    if (evt.key !== ' ') {
+      return
+    }
+    evt.preventDefault()
+
+    this.props.onChange(true)
+  }
+
   render() {
+    let { onChange, ...props } = this.props
     let disabled = this.props.disabled || this.props.disabledLoading
     return (
-      <Wrapper {...this.props} disabled={disabled} disabledLoading={this.props.disabledLoading}>
+      <Wrapper
+        {...props}
+        disabled={disabled}
+        disabledLoading={this.props.disabledLoading}
+        tabIndex={0}
+        onKeyDown={evt => { this.handleKeyDown(evt) }}
+      >
         <LabelStyled>
-          <InputStyled type="radio" {...this.props} disabled={disabled} data-test-id="radioInput-input" />
+          <InputStyled
+            type="radio"
+            {...props}
+            disabled={disabled}
+            data-test-id="radioInput-input"
+            onChange={e => { this.props.onChange(e.target.checked) }}
+          />
           <Text data-test-id="radioInput-label">{this.props.label}</Text>
         </LabelStyled>
       </Wrapper>

+ 1 - 1
src/components/atoms/RadioInput/test.jsx

@@ -23,7 +23,7 @@ const wrap = props => new TestWrapper(shallow(<RadioInput {...props} />), 'radio
 
 describe('RadioInput Component', () => {
   it('renders the given label', () => {
-    const wrapper = wrap({ label: 'the_value' })
+    const wrapper = wrap({ label: 'the_value', onChange: () => { } })
     expect(wrapper.findText('label')).toBe('the_value')
   })
 })

+ 29 - 4
src/components/atoms/Stepper/Stepper.jsx

@@ -12,6 +12,7 @@ import downSvg from './images/down.svg'
 
 const Wrapper = styled.div`
   position: relative;
+  ${props => props.disabledLoading ? StyleProps.animations.disabledLoading : ''}
 `
 const getInputWidth = (props: any) => {
   if (props.width) {
@@ -109,8 +110,9 @@ type Props = {
   name?: string,
   minimum?: ?number,
   maximum?: ?number,
+  disabledLoading?: ?boolean,
 }
-
+const INCREMENT = 1
 class Stepper extends React.Component<Props, State> {
   state = {
     inputValue: null,
@@ -148,6 +150,27 @@ class Stepper extends React.Component<Props, State> {
     this.setState({ inputValue: null })
   }
 
+  handleKeyDown(e: SyntheticKeyboardEvent<HTMLInputElement>) {
+    if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') {
+      return
+    }
+    e.preventDefault()
+    if (e.key === 'ArrowUp') {
+      this.increment()
+    }
+    if (e.key === 'ArrowDown') {
+      this.decrement()
+    }
+  }
+
+  increment() {
+    this.commitChange(`${(this.props.value || 0) + INCREMENT}`)
+  }
+
+  decrement() {
+    this.commitChange(`${this.props.value && this.props.value > 0 ? this.props.value - INCREMENT : 0}`)
+  }
+
   render() {
     const { _ref, value, type } = this.props
     let downImageRef: ?HTMLElement
@@ -160,9 +183,11 @@ class Stepper extends React.Component<Props, State> {
     }
 
     return (
-      <Wrapper>
+      <Wrapper disabledLoading={this.props.disabledLoading}>
         <Input
           {...this.props}
+          onKeyDown={e => { this.handleKeyDown(e) }}
+          disabled={this.props.disabled || this.props.disabledLoading}
           type={type || 'text'}
           innerRef={ref => {
             if (_ref && ref) { _ref(ref) }
@@ -173,7 +198,7 @@ class Stepper extends React.Component<Props, State> {
         />
         <StepsButtons embedded={this.props.embedded}>
           <StepButtonUp
-            onClick={() => { this.commitChange(`${(value || 0) + 1}`) }}
+            onClick={() => { this.increment() }}
             onMouseDown={() => { scale(upImageRef, 'down') }}
             onMouseUp={() => { scale(upImageRef, 'up') }}
             onMouseLeave={() => { scale(upImageRef, 'up') }}
@@ -184,7 +209,7 @@ class Stepper extends React.Component<Props, State> {
             />
           </StepButtonUp>
           <StepButtonDown
-            onClick={() => { this.commitChange(`${value && value > 0 ? value - 1 : 0}`) }}
+            onClick={() => { this.decrement() }}
             onMouseDown={() => { scale(downImageRef, 'down') }}
             onMouseUp={() => { scale(downImageRef, 'up') }}
             onMouseLeave={() => { scale(downImageRef, 'up') }}

+ 4 - 1
src/components/atoms/Switch/Switch.jsx

@@ -16,7 +16,7 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 import * as React from 'react'
 import { observer } from 'mobx-react'
-import styled from 'styled-components'
+import styled, { css } from 'styled-components'
 
 import Palette from '../../styleUtils/Palette'
 import StyleProps from '../../styleUtils/StyleProps'
@@ -35,6 +35,9 @@ const InputWrapper = styled.div`
   width: ${props => props.height * 2}px;
   height: ${props => props.height}px;
   ${props => !props.disabled ? 'cursor: pointer;' : ''};
+  :focus {
+    ${props => (props.disabled || props.disabledLoading) ? css`outline: none;` : ''}
+  }
 `
 const inputBackground = props => {
   if (props.big) {

+ 3 - 1
src/components/atoms/TextInput/TextInput.jsx

@@ -105,9 +105,10 @@ type Props = {
   'data-test-id'?: string,
   required?: boolean,
   disabledLoading?: boolean,
+  onInputKeyDown?: (e: SyntheticKeyboardEvent<HTMLInputElement>) => void,
 }
 const TextInput = (props: Props) => {
-  const { _ref, value, onChange, showClose, onCloseClick, disabled, disabledLoading, embedded } = props
+  const { _ref, value, onChange, showClose, onCloseClick, disabled, disabledLoading, embedded, onInputKeyDown } = props
   let actualDisabled = disabled || disabledLoading
   let input
   return (
@@ -119,6 +120,7 @@ const TextInput = (props: Props) => {
         onChange={onChange}
         data-test-id="textInput-input"
         {...props}
+        onKeyDown={onInputKeyDown}
         disabled={actualDisabled}
       />
       {props.required ? <Required right={embedded ? -24 : -16} /> : null}

+ 23 - 3
src/components/atoms/ToggleButtonBar/ToggleButtonBar.jsx

@@ -14,9 +14,9 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 // @flow
 
-import React from 'react'
+import * as React from 'react'
 import { observer } from 'mobx-react'
-import styled from 'styled-components'
+import styled, { css } from 'styled-components'
 
 import Palette from '../../styleUtils/Palette'
 import StyleProps from '../../styleUtils/StyleProps'
@@ -49,6 +49,10 @@ const Item = styled.div`
     border-bottom-right-radius: ${StyleProps.borderRadius};
     border-right: 1px solid ${Palette.primary};
   }
+  outline: none;
+  :focus {
+    ${props => !props.selected ? css`background: ${Palette.primary}44;` : ''}
+  }
 `
 
 type ItemType = { value: string, label: string }
@@ -62,6 +66,20 @@ type Props = {
 }
 @observer
 class ToggleButtonBar extends React.Component<Props> {
+  change(item: ItemType) {
+    if (this.props.onChange) {
+      this.props.onChange(item)
+    }
+  }
+
+  handleKeyPress(e: SyntheticKeyboardEvent<HTMLDivElement>, item: ItemType) {
+    if (e.key !== ' ') {
+      return
+    }
+    e.preventDefault()
+    this.change(item)
+  }
+
   render() {
     if (!this.props.items) {
       return null
@@ -79,7 +97,9 @@ class ToggleButtonBar extends React.Component<Props> {
               data-test-id={`toggleButtonBar-${item.value}`}
               key={item.value}
               selected={this.props.selectedValue === item.value}
-              onClick={() => { if (this.props.onChange) this.props.onChange(item) }}
+              onClick={() => { this.change(item) }}
+              tabIndex={0}
+              onKeyPress={e => { this.handleKeyPress(e, item) }}
             >{item.label}</Item>
           )
         })}

+ 35 - 27
src/components/molecules/AutocompleteDropdown/AutocompleteDropdown.jsx

@@ -14,14 +14,14 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 // @flow
 
-import React from 'react'
+import * as React from 'react'
 import { observer } from 'mobx-react'
 import styled, { css } from 'styled-components'
 import ReactDOM from 'react-dom'
 import autobind from 'autobind-decorator'
 
 import AutocompleteInput from '../../atoms/AutocompleteInput'
-import { Tip, updateTipStyle, scrollItemIntoView } from '../Dropdown'
+import { Tip, updateTipStyle, scrollItemIntoView, handleKeyNavigation } from '../Dropdown'
 import tipImage from '../Dropdown/images/tip'
 
 import Palette from '../../styleUtils/Palette'
@@ -79,6 +79,7 @@ const SearchNotFound = styled.div`
 const ListItem = styled.div`
   position: relative;
   color: ${props => props.selected ? 'white' : props.dim ? Palette.grayscale[3] : Palette.grayscale[4]};
+  ${props => props.arrowSelected ? css`background: ${Palette.primary}44;` : ''}
   ${props => props.selected ? css`background: ${Palette.primary};` : ''}
   ${props => props.selected ? css`font-weight: ${StyleProps.fontWeights.medium};` : ''}
   padding: 8px 16px;
@@ -133,6 +134,7 @@ type State = {
   firstItemHover: boolean,
   searchValue: string,
   filteredItems: any[],
+  arrowSelection: ?number,
 }
 @observer
 class AutocompleteDropdown extends React.Component<Props, State> {
@@ -145,6 +147,7 @@ class AutocompleteDropdown extends React.Component<Props, State> {
     firstItemHover: false,
     searchValue: '',
     filteredItems: [],
+    arrowSelection: null,
   }
 
   buttonRef: HTMLElement
@@ -164,7 +167,6 @@ class AutocompleteDropdown extends React.Component<Props, State> {
   }
 
   componentDidMount() {
-    window.addEventListener('mousedown', this.handlePageClick, false)
     if (this.buttonRef) {
       this.scrollableParent = DomUtils.getScrollableParent(this.buttonRef)
       this.scrollableParent.addEventListener('scroll', this.handleScroll)
@@ -186,7 +188,6 @@ class AutocompleteDropdown extends React.Component<Props, State> {
   }
 
   componentWillUnmount() {
-    window.removeEventListener('mousedown', this.handlePageClick, false)
     window.removeEventListener('resize', this.handleScroll, false)
     this.scrollableParent.removeEventListener('scroll', this.handleScroll, false)
   }
@@ -240,26 +241,12 @@ class AutocompleteDropdown extends React.Component<Props, State> {
     }
   }
 
-  @autobind
-  handlePageClick() {
+  closeDropdownList() {
     if (!this.itemMouseDown) {
       this.setState({ showDropdownList: false })
     }
   }
 
-  handleButtonClick() {
-    if (this.props.disabled) {
-      return
-    }
-
-    this.setState({
-      showDropdownList: !this.state.showDropdownList,
-      filteredItems: this.props.items,
-    }, () => {
-      this.scrollIntoView()
-    })
-  }
-
   handleItemClick(item: any) {
     this.setState({
       showDropdownList: false,
@@ -285,6 +272,26 @@ class AutocompleteDropdown extends React.Component<Props, State> {
     }
   }
 
+  handleInputKeyDown(e: SyntheticKeyboardEvent<HTMLInputElement>) {
+    if (!this.state.showDropdownList) {
+      return
+    }
+    handleKeyNavigation({
+      submitKeys: ['Enter'],
+      keyboardEvent: e,
+      arrowSelection: this.state.arrowSelection,
+      items: this.state.filteredItems,
+      selectedItem: this.props.selectedItem,
+      onSubmit: item => { this.handleItemClick(item) },
+      onGetValue: item => this.getValue(item),
+      onSelection: arrowSelection => {
+        this.setState({ arrowSelection }, () => {
+          this.scrollIntoView(arrowSelection)
+        })
+      },
+    })
+  }
+
   handleSearchInputChange(searchValue: string, isFocus?: boolean) {
     let filteredItems = isFocus ? this.props.items || [] : this.getFilteredItems(null, searchValue)
 
@@ -292,6 +299,7 @@ class AutocompleteDropdown extends React.Component<Props, State> {
       searchValue,
       filteredItems,
       showDropdownList: true,
+      arrowSelection: null,
     }, () => {
       if (isFocus) {
         this.scrollIntoView()
@@ -303,9 +311,11 @@ class AutocompleteDropdown extends React.Component<Props, State> {
     }
   }
 
-  scrollIntoView() {
-    let itemIndex = this.state.filteredItems.findIndex(i => this.getValue(i) === this.getValue(this.props.selectedItem))
-    scrollItemIntoView(this.listRef, this.listItemsRef, itemIndex)
+  scrollIntoView(itemIndex?: number) {
+    let selectedItemIndex = this.state.filteredItems
+      .findIndex(i => this.getValue(i) === this.getValue(this.props.selectedItem))
+    let actualItemIndex = itemIndex != null ? itemIndex : selectedItemIndex
+    scrollItemIntoView(this.listRef, this.listItemsRef, actualItemIndex)
   }
 
   updateListPosition() {
@@ -377,6 +387,7 @@ class AutocompleteDropdown extends React.Component<Props, State> {
               onClick={() => { this.handleItemClick(item) }}
               selected={value !== null && value === selectedValue}
               dim={this.props.dimNullValue && value == null}
+              arrowSelected={i === this.state.arrowSelection}
             >
               {label}
               {duplicatedLabel ? <DuplicatedLabel> (<span>{value || ''}</span>)</DuplicatedLabel> : ''}
@@ -436,24 +447,21 @@ class AutocompleteDropdown extends React.Component<Props, State> {
       <Wrapper
         data-test-id={this.props['data-test-id'] || 'acDropdown-wrapper'}
         className={this.props.className}
-        onMouseDown={() => { this.itemMouseDown = true }}
-        onMouseUp={() => { this.itemMouseDown = false }}
         width={this.props.width}
         embedded={this.props.embedded}
       >
         <AutocompleteInput
           width={this.props.width}
           innerRef={ref => { this.buttonRef = ref }}
-          onMouseDown={() => { this.itemMouseDown = true }}
-          onMouseUp={() => { this.itemMouseDown = false }}
+          onBlur={() => { this.closeDropdownList() }}
           value={inputValue}
-          onClick={() => this.handleButtonClick()}
           onChange={searchValue => { this.handleSearchInputChange(searchValue) }}
           onFocus={() => { this.handleSearchInputChange(this.state.searchValue, true) }}
           highlight={this.props.highlight}
           disabled={this.props.disabled}
           disabledLoading={this.props.disabledLoading}
           embedded={this.props.embedded}
+          onInputKeyDown={e => { this.handleInputKeyDown(e) }}
         />
         {this.props.required ? <Required right={this.props.embedded ? -24 : -16} /> : null}
         {this.renderList()}

+ 117 - 21
src/components/molecules/Dropdown/Dropdown.jsx

@@ -14,7 +14,7 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 // @flow
 
-import React from 'react'
+import * as React from 'react'
 import { observer } from 'mobx-react'
 import styled, { css } from 'styled-components'
 import ReactDOM from 'react-dom'
@@ -40,6 +40,9 @@ const getWidth = props => {
 const Wrapper = styled.div`
   position: relative;
   ${props => props.embedded ? 'width: 100%;' : ''}
+  &:focus {
+    outline: none;
+  }
 `
 const Required = styled.div`
   position: absolute;
@@ -109,6 +112,7 @@ const ListItem = styled.div`
   position: relative;
   display: flex;
   color: ${props => props.multipleSelected ? Palette.primary : props.selected ? 'white' : props.dim ? Palette.grayscale[3] : Palette.grayscale[4]};
+  ${props => props.arrowSelected ? css`background: ${Palette.primary}44;` : ''}
   ${props => props.selected ? css`background: ${Palette.primary};` : ''}
   ${props => props.selected ? css`font-weight: ${StyleProps.fontWeights.medium};` : ''}
   padding: 8px 16px;
@@ -186,6 +190,47 @@ export const scrollItemIntoView = (
   listItemsRef.children[itemIndex].parentNode.scrollTop = listItemsRef.children[itemIndex].offsetTop - listItemsRef.children[itemIndex].parentNode.offsetTop - 32
 }
 
+export const handleKeyNavigation = (options: {
+  submitKeys: string[],
+  keyboardEvent: SyntheticKeyboardEvent<HTMLInputElement | HTMLDivElement>,
+  arrowSelection: ?number,
+  items: any[],
+  selectedItem: any,
+  onSubmit: (item: any) => void,
+  onGetValue: (item: any) => any,
+  onSelection: (arrowSelection: number) => void,
+}) => {
+  let { submitKeys, keyboardEvent, arrowSelection, items, onSubmit, onGetValue, selectedItem, onSelection } = options
+  if (submitKeys.find(k => k === keyboardEvent.key)) {
+    keyboardEvent.preventDefault()
+    if (arrowSelection == null) {
+      return
+    }
+    window.handlingEnterKey = true // Needed for KeyboardManager conflict resolution
+    let arrowSelectedItem = items[arrowSelection]
+    if (arrowSelectedItem) {
+      onSubmit(arrowSelectedItem)
+    }
+    setTimeout(() => { window.handlingEnterKey = false }, 100)
+    return
+  }
+  if (keyboardEvent.key !== 'ArrowUp' && keyboardEvent.key !== 'ArrowDown') {
+    return
+  }
+  keyboardEvent.preventDefault()
+  let itemIndex = items.findIndex(i => onGetValue(i) === onGetValue(selectedItem))
+  let currentIndex = arrowSelection == null ? itemIndex : arrowSelection
+  let maxIndex = items.length - 1
+
+  if (keyboardEvent.key === 'ArrowUp') {
+    onSelection(currentIndex === 0 ? maxIndex : currentIndex - 1)
+  }
+
+  if (keyboardEvent.key === 'ArrowDown') {
+    onSelection(currentIndex === maxIndex ? 0 : currentIndex + 1)
+  }
+}
+
 type Props = {
   selectedItem: any,
   items: any[],
@@ -208,7 +253,8 @@ type Props = {
 }
 type State = {
   showDropdownList: boolean,
-  firstItemHover: boolean
+  firstItemHover: boolean,
+  arrowSelection: ?number,
 }
 @observer
 class Dropdown extends React.Component<Props, State> {
@@ -219,6 +265,7 @@ class Dropdown extends React.Component<Props, State> {
   state = {
     showDropdownList: false,
     firstItemHover: false,
+    arrowSelection: null,
   }
 
   buttonRef: HTMLElement
@@ -227,12 +274,14 @@ class Dropdown extends React.Component<Props, State> {
   firstItemRef: HTMLElement
   tipRef: HTMLElement
   scrollableParent: HTMLElement
+  wrapperRef: HTMLElement
   buttonRect: ClientRect
   itemMouseDown: boolean
+  justFocused: boolean
+  ignoreFocusHandler: boolean
   checkmarkRefs: { [string]: HTMLElement } = {}
 
   componentDidMount() {
-    window.addEventListener('mousedown', this.handlePageClick, false)
     if (this.buttonRef) {
       this.scrollableParent = DomUtils.getScrollableParent(this.buttonRef)
       this.scrollableParent.addEventListener('scroll', this.handleScroll)
@@ -264,7 +313,6 @@ class Dropdown extends React.Component<Props, State> {
   }
 
   componentWillUnmount() {
-    window.removeEventListener('mousedown', this.handlePageClick, false)
     window.removeEventListener('resize', this.handleScroll, false)
     this.scrollableParent.removeEventListener('scroll', this.handleScroll, false)
   }
@@ -307,26 +355,66 @@ class Dropdown extends React.Component<Props, State> {
     }
   }
 
-  @autobind
-  handlePageClick() {
+  toggleDropdownList(show: boolean = true) {
+    if (this.props.disabled && show) {
+      return
+    }
+
+    this.setState({ showDropdownList: show }, () => {
+      this.scrollIntoView()
+    })
+  }
+
+  handleFocus() {
+    if (this.ignoreFocusHandler || this.props.disabled || this.props.disabledLoading) {
+      return
+    }
+
+    this.justFocused = true
+    this.toggleDropdownList(true)
+    setTimeout(() => { this.justFocused = false }, 100)
+  }
+
+  handleBlur() {
     if (!this.itemMouseDown) {
       this.setState({ showDropdownList: false })
     }
   }
 
-  handleButtonClick() {
-    if (this.props.disabled) {
+  handleKeyPress(e: SyntheticKeyboardEvent<HTMLDivElement>) {
+    if (!this.state.showDropdownList) {
       return
     }
-
-    this.setState({ showDropdownList: !this.state.showDropdownList }, () => {
-      this.scrollIntoView()
+    handleKeyNavigation({
+      submitKeys: ['Enter', ' '],
+      keyboardEvent: e,
+      arrowSelection: this.state.arrowSelection,
+      items: this.props.items,
+      selectedItem: this.props.selectedItem,
+      onSubmit: item => { this.handleItemClick(item) },
+      onGetValue: item => this.getValue(item),
+      onSelection: arrowSelection => {
+        this.setState({ arrowSelection }, () => {
+          this.scrollIntoView(arrowSelection)
+        })
+      },
     })
   }
 
+  handleButtonClick() {
+    if (this.justFocused) {
+      return
+    }
+    this.toggleDropdownList(!this.state.showDropdownList)
+  }
+
   handleItemClick(item: any) {
     if (!this.props.multipleSelection) {
-      this.setState({ showDropdownList: false, firstItemHover: false })
+      this.setState({ showDropdownList: false, firstItemHover: false }, () => {
+        this.ignoreFocusHandler = true
+        this.wrapperRef.focus()
+        setTimeout(() => { this.ignoreFocusHandler = false }, 100)
+      })
     } else {
       let selected = Boolean(this.props.selectedItems && this.props.selectedItems.find(i =>
         this.getValue(i) === this.getValue(item)))
@@ -393,9 +481,11 @@ class Dropdown extends React.Component<Props, State> {
     updateTipStyle(this.listItemsRef, this.tipRef, this.firstItemRef)
   }
 
-  scrollIntoView() {
-    let itemIndex = this.props.items.findIndex(i => this.getValue(i) === this.getValue(this.props.selectedItem))
-    scrollItemIntoView(this.listRef, this.listItemsRef, itemIndex)
+  scrollIntoView(itemIndex?: number) {
+    let selectedItemIndex = this.props.items
+      .findIndex(i => this.getValue(i) === this.getValue(this.props.selectedItem))
+    let actualItemIndex = itemIndex != null ? itemIndex : selectedItemIndex
+    scrollItemIntoView(this.listRef, this.listItemsRef, actualItemIndex)
   }
 
   renderList() {
@@ -418,7 +508,10 @@ class Dropdown extends React.Component<Props, State> {
     const isFirstItemSelected = selectedValue === firstItemValue
 
     let list = ReactDOM.createPortal((
-      <List {...this.props} innerRef={ref => { this.listRef = ref }}>
+      <List
+        {...this.props}
+        innerRef={ref => { this.listRef = ref }}
+      >
         <Tip
           innerRef={ref => { this.tipRef = ref }}
           primary={this.state.firstItemHover || isFirstItemSelected}
@@ -449,6 +542,7 @@ class Dropdown extends React.Component<Props, State> {
                 multipleSelected={this.props.multipleSelection && multipleSelected}
                 dim={this.props.dimFirstItem && i === 0}
                 paddingLeft={this.props.multipleSelection ? 8 : 16}
+                arrowSelected={i === this.state.arrowSelection}
               >
                 {this.props.multipleSelection ? (
                   <Checkmark
@@ -489,19 +583,21 @@ class Dropdown extends React.Component<Props, State> {
     return (
       <Wrapper
         className={this.props.className}
-        onMouseDown={() => { this.itemMouseDown = true }}
-        onMouseUp={() => { this.itemMouseDown = false }}
         data-test-id={this.props['data-test-id'] || 'dropdown'}
         embedded={this.props.embedded}
+        tabIndex={0}
+        innerRef={ref => { this.wrapperRef = ref }}
+        onFocus={() => { this.handleFocus() }}
+        onBlur={() => { this.handleBlur() }}
+        onKeyDown={e => { this.handleKeyPress(e) }}
       >
         <DropdownButton
           {...this.props}
           data-test-id="dropdown-dropdownButton"
           innerRef={ref => { this.buttonRef = ref }}
-          onMouseDown={() => { this.itemMouseDown = true }}
-          onMouseUp={() => { this.itemMouseDown = false }}
           value={buttonValue()}
-          onClick={() => this.handleButtonClick()}
+          onClick={() => { this.handleButtonClick() }}
+          outline={this.state.showDropdownList}
         />
         {this.props.required ? (
           <Required

+ 1 - 1
src/components/molecules/FieldInput/FieldInput.jsx

@@ -290,7 +290,7 @@ class FieldInput extends React.Component<Props> {
       <RadioInput
         checked={this.props.value}
         label={LabelDictionary.get(this.props.name)}
-        onChange={e => { if (this.props.onChange) this.props.onChange(e.target.checked) }}
+        onChange={checked => { if (this.props.onChange) this.props.onChange(checked) }}
         disabled={this.props.disabled}
         disabledLoading={this.props.disabledLoading}
       />

+ 12 - 2
src/components/organisms/WizardInstances/WizardInstances.jsx

@@ -65,6 +65,10 @@ const InstanceContent = styled.div`
 const CheckboxStyled = styled(Checkbox)`
   opacity: 0;
   transition: all ${StyleProps.animations.swift};
+
+  :focus {
+    opacity: 1;
+  }
 `
 const Instance = styled.div`
   display: flex;
@@ -174,6 +178,7 @@ class WizardInstances extends React.Component<Props, State> {
     searchText: '',
   }
   timeout: TimeoutID
+  isCheckboxMouseDown: boolean
 
   componentWillUnmount() {
     this.props.onSearchInputChange('')
@@ -294,11 +299,16 @@ class WizardInstances extends React.Component<Props, State> {
           return (
             <Instance
               key={instance.id}
-              onClick={() => { this.props.onInstanceClick(instance) }}
+              onMouseDown={() => { if (!this.isCheckboxMouseDown) this.props.onInstanceClick(instance) }}
               selected={selected}
               data-test-id={`wInstances-item-${instance.id}`}
             >
-              <CheckboxStyled checked={selected} onChange={() => { }} />
+              <CheckboxStyled
+                checked={selected}
+                onChange={() => { this.props.onInstanceClick(instance) }}
+                onMouseDown={() => { this.isCheckboxMouseDown = true }}
+                onMouseUp={() => { this.isCheckboxMouseDown = false }}
+              />
               <InstanceContent data-test-id="wInstances-instanceItem">
                 <Image />
                 <Label data-test-id="wInstances-itemName">{instance.instance_name || instance.name}</Label>

+ 24 - 6
src/components/organisms/WizardOptions/story.jsx

@@ -48,6 +48,21 @@ let fields = [
     name: 'set_dhcp',
     type: 'boolean',
   },
+  {
+    name: 'enum_field',
+    type: 'string',
+    enum: ['enum 1', 'enum 2', 'enum 3'],
+  },
+  {
+    name: 'long list',
+    type: 'string',
+    enum: ['enum 1', 'enum 2', 'enum 3', 'enum 4', 'enum 5', 'enum 6', 'enum 7', 'enum 8'],
+  },
+  {
+    name: 'enum_field_autocomplete',
+    type: 'string',
+    enum: ['enum 1', 'enum 2', 'enum 3', 'enum 4', 'enum 5', 'enum 6', 'enum 7', 'enum 8', 'enum 9', 'enum 10'],
+  },
   {
     name: 'string_field_with_default',
     type: 'string',
@@ -58,11 +73,6 @@ let fields = [
     name: 'required_string_field',
     type: 'string',
   },
-  {
-    name: 'enum_field',
-    type: 'string',
-    enum: ['enum 1', 'enum 2', 'enum 3'],
-  },
   {
     name: 'boolean_field',
     type: 'boolean',
@@ -95,7 +105,7 @@ class Wrapper extends React.Component {
 
   render() {
     return (
-      <div style={{ width: '1000px', display: 'flex', justifyContent: 'center' }}>
+      <div style={{ width: '1024px', display: 'flex', justifyContent: 'center' }}>
         <WizardOptions
           {...this.props}
           data={this.state.data}
@@ -129,3 +139,11 @@ storiesOf('WizardOptions', module)
       selectedInstances={[{}, {}]}
     />
   ))
+  .add('loading', () => (
+    <Wrapper
+      fields={fields}
+      selectedInstances={[]}
+      wizardType="replica"
+      optionsLoading
+    />
+  ))

+ 1 - 1
src/utils/KeyboardManager.js

@@ -21,7 +21,7 @@ const keyDownHandler = evt => {
   listeners.forEach(l => { maxPriority = Math.max(l.priority, maxPriority) })
   let prioritizedListeners = listeners.filter(l => l.priority === maxPriority)
   prioritizedListeners.forEach(listener => {
-    if (listener.callback) listener.callback(evt)
+    if (listener.callback && !window.handlingEnterKey) listener.callback(evt)
   })
 }
 export default class KeyboardManager {