Skip to content
This repository has been archived by the owner on Jan 5, 2021. It is now read-only.

Commit

Permalink
Salesforce-UX sortable list box
Browse files Browse the repository at this point in the history
  • Loading branch information
awead committed Dec 7, 2017
1 parent 415a2ea commit d1e0c73
Show file tree
Hide file tree
Showing 7 changed files with 496 additions and 0 deletions.
341 changes: 341 additions & 0 deletions app/javascript/list_box/Listbox.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,341 @@
// Copyright (c) 2015-present, salesforce.com, inc. All rights reserved
// Licensed under BSD 3-Clause - see LICENSE.txt or git.io/sfdc-license

import React, { Component } from 'react';
import classnames from 'classnames';
import PropTypes from 'prop-types';

const proptypes = {
/** @type {string} is value for the aria-label*/
ariaLabel: PropTypes.string,
/** @type {bool} specifies if the list allows drag and drop*/
hasDragDrop: PropTypes.bool,
/** @type {bool} specifies if the list allows multi select*/
hasMulti: PropTypes.bool,
/** @type {string} is the name for the sizing class for horizontal list options*/
horizontalClass: PropTypes.string,
/** @type {bool} specifies if the list is displayed horizontally*/
isHorizontal: PropTypes.bool
}

/**
* A list of listbox options
* @name Listbox
*/
class Listbox extends Component {
constructor() {
super();
this.state = {
ariaLiveText: '',
focusedOption: 0,
inDragdropMode: false,
listOptions: null,
selectedOptions: [0]
};
this.handleClick = this.handleClick.bind(this);
this.handleDrag = this.handleDrag.bind(this);
this.handleDragOver = this.handleDragOver.bind(this);
this.handleDrop = this.handleDrop.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
}

componentDidMount() {
this.setState({ listOptions: this.props.children });
}

/**
* Returns the new option based on the direction of movement and the current option
* @props {integer} option - the current option
* @props {boolean} moveNext - specifies the direction the option moved
*/
findNewOption(option, moveNext) {
var newOption = option;
if (option < (this.props.children.length - 1) && moveNext) { newOption += 1; }
else if (option === this.props.children.length - 1 && moveNext) { newOption = 0; }
else if (option > 0 && !moveNext) { newOption -= 1; }
else if (option === 0 && !moveNext) { newOption = this.props.children.length - 1; }
return newOption;
}

handleClick(event) {
let option = parseInt(event.target.id, 10);
event.preventDefault();
if (this.props.hasMulti && event.shiftKey) {
var updatedSelected = [];
var rangeSelected = [];
var i = 0;
var start = this.state.selectedOptions[this.state.selectedOptions.length - 1];
if (option > start) {
for (i = start; i <= option; i++) { rangeSelected.push(i); }
} else {
for (i = option; i <= start; i++) { rangeSelected.push(i); }
}
for (i = 0; i < rangeSelected.length; i++) {
updatedSelected = this.updateArray(rangeSelected[i], updatedSelected);
}
} else if (this.props.hasMulti && (event.ctrlKey || event.metaKey)) {
updatedSelected = this.updateArray(option, this.state.selectedOptions);
} else {
updatedSelected = [option];
}
this.setState({
focusedOption: option,
selectedOptions: updatedSelected
});
}

handleDrag(event) {
event.preventDefault();
var index = parseInt(event.target.id, 10);
this.setState({
focusedOption: index,
selectedOptions: [index]
});
}

/**
* Handles keyboard events for drag and drop listboxes
* @props {event} event - the keydown event
*/
handleDragDropKeyDown(event) {
var currentOption = parseInt(event.target.id, 10);
var ariaLiveText, startIndex, grabbedOptionName;
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
if (this.state.inDragdropMode) {
event.preventDefault();
let moveNext;
if (event.key === 'ArrowDown') { moveNext = true; }
else { moveNext = false; }
startIndex = this.state.selectedOptions[0];
var newOption = this.findNewOption(currentOption, moveNext);
this.handleDragStateChange(startIndex, newOption);
grabbedOptionName = this.state.listOptions[startIndex].props.name;
ariaLiveText = this.updateAssistiveText(grabbedOptionName, newOption, false, true);
this.setState({ariaLiveText});
} else {
this.handleSingleSelectKeyDown(event);
}
} else if (event.key === ' ') {
event.preventDefault();
grabbedOptionName = this.state.listOptions[currentOption].props.name;
if (this.state.inDragdropMode) {
startIndex = this.state.selectedOptions[0];
var endIndex = this.state.focusedOption;
this.handleDragStateChange(startIndex, endIndex);
grabbedOptionName = this.state.listOptions[startIndex].props.name;
}
ariaLiveText = this.updateAssistiveText(grabbedOptionName, this.state.focusedOption, this.state.inDragdropMode, false);
this.setState( prevState => ({
ariaLiveText,
inDragdropMode: !prevState.inDragdropMode
}));
}
}

/** Handles the drag over event, necessary because it makes the event target a valid drop area */
handleDragOver(event) {
event.preventDefault();
}

/**
* Changes the elements and selected states of the list when drop an element
* @props {integer} startIndex - the original location of the element
* @props {integer} endIndex - the new location of the element
*/
handleDragStateChange(startIndex, endIndex) {
var options = this.state.listOptions.slice();
var option = this.state.listOptions[startIndex];
options.splice(startIndex, 1);
options.splice(endIndex, 0, option);
var selected = [endIndex];
this.setState({
focusedOption: endIndex,
listOptions: options,
selectedOptions: selected
});
}

handleDrop(event) {
event.preventDefault();
var startIndex = this.state.selectedOptions[0];
var endIndex = parseInt(event.target.id, 10);
this.handleDragStateChange(startIndex, endIndex);
}

handleKeyDown(event) {
if (this.props.hasMulti) {
this.handleMultiSelectKeyDown(event);
} else if (this.props.hasDragDrop) {
this.handleDragDropKeyDown(event);
} else {
this.handleSingleSelectKeyDown(event);
}
}

/**
* Handles keyboard events for multi select listboxes
* @props {event} event - the keydown event
*/
handleMultiSelectKeyDown(event) {
var currentOption = parseInt(event.target.id, 10);
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
let moveNext;
if (event.key === 'ArrowDown') { moveNext = true; }
else { moveNext = false; }
var newOption = this.findNewOption(currentOption, moveNext);

if (event.shiftKey) {
var selectedOptions = this.updateArray(newOption, this.state.selectedOptions);
this.setState({
focusedOption: newOption,
selectedOptions: selectedOptions
});
} else if (event.ctrlKey || event.metaKey) {
this.setState({ focusedOption: newOption });
} else {
this.handleSingleSelectKeyDown(event);
}
} else if (event.key === ' ' && event.ctrlKey) {
event.preventDefault();
var updatedSelected = this.updateArray(currentOption, this.state.selectedOptions);
this.setState({ selectedOptions: updatedSelected });
} else if (event.key === 'a' && event.ctrlKey) {
this.handleSelectAll();
}
}

/**
* Selects/Deselects all the options based on what is currently selected
*/
handleSelectAll() {
var selectedOptions = [0, 1, 2, 3];
var ariaLiveText = 'Selected all elements';
if (this.state.selectedOptions.length === 4) {
selectedOptions = [this.state.focusedOption];
ariaLiveText = 'Deselected all elements';
}
this.setState({
selectedOptions,
ariaLiveText
});
}

/**
* Handles keyboard events for single select listboxes
* @props {event} event - the keydown event
*/
handleSingleSelectKeyDown(event) {
let currentOption = parseInt(event.target.id, 10);
if (event.key === 'ArrowDown' || (event.key === 'ArrowRight' && this.props.isHorizontal)) {
event.preventDefault();
let newOption = this.findNewOption(currentOption, true);
this.setState({
focusedOption: newOption,
selectedOptions: [newOption],
ariaLiveText: null
});
} else if (event.key === 'ArrowUp' || (event.key === 'ArrowLeft' && this.props.isHorizontal)) {
event.preventDefault();
let newOption = this.findNewOption(currentOption, false);
this.setState({
focusedOption: newOption,
selectedOptions: [newOption],
ariaLiveText: null
});
}
}

/**
* Clones the children elements and sets their props to the correct values based on the Listbox's state.
* The order they are displayed is based on the state 'listOptions'
*/
renderListboxOptions() {
return React.Children.map(this.state.listOptions, (child, i) => {
return React.cloneElement(child, {
horizontalClass: this.props.horizontalClass,
id: i.toString(),
isDraggable: this.props.hasDragDrop,
isFocused: (this.state.focusedOption === i ? true : false),
isHorizontal: this.props.isHorizontal,
isSelected: (this.state.selectedOptions.indexOf(i) > -1 ? true : false),
onClick: this.handleClick,
onDrag: this.handleDrag,
onDragOver: this.handleDragOver,
onDrop: this.handleDrop
});
});
}

/**
* Returns a new array that either removes or adds an element
* @props {integer} element - element changed in the array
* @props {array} arr - original array
*/
updateArray(element, arr) {
var newArr = arr.slice();
if (arr.indexOf(element) === -1) {
newArr.push(element);
} else {
newArr.splice(arr.indexOf(element), 1);
}
return newArr;
}

/**
* Returns a new string for the aria-live div for drag and drop listboxes
* @props {array} element - the name of the selected element
* @props {integer} index - index of where the elements are
* @props {boolean} drop - specifies if the element is being dropped into its new location
* @props {boolean} move - specifies if the element is being moved
*/
updateAssistiveText(element, index, drop, move) {
var updatedString = element;
if (!drop && !move) { updatedString += ' grabbed, current '; }
if (!drop && move) { updatedString += ' moved, new '; }
if (drop) { updatedString += ' dropped, final '; }
updatedString += 'position ' + (index + 1) + ' of ' + this.props.children.length;
return updatedString;
}

render() {
let AssistiveText = () =>
<div>
{this.props.hasDragDrop ?
<div id="instructions" className="slds-assistive-text">
Press space bar to toggle drag drop mode, use arrow keys to move selected elements.
</div> : null
}
{this.props.hasDragDrop || this.props.hasMulti ?
<div aria-live="assertive" className="slds-assistive-text assistiveText">
{this.state.ariaLiveText}
</div> : null
}
</div>;

return(
<div>
{this.props.hasDragDrop || this.props.hasMulti ?
<AssistiveText /> : null
}
<ul
aria-label={this.props.ariaLabel}
aria-multiselectable={this.props.hasMulti ? true : null}
className={classnames("slds-border_top", "slds-border_right", "slds-border_bottom", "slds-border_left",
{
"slds-list_horizontal": this.props.isHorizontal
}
)}
onKeyDown={this.handleKeyDown}
role="listbox"
>
{this.renderListboxOptions()}
</ul>
</div>
);
}
}

Listbox.propTypes = proptypes;

export default Listbox;

0 comments on commit d1e0c73

Please sign in to comment.