jgwhite/ember-sortable

View on GitHub
addon/src/modifiers/sortable-group.js

Summary

Maintainability
D
1 day
Test Coverage
/* eslint-disable ember/no-computed-properties-in-native-classes */
/* eslint-disable ember/no-incorrect-calls-with-inline-anonymous-functions */
import Modifier from 'ember-modifier';
import { action, computed, set } from '@ember/object';
import {
  isDownArrowKey,
  isEnterKey,
  isEscapeKey,
  isLeftArrowKey,
  isRightArrowKey,
  isSpaceKey,
  isUpArrowKey,
} from '../utils/keyboard';
import { ANNOUNCEMENT_ACTION_TYPES } from '../utils/constant';
import { defaultA11yAnnouncementConfig } from '../utils/defaults';
import { next, schedule, scheduleOnce, later } from '@ember/runloop';
import { inject as service } from '@ember/service';
import { registerDestructor, isDestroyed } from '@ember/destroyable';

const NO_MODEL = {};

/**
 * Modifier to apply a11y support to a group container for the Sortable component
 *
 * @param {String} [a11yItemName] A name for each model, used for creating more meaningful a11y announcements.
 * @param {Object} [a11yAnnouncementConfig] A map of action to function to build meaningful a11y announcements.
 * @param {String} [itemVisualClass] A class for styling visual indicators on the yielded `sortable-item`.
 * @param {Object} [handleVisualClass] An object for styling visual indicators on the yielded `sortable-handle` on different `move`.
 * @param {Function} [onChange] An optional callback for when position rearrangements are confirmed.
 *
 * @module drag-drop/draggable-group
 * @example
 *    <ol {{sortable-group onChange=this.update a11yAnnouncementConfig=this.myA11yConfig}}>
 *      {{#each model.items as |item|}}
 *        <li {{sortable-item model=item}}>
 *          {{item.name}}
 *          <span class="handle" {{sortable-handle}}>&varr;</span>
 *        </li>
 *      {{/each}}
 *    </ol>
 */
export default class SortableGroupModifier extends Modifier {
  /** Primary keyboard utils */
  // Tracks the currently selected item
  _selectedItem = null;
  // Tracks the current move
  move = null;
  moves = [];

  // Tracks the status of keyboard reorder mode
  isKeyboardReorderModeEnabled = false;

  isKeyDownEnabled = false;

  // Tracks if we're still performing a programmatic focus.
  isRetainingFocus = false;
  /** End of keyboard utils */

  get disabled() {
    return this.named.disabled || false;
  }

  /** Start of a11y properties */
  /**
   * @property an object containing different classes for visual indicators
   * @type {Object}
   * @default null
   * @example
   * {
   *  UP: 'up'
   *  DOWN: 'down',
   *  LEFT: 'left',
   *  RIGHT: 'right',
   * }
   */
  get handleVisualClass() {
    return this.named.handleVisualClass || NO_MODEL;
  }

  /**
   * @property an object containing functions for producing screen reader announcements
   * @type {Object}
   * @example
   * {
   *  MOVE: function() {},
   *  ACTIVATE: function() {},
   *  CONFIRM: function() {},
   *  CANCEL: function() {},
   * }
   */
  get a11yAnnouncementConfig() {
    return this.named.a11yAnnouncementConfig || defaultA11yAnnouncementConfig;
  }

  get itemVisualClass() {
    return this.named.itemVisualClass || 'is-activated';
  }

  get a11yItemName() {
    return this.named.a11yItemName || 'item';
  }
  /** End of a11y properties */

  /**
   * Make sure that we cancel any ongoing keyboard operation when the focus is lost from the handle.
   * Because this can be fired pre-maturely, effectively cancelling before other keyboard operations,
   * we need to wait until other operations are completed, so this will cancel properly.
   *
   * @param {Event} event a DOM event.
   */
  @action
  focusOut() {
    if (!this.isRetainingFocus && !this._isElementWithinHandle(document.activeElement)) {
      this.cancelKeyboardSelection();
    }
  }

  /**
   * Explanation
   * 1. `KeyboardReorderMode` is disabled: users can activate it via ENTER or SPACE.
   * 2. `KeyboardReorderMode` is enabled: users can reorder via UP or DOWN arrow keys. TODO: Expand to more keys, e.g LEFT, RIGHT
   * 3. `KeyboardReorderMode` is enabled: users can finalize/save the reordering via ENTER or SPACE.
   * 4. `KeyboardReorderMode` is enabled: users can discard the reordering via ESC.
   *
   * @param {Event} event a DOM event
   */
  @action
  keyDown(event) {
    if (!this.isKeyDownEnabled) {
      return;
    }

    // Note: If handle is specified, we need to target the keyDown on the handle
    const isKeyboardReorderModeEnabled = this.isKeyboardReorderModeEnabled;
    const _selectedItem = this._selectedItem;

    if (!isKeyboardReorderModeEnabled && (isEnterKey(event) || isSpaceKey(event))) {
      this._prepareKeyboardReorderMode();
      this._announceAction(ANNOUNCEMENT_ACTION_TYPES.ACTIVATE);
      this._updateItemVisualIndicators(_selectedItem, true);
      this._updateHandleVisualIndicators(_selectedItem, true);

      this.isRetainingFocus = true;

      scheduleOnce('render', () => {
        this.element.focus();
        this.isRetainingFocus = false;
      });

      // Prevent the default scroll
      event.preventDefault();
      return;
    }

    if (isKeyboardReorderModeEnabled) {
      this._handleKeyboardReorder(event);
      event.preventDefault();
    }
  }

  /**
   * Checks if the given element is a child of a handle.
   *
   * @param {Element} element a DOM element.
   */
  _isElementWithinHandle(element) {
    return element.closest(`[data-sortable-handle]`);
  }

  /**
   * Moves an sortedItem from one index to another index, effectively performing an reorder.
   *
   * @param {Integer} fromIndex the original index
   * @param {Integer} toIndex the new index
   */
  _move(fromIndex, toIndex) {
    const direction = this.direction;
    const sortedItems = this.sortedItems;
    const item = sortedItems[fromIndex];
    const nextItem = sortedItems[toIndex];

    // switch direction values to notify sortedItems to update, so it sorts by direction.
    let value;
    const dimension = direction === 'y' ? 'height' : 'width';
    // DOWN or RIGHT
    if (toIndex > fromIndex) {
      value = item[direction];
      set(item, direction, nextItem[direction] + (nextItem[dimension] - item[dimension]));
      set(nextItem, direction, value);
      // UP or LEFT
    } else {
      value = nextItem[direction];
      set(nextItem, direction, item[direction] + (item[dimension] - nextItem[dimension]));
      set(item, direction, value);
    }
  }

  /**
   * Handles all of the keyboard operations, such as
   * 1. Keyboard navigation for UP, DOWN, LEFT, RIGHT
   * 2. Confirming reorder
   * 3. Discard reorder
   * 4. Also handles refocusing the element that triggered the interaction.
   *
   * @param {Event} event a DOM event.
   */
  _handleKeyboardReorder(event) {
    const direction = this.direction;
    const selectedItem = this._selectedItem;

    if (direction === 'y' && isDownArrowKey(event)) {
      this.moveItem(selectedItem, 1);
    } else if (direction === 'y' && isUpArrowKey(event)) {
      this.moveItem(selectedItem, -1);
    } else if (direction === 'x' && isLeftArrowKey(event)) {
      this.moveItem(selectedItem, -1);
    } else if (direction === 'x' && isRightArrowKey(event)) {
      this.moveItem(selectedItem, 1);
    } else if (isEnterKey(event) || isSpaceKey(event)) {
      // confirm will reset the selectedItem, so caching it here before we remove it.
      const itemElement = selectedItem.element;
      this._announceAction(ANNOUNCEMENT_ACTION_TYPES.CONFIRM);
      this.confirmKeyboardSelection();

      this.isRetainingFocus = true;
      scheduleOnce('render', () => this._focusItem(itemElement));
    } else if (isEscapeKey(event)) {
      // cancel will reset the selectedItem, so caching it here before we remove it.
      const _selectedItemElement = selectedItem.element;
      this._announceAction(ANNOUNCEMENT_ACTION_TYPES.CANCEL);
      this.cancelKeyboardSelection();

      this.isRetainingFocus = true;
      scheduleOnce('render', () => {
        const moves = this.moves;
        if (moves && moves.length > 0) {
          const sortedItems = this.sortedItems;
          const itemElement = sortedItems[moves[0].fromIndex].element;
          this._focusItem(itemElement);
        } else {
          this._focusItem(_selectedItemElement);
        }
        this.isRetainingFocus = false;
      });
    }
  }

  /**
   * Moves the item to its new position and adds the move to our history.
   *
   * @param {SortableItemModifier} item the item to be moved.
   * @param {Integer} delta how much to move index-wise.
   */
  moveItem(item, delta) {
    const sortedItems = this.sortedItems;
    const moves = this.moves;

    const sortedIndex = sortedItems.indexOf(item);
    const newSortedIndex = sortedIndex + delta;

    // If out of bounds, we don't do anything.
    if (newSortedIndex < 0 || newSortedIndex >= sortedItems.length) {
      return;
    }
    this._announceAction(ANNOUNCEMENT_ACTION_TYPES.MOVE, delta);
    // Guarantees that the before the UI is fully rendered before we move again.
    scheduleOnce('render', () => {
      this._move(sortedIndex, newSortedIndex);
      this._updateHandleVisualIndicators(item, true);

      moves.push([sortedIndex, newSortedIndex]);
    });
  }

  /**
   * Handles all the necessary operations needed for cancelling the current keyboard selection.
   * 1. Disables keyboard reorder mode.
   * 2. Undo all of the tracked moves.
   * 3. Tears down the application container, so we are not focus locked within the application.
   * 4. Resets the current selected item.
   */
  @action
  cancelKeyboardSelection() {
    const _selectedItem = this._selectedItem;
    this._disableKeyboardReorderMode();
    // Revert the process by reversing the move.
    const moves = this.moves;
    while (moves.length > 0) {
      const move = moves.pop();
      this._move(move[1], move[0]);
    }
    this._tearDownA11yApplicationContainer();
    this._updateItemVisualIndicators(_selectedItem, false);
    this._updateHandleVisualIndicators(_selectedItem, false);
    this._resetItemSelection();
  }

  /**
   * Handles all th necessary operations needed for confirming the current keyboard selection.
   * 1. Disables keyboard reorder mode.
   * 2. Tears down the application container, so we are not focus locked within the container.
   * 3. Make sure to update and sync all the internal items and UI.
   * 4. Triggers the `onChange` action if provided.
   * 5. Resets the currently selected item.
   */
  confirmKeyboardSelection() {
    const _selectedItem = this._selectedItem;
    this.moves = [];
    this._disableKeyboardReorderMode();
    this._tearDownA11yApplicationContainer();
    set(_selectedItem, 'wasDropped', true);
    this.commit();
    this._updateItemVisualIndicators(_selectedItem, false);
    this._updateHandleVisualIndicators(_selectedItem, false);
    this._resetItemSelection();
  }

  /**
   * Announces the message constructed from `a11yAnnouncementConfig`.
   *
   * @param {String} type the action type.
   * @param {Number} delta how much distance (item-wise) is being moved.
   */
  _announceAction(type, delta = null) {
    const a11yAnnouncementConfig = this.a11yAnnouncementConfig;
    const a11yItemName = this.a11yItemName;

    if (!a11yItemName || !(type in a11yAnnouncementConfig)) {
      return;
    }

    const sortedItems = this.sortedItems;
    const _selectedItem = this._selectedItem;
    const index = sortedItems.indexOf(_selectedItem);
    const announcer = this.announcer;

    const config = {
      a11yItemName,
      index: index,
      maxLength: sortedItems.length,
      direction: this.direction,
      delta,
    };

    const message = a11yAnnouncementConfig[type](config);
    announcer.textContent = message;

    // Reset the message after the message is announced.
    later(() => {
      announcer.textContent = '';
    }, 1000);
  }

  /**
   * Reset the selected item.
   */
  _resetItemSelection() {
    this._selectedItem = null;
  }

  /**
   * Updates the selected item's visual indicators.
   *
   * @param {SortableItemModifier} item the selected item.
   * @param {Boolean} isActive to activate or deactivate the class.
   */
  _updateItemVisualIndicators(item, isActive) {
    const itemVisualClass = this.itemVisualClass;

    if (!itemVisualClass || !item) {
      return;
    }

    if (isActive) {
      item.element.classList.add(itemVisualClass);
    } else {
      item.element.classList.remove(itemVisualClass);
    }
  }

  /**
   * Updates the selected item's handle's visual indicators
   *
   * @param {SortableItemModifier} item the selected item.
   * @param {boolean} isUpdate to update or not update.
   */
  _updateHandleVisualIndicators(item, isUpdate) {
    const handleVisualClass = this.handleVisualClass;

    if (handleVisualClass === NO_MODEL || !item) {
      return;
    }

    const sortedItems = this.sortedItems;
    const direction = this.direction;
    const index = sortedItems.indexOf(item);
    const handle = item.element.querySelector('[data-sortable-handle');
    const visualHandle = handle ? handle : item.element;
    const visualKeys = direction === 'y' ? ['UP', 'DOWN'] : ['LEFT', 'RIGHT'];

    visualKeys.forEach((visualKey) => {
      visualHandle.classList.remove(handleVisualClass[visualKey]);
    });

    if (!isUpdate) {
      return;
    }

    if (index > 0) {
      visualHandle.classList.add(handleVisualClass[visualKeys[0]]);
    }

    if (index < sortedItems.length - 1) {
      visualHandle.classList.add(handleVisualClass[visualKeys[1]]);
    }
  }

  /**
   * Sets focus on the current item or its handle.
   *
   * @param {Element} itemElement an DOM element representing an sortable-item.
   */
  _focusItem(itemElement) {
    const handle = itemElement.querySelector('[data-sortable-handle]');
    if (handle) {
      handle.focus();
    } else {
      // The consumer did not use a handle, so we set focus back to the item.
      itemElement.focus();
    }
  }

  /**
   * Enables keyboard reorder mode.
   */
  _enableKeyboardReorderMode() {
    this.isKeyboardReorderModeEnabled = true;
  }

  /**
   * Disables keyboard reorder mode
   */
  _disableKeyboardReorderMode() {
    this.isKeyboardReorderModeEnabled = false;
  }

  /**
   * Sets up the group as an application and make it programmatically focusable.
   */
  _setupA11yApplicationContainer() {
    this.element.setAttribute('role', 'application');
    this.element.tabIndex = -1;
  }

  /**
   * Tears down the `role=application` container.
   */
  _tearDownA11yApplicationContainer() {
    this.element.removeAttribute('role');
    this.element.removeAttribute('tabIndex');
  }

  _prepareKeyboardReorderMode() {
    this._enableKeyboardReorderMode();
    this._setupA11yApplicationContainer();
  }

  // Begin of API

  /**
   @property direction
   @type string
   @default y
   */
  get direction() {
    return this.named.direction || 'y';
  }

  /**
   Called when order of items has been changed
   @property onChange
   @type Function
   @param {Object} groupModel group model (omitted if not set)
   @param {Object[]} newModel models in their new order
   @param {Object} itemModel model just dragged
   @default null
   */
  get onChange() {
    return this.named.onChange;
  }

  @service('ember-sortable-internal-state')
  sortableService;

  /**
   * This is the group name used to keep groups separate if there are more than one on the screen at a time.
   * If no group is assigned a default is used
   *
   * @default "_EmberSortableGroup"
   * @returns {*|string}
   */
  get groupName() {
    return this.named.groupName || '_EmberSortableGroup';
  }

  /**
   This is an array of SortableItemModifiers

   @property items
   @type SortableItemModifier[]
   */
  get items() {
    return this._groupDef.items;
  }
  set(items) {
    this._groupDef.items = items;
  }

  /**
   * Announcer element
   *
   * @type {Element}
   */
  announcer = null;

  /**
   Position for the first item.
   If spacing is present, first item's position will have to change as well.
   @property firstItemPosition
   @type Number
   */
  @computed('direction', 'sortedItems')
  get firstItemPosition() {
    const direction = this.direction;
    const sortedItems = this.sortedItems;

    return sortedItems[0][`${direction}`] - sortedItems[0].spacing;
  }

  /**
   An array of DOM elements.
   @property sortedItems
   @type SortableItemModifier[]
   */
  get sortedItems() {
    const direction = this.direction;
    return this.items.sort((a, b) => a[direction] - b[direction]);
  }

  /**
   * Enables keyboard navigation
   */
  @action
  activateKeyDown(selectedItem) {
    this._selectedItem = selectedItem;
    this.isKeyDownEnabled = true;
  }

  /**
   * Disables keyboard navigation
   * Currently used to handle keydown events bubbling up from
   * elements that aren't meant to invoke keyboard navigation
   * by ignoring them.
   */
  @action
  deactivateKeyDown() {
    this.isKeyDownEnabled = false;
  }

  /**
   Register the group with this Sortable.
   @method registerGroup
   @param {SortableGroupModifier} group
   */
  @action
  registerGroup(group) {
    this._group = group;
  }

  /**
   De-register the group with this Sortable.
   @method deregisterGroup
   @param {SortableGroupModifier} group
   */
  @action
  deregisterGroup(group) {
    if (this._group === group) {
      this._group = null;
    }
  }

  /**
   Prepare for sorting.
   Main purpose is to stash the current firstItemPosition so
   we don’t incur expensive re-layouts.
   @method _prepare
   */
  @action
  prepare() {
    this._firstItemPosition = this.firstItemPosition;
  }

  /**
   Update item positions (relatively to the first element position).
   @method update
   */
  @action
  update() {
    let sortedItems = this.sortedItems;
    // Position of the first element
    let position = this._firstItemPosition;

    // Just in case we haven’t called prepare first.
    if (position === undefined) {
      position = this.firstItemPosition;
    }

    sortedItems.forEach((item) => {
      let dimension;
      let direction = this.direction;

      if (!isDestroyed(item) && !item.isDragging) {
        set(item, direction, position);
      }

      // add additional spacing around active element
      if (item.isBusy) {
        position += item.spacing * 2;
      }

      if (direction === 'x') {
        dimension = 'width';
      }
      if (direction === 'y') {
        dimension = 'height';
      }

      position += item[dimension];
    });
  }

  /**
   @method _commit
   */
  @action
  commit() {
    const items = this.sortedItems;
    const itemModels = items.map((item) => item.model);
    const draggedItem = items.find((item) => item.wasDropped);
    let draggedModel;

    if (draggedItem) {
      draggedItem.wasDropped = false; // Reset
      draggedModel = draggedItem.model;
    }

    this._updateItems();
    this._onChange(itemModels, draggedModel);
  }

  @action
  _onChange(itemModels, draggedModel) {
    if (this.onChange) {
      this.onChange(itemModels, draggedModel);
    }
  }

  /**
   * Keeps the UI in sync with actual changes.
   * Needed for drag and keyboard operations.
   */
  _updateItems() {
    const items = this.sortedItems;

    delete this._firstItemPosition;

    schedule('render', () => {
      items.forEach((item) => item.freeze());
    });

    schedule('afterRender', () => {
      items.forEach((item) => item.reset());
    });

    next(() => {
      schedule('render', () => {
        items.forEach((item) => item.thaw());
      });
    });
  }

  @action
  _createAnnouncer() {
    const announcer = document.createElement('span');
    announcer.setAttribute('aria-live', 'polite');
    announcer.classList.add('visually-hidden');
    return announcer;
  }

  // end of API

  addEventListener() {
    this.element.addEventListener('keydown', this.keyDown);
    this.element.addEventListener('focusout', this.focusOut);
  }

  removeEventListener() {
    this.element.removeEventListener('keydown', this.keyDown);
    this.element.removeEventListener('focusout', this.focusOut);
  }

  element;
  didSetup = false;

  constructor(owner, args) {
    super(owner, args);
    registerDestructor(this, cleanup);
  }

  modify(element, _positional, named) {
    this.element = element;
    this.named = named;

    this.removeEventListener();

    if (!this.didSetup) {
      this._groupDef = this.sortableService.fetchGroup(this.groupName);
      this.announcer = this._createAnnouncer();
      this.element.insertAdjacentElement('afterend', this.announcer);
      this.sortableService.registerGroup(this.groupName, this);

      this.didSetup = true;
    }

    if (this.disabled) {
      return;
    }

    this.addEventListener();
  }
}

/**
 *
 * @param {SortableGroupModifier} instance
 */
function cleanup(instance) {
  // todo cleanup the announcer
  if (instance.announcer.parentNode) {
    instance.announcer.parentNode.removeChild(instance.announcer);
  }
  instance.removeEventListener();
  instance.sortableService.deregisterGroup(instance.groupName, instance);
}