Просмотр исходного кода

Merge pull request #508 from smiclea/keyboard-navigation

Add fields keyboard navigation support
Nashwan Azhari 6 лет назад
Родитель
Сommit
c0663caa5c

+ 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'
@@ -80,6 +80,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;
@@ -134,6 +135,7 @@ type State = {
   firstItemHover: boolean,
   searchValue: string,
   filteredItems: any[],
+  arrowSelection: ?number,
 }
 @observer
 class AutocompleteDropdown extends React.Component<Props, State> {
@@ -146,6 +148,7 @@ class AutocompleteDropdown extends React.Component<Props, State> {
     firstItemHover: false,
     searchValue: '',
     filteredItems: [],
+    arrowSelection: null,
   }
 
   buttonRef: HTMLElement
@@ -165,7 +168,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)
@@ -187,7 +189,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)
   }
@@ -241,26 +242,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,
@@ -286,6 +273,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)
 
@@ -293,6 +300,7 @@ class AutocompleteDropdown extends React.Component<Props, State> {
       searchValue,
       filteredItems,
       showDropdownList: true,
+      arrowSelection: null,
     }, () => {
       if (isFocus) {
         this.scrollIntoView()
@@ -304,9 +312,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() {
@@ -378,6 +388,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> : ''}
@@ -437,24 +448,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;
@@ -110,6 +113,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;
@@ -187,6 +191,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[],
@@ -209,7 +254,8 @@ type Props = {
 }
 type State = {
   showDropdownList: boolean,
-  firstItemHover: boolean
+  firstItemHover: boolean,
+  arrowSelection: ?number,
 }
 @observer
 class Dropdown extends React.Component<Props, State> {
@@ -220,6 +266,7 @@ class Dropdown extends React.Component<Props, State> {
   state = {
     showDropdownList: false,
     firstItemHover: false,
+    arrowSelection: null,
   }
 
   buttonRef: HTMLElement
@@ -228,12 +275,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)
@@ -265,7 +314,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)
   }
@@ -308,26 +356,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)))
@@ -394,9 +482,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() {
@@ -419,7 +509,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}
@@ -450,6 +543,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
@@ -490,19 +584,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 {