Skip to content

Latest commit

 

History

History
1100 lines (922 loc) · 32.6 KB

V2_MIGRATION_RFC.md

File metadata and controls

1100 lines (922 loc) · 32.6 KB

Ember Sortable V2 Migration RFC

Author

ygongdev

Credits

Table of Content

  1. Problem Statement
  2. Features
  3. Design/Architecture
  4. API
  5. Implementation
  6. Release Plan
  7. Questions to be addressed

Problem Statement

ember-sortable has been falling behind in the adoption of the on-going Ember upgrades. It is currently not in the right condition, in which we can upgrade the addon without many blockers.

This RFC is meant to describe a high level overview of a new ember-sortable, which will help push us adopt many of the new Ember features.

This RFC is NOT meant to show the final implementation details as implementation will be re-iterated and improved over time.

Features

  • Baked in accessibility support
    • Up/down and left/right keyboard navigation
    • Screen reader announcement
    • Focus management
    • Visual indicators
  • Direction-agnostic (in terms of mouse drag)
    • Keyboard navigation is limited to up/down/left/right
  • Allows nested sortable elements
  • Adoption of modern Ember testing infrastructure
  • Animation
  • Built with composability and customizability in mind

Design/Architecture

1. Contextual Components

The new ember sortable will be designed using contextual components. It will be made up of 3 main components:

Sortable-group

Represents the entire sortable component.

  • requires a group of models to sort. The model will be a shallow copy and will not modify the given group of models.
  • contains all of main logic that makes ember-sortable work.
  • yields sortable-item and other properties as needed.

Sortable-item

Represents the individual model of the group of models.

  • yields sortable-handle and other properties as needed.

Sortable-handle

Represents the handle of each sortable-item.

This is the bread and butter of the entire component because it is the entrypoint that allows us to start sorting.

This component hooks up your custom handle to the mainframe, sortable-group.

  • yields other properties as needed.

Conceptual Example

{{#ember-sortable::sortable-group
  modelGroup=modelGroup
  onSubmit=(action "onSubmit")
  onDragStart=(action "onDragStart")
  onDragEnd=(action "onDragEnd")
  as |group|
}}
  {{#each group.modelGroup as |singleModel index|}}
    {{#group.item
      model=singleModel
      index=index
      as |item|
    }}
      {{!-- You can nest another "sortable-group" here --}}
      {{!-- Content goes here --}}
      {{#item.handle}}
        {{!-- Handle goes here --}}
      {{/item.handle}}
    {{/group.item}}
  {{/each}}
{{/ember-sortable::sortable-group}}

2. Event driven

Similar to the current ember-sortable, the sorting behavior is going to be mainly based on events. Animation might be an exception. We will utilize the Drag and Drop API, Keyboard Event API, and/or Mouse Event API.

3. Animation

For animation, we can explore different methods, e.g custom implementation, external animation library. However, the final decision will hopefully satisify the following:

  • minimize expensive DOM operations, e.g getComputedStyle, Ember runloop scheduling.
  • If we want to test animations, the animation have to be deterministic enough for us to test reliably.

4. Accessibility

This section provides a high level overview of how we will address the issue of accessibility.

The accessibility solutions described are directly referenced from this awesome codepen demo made by Andrew Lee.

Keyboard Navigation

1. Keyboard Reorder Mode

  • To initiate keyboardReorderMode, a sortable-handle must be focused and an Enter/Space must be pressed.
  • This operation selects sortable-item parent of the sortable-handle and enable sorting the sortable-item within the sortable-group via up/down/left/right arrow keys.
  • We will set sortable-group as a dedicated container by adding role attribute and programmatically set the focus onto it.
  • We will create a visual color indicator around the selected sortable-item. A screen reader announcement will also be made to inform the user that the sortable-item has been selected and sorting has been enabled.
  • To create visual indicators, sortable-item will append some classes, which by default tries to create arrow visual indcators around sortable-item. However, consumers are free to override the class to customize their own. Visual indicators will move as thesortable-item moves.

2. Commit

  • Every navigation will reorder the components in the UI. However, the reorder will not be committed until an Enter/Space key has been pressed. If committed, the focus will move from the sortable-group back to the sortable-handle. role will be removed.

3. Reset

  • The user can also exit keyboardReorderMode via Escape key or on focus lost. This will reset the reordering back to its initial state and focus is retained on the sortable-handle. role will be removed.
  • We should not need animation for this.

Drag Drop

Drag drop will retain the same behavior as the current ember-sortable.

Screen Reader Announcements

  • Internally, we can use a announceActionConfig object to map an action to a text. We can have some default texts.
  • To support i18n, the consumer can supply their own announceActionConfig with their translated strings.

5. Utility Classes and Functions

We should create a utility class to abstract as much work as we can from the components.

For example, a keyboardManager class can be used to maintain a history of our operations as well as provide the functionalities to perform the reordering logic during keyboard navigation.

6. Testing

The current ember-sortable implements two test helpers

  • reorder
  • drag

Both are heavily DOM calculation driven and can sometimes be hard and unreliable to use.

I propose new event driven test helpers and perhaps remove reorder as it is just a combination of drag. Internally, these would be just triggering events, which should be determinstic to test.

  • drag (for mouse)
  • move (for keyboard)
  • reorder?

We can implement another drag that is dedicated to testing animation.

API

TBD

Implementation

NOTE This is an example to help give an idea of what it could look like. This is NOT meant to be the final implementation. This example does NOT contain how we would handle animation and horizontal keyboard sorting. While creating this, I used the name draggable instead of ember-sortable.

draggable-group

hbs

{{yield
  (hash
    item=(
      component "drag-drop@-private/draggable-item"
      onSelect=(action "onModelSelect")
      onDragStart=(action "onModelDragStart")
      onDragEnter=(action "onModelDragEnter")
      isKeyboardReorderModeEnabled=isKeyboardReorderModeEnabled
      selectedModelIndex=selectedModelIndex
      maxIndex=maxIndex
      itemName=itemName
    )
    modelGroup=modelGroupCopy
  )
}}

js

/**
 * This component supports re-ordering items in a group via drag-drop and keyboard navigation.
 * The component is built with accessibility in mind. The logic of the component are mostly derived from https://codepen.io/drewlee/project/full/XWNLeE.
 *
 * @param {Ember.Array} modelGroup the group of models to be rearranged.
 * @param {String} itemName A name for the individual models, used for creating more meaningful a11y announcements.
 * @param {Function} [onSubmit] An optional callback for when position rearrangements are confirmed.
 * @param {Function} [onDragStart] An optional callback for when the user starts dragging a model.
 * @param {Funtion} [onDragEnd] An optional callback for when the user has finished dragging a model.
 *
 * @module drag-drop/draggable-group
 * @example
 * {{#drag-drop::draggable-group
 *   onSubmit=(action onSubmit)
 *   modelGroup=modelGroup
 *   onDragStart=(action onDragStart)
 *   onDragEnd=(action onDragEnd)
 *   itemName=itemName
 *   as |group|
 * }}
 *   {{#each group.modelGroup as |singleModel index|}}
 *     {{#group.item
 *       model=singleModel
 *       index=index
 *       as |item|
 *     }}
 *       {{singleModel}}
 *       {{#item.handle}}
 *         Handle
 *       {{/item.handle}}
 *     {{/group.item}}
 *   {{/each}}
 * {{/drag-drop::draggable-group}}
 */
export default Component.extend({
  layout,
  attributeBindings: ['tabindex', 'role', 'dataTestDragDropDraggableGroup:data-test-drag-drop-draggable-group'],
  // data-test selector
  dataTestDragDropDraggableGroup: true,
  /**
   * @param {Boolean} isKeyboardReorderModeEnabled if the keyboard navigation can be utilized.
   * @param {Object} selectedModel the selected model that the user is repositioning.
   * @param {Integer} selectedModelIndex the position of the selected model in the DOM.
   * @param {Object} targetModel the targeted model that the user is dropping the dragged model onto.
   * @param {Integer} targetModelIndex the index of the targeted model.
   * @param {Boolean} isRetainingFocus if the focus is being managed. This is usually to prevent incorrect focus when the DOM is not ready.
   * @param {Integer} tabindex tabindex attribute.
   * @param {String} role role attribute.
   * @param {Integer} maxIndex the highest possible index within the group.
   */
  isKeyboardReorderModeEnabled: false,
  selectedModel: null,
  selectedModelIndex: -1,
  targetModel: null,
  targetModelIndex: -1,
  isRetainingFocus: false,
  tabindex: undefined,
  role: undefined,
  maxIndex: alias('modelGroup.length'),

  a11yNotification: service('a11y-notification'),
  i18n: service('i18n'),

  init() {
    this._super(...arguments);

    this._assertProperties();

    // Create a shallow copy of the origina group to prevent mutating the original group.
    const modelGroupCopy = [...get(this, 'modelGroup')];
    setProperties(this, {
      modelGroupCopy,
      keyboardReorderManager: new KeyboardReorderManager(modelGroupCopy),
    });
  },

  /**
   * 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} evt a HTML event
   */
  keyDown(evt) {
    const isKeyboardReorderModeEnabled = get(this, 'isKeyboardReorderModeEnabled');

    if (!isKeyboardReorderModeEnabled && (isEnterKey(evt) || isSpaceKey(evt))) {
      this._enableKeyboardReorderMode();
      this._setupA11yApplicationContainer();

      set(this, 'isRetainingFocus', true);
      mutateDOM(() => {
        this.element.focus();
        set(this, 'isRetainingFocus', false);
      });

      evt.preventDefault();
      // In case of nested groups, prevent the keyDown from bubbling up to the parent.
      evt.stopPropagation();
      return;
    }

    if (isKeyboardReorderModeEnabled) {
      const { selectedModelIndex, maxIndex } = getProperties(this, 'selectedModelIndex', 'maxIndex');
      if (isDownArrowKey(evt)) {
        const newIndex = Math.min(selectedModelIndex + 1, maxIndex - 1);
        this._moveItem(selectedModelIndex, newIndex);

        set(this, 'selectedModelIndex', newIndex);

        this._announceAction(ANNOUNCEMENT_ACTION_TYPES.MOVE, {
          index: newIndex,
          maxIndex,
        });
        evt.preventDefault();
      } else if (isUpArrowKey(evt)) {
        const newIndex = Math.max(selectedModelIndex - 1, 0);

        this._moveItem(selectedModelIndex, newIndex);

        set(this, 'selectedModelIndex', newIndex);

        this._announceAction(ANNOUNCEMENT_ACTION_TYPES.MOVE, {
          index: newIndex,
          maxIndex,
        });
        // prevent mouse scroll
        evt.preventDefault();
      } else if (isEnterKey(evt) || isSpaceKey(evt)) {
        set(this, 'isRetainingFocus', true);
        this._confirmKeyboardSelection();

        readDOM(() => {
          if (IS_BROWSER) {
            this.element.querySelectorAll(`[${DRAGGABLE_HANDLE_ATTRIBUTE}]`)[selectedModelIndex].focus();
            set(this, 'isRetainingFocus', false);
          }
        });

        evt.preventDefault();
      } else if (isEscapeKey(evt)) {
        const keyboardReorderManager = get(this, 'keyboardReorderManager');
        const record = keyboardReorderManager.getRecord();
        const lastIndex = record ? record.fromIndex : selectedModelIndex;

        set(this, 'isRetainingFocus', true);
        this._cancelKeyboardSelection();

        readDOM(() => {
          if (IS_BROWSER) {
            this.element.querySelectorAll(`[${DRAGGABLE_HANDLE_ATTRIBUTE}]`)[lastIndex].focus();
            set(this, 'isRetainingFocus', false);
          }
        });

        evt.preventDefault();
      }
    }
    // In case of nested groups, prevent the keyDown from bubbling up to the parent.
    evt.stopPropagation();
  },

  /**
   * If focus management is not finished and the current focused element is not the handle or descendant of the handle: Cancel
   */
  focusOut(evt) {
    if (IS_BROWSER && !get(this, 'isRetainingFocus') && !this._isElementWithinHandle(document.activeElement)) {
      this._cancelKeyboardSelection();
    }
    evt.stopPropagation();
  },

  /**
   * Reset any ongoing keyboard selections and disable keyboard navigation because drag is taking over.
   * Invokes any optional `onDragStart` callback.
   *
   * @param {Event} evt a HTML event
   */
  dragStart(evt) {
    const keyboardReorderManager = get(this, 'keyboardReorderManager');

    keyboardReorderManager.reset();
    this._disableKeyboardReorderMode();
    evt.dataTransfer.setData('text/plain', '');
    tryInvoke(this, 'onDragStart');
    // In case of nested models, prevent the parent from being dragged instead of the child.
    evt.stopPropagation();
  },

  /**
   * Invoke optional `onDragEnd` callback.
   */
  dragEnd(evt) {
    tryInvoke(this, 'onDragEnd');
    // In case of nested models, prevent invoking parent's handler.
    evt.stopPropagation();
  },

  /**
   * Need `preventDefault` to allow `drop` to happen.
   *
   * @param {Event} evt a HTML event
   */
  dragOver(evt) {
    evt.preventDefault();
  },

  /**
   * If target drop area is within the group, execute the `drop` by
   * 1. Moving the `selectedModel` to the `targetModel` position
   * 2. Invoke the `onSubmit` callback since the new order is confirmed.
   * 3. Reset everything.
   *
   * @param {Event} evt a HTML event
   */
  drop(evt) {
    const dropTarget = evt.target;
    evt.preventDefault();
    if (this._isElementWithinDrop(dropTarget)) {
      const { keyboardReorderManager, selectedModel, selectedModelIndex, targetModel, targetModelIndex } =
        getProperties(
          this,
          'keyboardReorderManager',
          'selectedModel',
          'selectedModelIndex',
          'targetModel',
          'targetModelIndex'
        );
      this._moveItem(selectedModelIndex, targetModelIndex);
      tryInvoke(this, 'onSubmit', [selectedModel, selectedModelIndex, targetModel, targetModelIndex]);
      keyboardReorderManager.clearRecord();
    }
    this._resetModelSelection();
    // In case of nested models, prevent the parent from being dropped instead of the child.
    evt.stopPropagation();
  },

  /**
   * Confirms the keyboard selection by:
   * 1. Clearing the tracked record movement.
   * 2. Disabling keyboard navigation.
   * 3. Resets model selections.
   * 4. Tear down a11y container.
   * 5. Invoke `onSubmit` callback.
   * 6. Announce the change.
   */
  _confirmKeyboardSelection() {
    const { keyboardReorderManager, selectedModel, selectedModelIndex, targetModel, targetModelIndex } = getProperties(
      this,
      'keyboardReorderManager',
      'selectedModel',
      'selectedModelIndex',
      'targetModel',
      'targetModelIndex'
    );

    keyboardReorderManager.clearRecord();
    this._disableKeyboardReorderMode();
    this._tearDownA11yApplicationContainer();

    tryInvoke(this, 'onSubmit', [selectedModel, selectedModelIndex, targetModel, targetModelIndex]);

    this._resetModelSelection();
    this._announceAction(ANNOUNCEMENT_ACTION_TYPES.CONFIRM);
  },

  /**
   * Cancels the keyboard selection by:
   * 1. Disabling the keyboard navigation.
   * 2. Reset model selections.
   * 3. Reset any tracked movement by reverting the move and clearing the record.
   * 4. Tear down a11y container.
   * 4. Announce the change.
   */
  _cancelKeyboardSelection() {
    const keyboardReorderManager = get(this, 'keyboardReorderManager');

    this._disableKeyboardReorderMode();

    this._resetModelSelection();

    keyboardReorderManager.reset();

    this._tearDownA11yApplicationContainer();

    this._announceAction(ANNOUNCEMENT_ACTION_TYPES.CANCEL);
  },

  /**
   * Rearranges the order of models inside `modelGroupCopy`.
   *
   * @param {Integer} oldIndex the position of the model to be moved.
   * @param {Integer} newIndex the position that the model is moving to.
   */
  _moveItem(oldIndex, newIndex) {
    const keyboardReorderManager = get(this, 'keyboardReorderManager');

    keyboardReorderManager.move(oldIndex, newIndex);
  },

  /**
   * Reset model selection
   */
  _resetModelSelection() {
    setProperties(this, {
      selectedModel: null,
      selectedModelIndex: -1,
      targetModel: null,
      targetedModelIndex: -1,
    });
  },

  /**
   * Sets up a `role=application` container.
   */
  _setupA11yApplicationContainer() {
    setProperties(this, {
      role: 'application',
      tabindex: -1,
    });
  },

  /**
   * Tears down the `role=application` container.
   */
  _tearDownA11yApplicationContainer() {
    setProperties(this, {
      role: undefined,
      tabindex: undefined,
    });
  },

  /**
   * Asserts that required properties are defined correctly.
   */
  _assertProperties() {
    assert('modelGroup is required', get(this, 'modelGroup'));
    assert('itemName is required', get(this, 'itemName'));
  },

  /**
   * Enables keyboard navigation.
   */
  _enableKeyboardReorderMode() {
    set(this, 'isKeyboardReorderModeEnabled', true);
  },

  /**
   * Disables keyboard navigation.
   */
  _disableKeyboardReorderMode() {
    set(this, 'isKeyboardReorderModeEnabled', false);
  },

  /**
   * Checks if the given element is a descedant of a handle.
   *
   * @param {Element} element a DOM element.
   */
  _isElementWithinHandle(element) {
    return element.closest(`#${this.element.id} [${DRAGGABLE_HANDLE_ATTRIBUTE}]`);
  },

  /**
   * Checks if the given element is a descedant of a droppable region.
   *
   * @param {Element} element a DOM element
   */
  _isElementWithinDrop(element) {
    return element.closest(`#${this.element.id} [${DRAGGABLE_ITEM_ATTRIBUTE}]`);
  },

  /**
   * Helper method for extracting i18n strings used in JS
   *
   * @method geti18nMessage
   * @param {String} key - Unique key that identifies an i18n string
   * @param {Object} data - Dynamic segments of an i18n string
   * @return {Function}
   */
  _geti18nMessage(key, data) {
    const messageRenderer = get(this, 'i18n').getMessageRenderer(get(this, 'layout'), key);
    return messageRenderer([data]);
  },

  /**
   * Announce action for screen reader.
   *
   * @param {Enum} announcementType
   * @param {Object} announcementConfig
   */
  _announceAction(announcementType, announcementConfig = {}) {
    const a11yNotification = get(this, 'a11yNotification');

    let message;
    const itemName = get(this, 'itemName');
    const { index, maxIndex } = announcementConfig;

    switch (announcementType) {
      case ANNOUNCEMENT_ACTION_TYPES.ACTIVATE:
        message = this._geti18nMessage('i18n_activate', {
          itemName,
          index,
          maxIndex,
        });
        break;
      case ANNOUNCEMENT_ACTION_TYPES.MOVE:
        message = this._geti18nMessage('i18n_move', {
          itemName,
          index,
          maxIndex,
        });
        break;
      case ANNOUNCEMENT_ACTION_TYPES.CONFIRM:
        message = this._geti18nMessage('i18n_confirm', { itemName });
        break;
      case ANNOUNCEMENT_ACTION_TYPES.CANCEL:
        message = this._geti18nMessage('i18n_cancel', { itemName });
        break;
      default:
        break;
    }
    a11yNotification.setTextInLiveRegion(message);
  },

  actions: {
    /**
     * Enables keyboard navigation.
     */
    enableKeyboardReorderMode() {
      this._enableKeyboardReorderMode();
    },

    /**
     * Enables keyboard navigation.
     */
    disableKeyboardReorderMode() {
      this._disableKeyboardReorderMode();
    },

    /**
     * `draggable-item` invokes this when it is selected via keyboard.
     *
     * @param {Object} model the selected model.
     * @param {Integer} index the position of the selected model in the DOM.
     */
    onModelSelect(model, index) {
      setProperties(this, {
        selectedModel: model,
        selectedModelIndex: index,
      });
    },

    /**
     * `draggable-item` invokes this when it starts being dragged.
     *
     * @param {Object} model the model being dragged.
     * @param {Integer} index the position of the dragged model in the DOM.
     */
    onModelDragStart(model, index) {
      setProperties(this, {
        selectedModel: model,
        selectedModelIndex: index,
      });
    },

    /**
     * `draggable-item` invokes this when a dragged model enters the target model's region
     *
     * @param {Object} model the target model.
     * @param {Integer} index the position of the target model in the DOM.
     */
    onModelDragEnter(model, index) {
      setProperties(this, {
        targetModel: model,
        targetModelIndex: index,
      });
    },
  },
});

draggable-item

hbs

{{yield
  (hash
    handle=(component "drag-drop@-private/draggable-handle"
      enableDrag=(action "enableDrag")
      disableDrag=(action "disableDrag")
      isKeyboardReorderModeEnabled=isKeyboardReorderModeEnabled
      selectedIndex=selectedModelIndex
      index=index
      maxIndex=maxIndex
      a11yText=(some-i18n-util itemName=itemName)
    )
  )
}}

js

/**
 * This private component represents the individual model of `draggable-group`.
 *
 * Public API
 * @param {Function}  model the model that this component is associated with.
 * @param {Function}  index the position of this component in the DOM.
 *
 * Private API
 * @param {Boolean}  isKeyboardReorderModeEnabled If the keyboard navigation can be utilized.
 * @param {Integer}  selectedModelIndex The position of the selected model in the DOM.
 * @param {Integer}  maxIndex The highest possible index within the group.
 * @param {String}  itemName A name for the individual models, used for creating more meaningful a11y announcements.
 * @param {Function}  onDragStart Callback to notify `draggable-group` that this component is being dragged.
 * @param {Function}  onDragEnter Callback to notify `draggable-group` that this component is can be dropped on.
 *
 * @module drag-drop/-private/draggable-item
 */
export default Component.extend({
  tagName: 'div',
  classNameBindings: ['isSelected:drag-drop__item--active'],
  attributeBindings: [
    'draggable',
    `dataDragDropItem:${DRAGGABLE_ITEM_ATTRIBUTE}`,
    `dataTestDragDropDraggableItem:data-test-drag-drop-draggable-item`,
  ],
  // data attribute
  dataDragDropItem: true,
  // data-test selector
  dataTestDragDropDraggableItem: true,
  // native attribute allowing element to be draggable.
  draggable: false,

  /**
   * Toggles a visual state for a11y purpose.
   *
   * @param {Integer} selectedModelIndex the position of the selected model in the DOM.
   * @param {Integer} index the position of this model in the DOM.
   */
  isSelected: computed('selectedModelIndex', 'index', function getIsSelected() {
    const { selectedModelIndex, index } = getProperties(this, 'selectedModelIndex', 'index');

    return selectedModelIndex === index;
  }),

  onDragStart() {
    assert('onDragStart is required');
  },

  onDragEnter() {
    assert('onDragEnter is required');
  },

  init() {
    this._super(...arguments);
    this._assertProperties();
  },

  /**
   * Pass the selected model up to the `draggable-group`, so the group knows which model is being selected.
   *
   * @param {Event} evt a HTML event.
   */
  keyDown(evt) {
    const { isKeyboardReorderModeEnabled, model, selectedModelIndex, index } = getProperties(
      this,
      'isKeyboardReorderModeEnabled',
      'model',
      'selectedModelIndex',
      'index'
    );

    if (
      selectedModelIndex < 0 &&
      (isKeyboardReorderModeEnabled || (!isKeyboardReorderModeEnabled && (isEnterKey(evt) || isSpaceKey(evt))))
    ) {
      this.onSelect(model, index);
    }
  },

  /**
   * Invoke `onDragStart` callback from `draggable-group`.
   */
  dragStart() {
    this.onDragStart(get(this, 'model'), get(this, 'index'));
  },

  /**
   * Invoke `_disableDrag` callback from `draggable-group`.
   */
  dragEnd() {
    this._disableDrag();
  },

  /**
   * Invoke `onDragEnter` callback from `draggable-group`.
   */
  dragEnter() {
    this.onDragEnter(get(this, 'model'), get(this, 'index'));
  },

  /**
   * Asserts that required properties are defined correctly.
   */
  _assertProperties() {
    assert('isKeyboardReorderModeEnabled is required', get(this, 'isKeyboardReorderModeEnabled') !== undefined);
    assert('selectedModelIndex is required', get(this, 'selectedModelIndex') !== undefined);
    assert('maxIndex is required', get(this, 'maxIndex') !== undefined);
    assert('itemName is required', get(this, 'itemName') !== undefined);
  },

  /**
   * Enables this item to be draggable.
   */
  _enableDrag() {
    set(this, 'draggable', true);
  },

  /**
   * Disables this item from being draggable.
   */
  _disableDrag() {
    set(this, 'draggable', false);
  },

  actions: {
    /**
     * Callback for `draggable-handle` to enable draggable.
     */
    enableDrag() {
      this._enableDrag();
    },

    /**
     * Callback for `draggable-handle` to disable draggable.
     */
    disableDrag() {
      this._disableDrag();
    },
  },
});

draggable-handle

hbs

{
  {
    yield;
  }
}
<span class="visually-hidden">{{ a11yText }}</span>;

js

/**
 * This private component represents the `handle` for each `draggable-item` of a `draggable-group`.
 *
 * @param {Boolean} isKeyboardReorderModeEnabled If the keyboard navigation can be utilized.
 * @param {Integer} selectedIndex The position of the selected model in the DOM.
 * @param {Integer} index The position of this component in the DOM.
 * @param {Integer} maxIndex  The highest possible index within the group.
 * @param {String} a11yText The text for this handle.
 * @param {Function} enableDrag Callback to make `draggable-item` draggable.
 * @param {Function} disableDrag Callback to make `draggable-item` not draggable.
 *
 * @module drag-drop/-private/draggable-handle
 */
export default Component.extend({
  /**
   * <div> and tabindex: 0 is used intentionally to make the element non-interactive, so it works with Windows screen reader.
   */
  tagName: 'div',
  tabindex: 0,
  classNameBindings: ['showA11yPreviousArrow:drag-drop__handle-previous', 'showA11yNextArrow:drag-drop__handle-next'],
  attributeBindings: [
    `dataDragDropHandle:${DRAGGABLE_HANDLE_ATTRIBUTE}`,
    'tabindex',
    'dataTestDragDropDraggableHandle:data-test-drag-drop-draggable-handle',
  ],
  // data attribute
  dataDragDropHandle: true,
  // data-test selector
  dataTestDragDropDraggableHandle: true,

  /**
   * Shows the previous arrow.
   * 1. keyboard navigation is enabled.
   * 2. This handle is selected.
   * 3. This handle is not the first handle.
   *
   * @param {Integer} index The position of this component in the DOM.
   * @param {Integer} selectedIndex The position of the selected model in the DOM.
   * @param {Boolean} isKeyboardReorderModeEnabled If the keyboard navigation can be utilized.
   */
  showA11yPreviousArrow: computed(
    'index',
    'selectedIndex',
    'isKeyboardReorderModeEnabled',
    function getShowA11yPreviousArrow() {
      const { index, selectedIndex, isKeyboardReorderModeEnabled } = getProperties(
        this,
        'index',
        'selectedIndex',
        'isKeyboardReorderModeEnabled'
      );
      return isKeyboardReorderModeEnabled && selectedIndex === index && index > 0;
    }
  ),

  /**
   * Shows the next arrow if
   * 1. keyboard navigation is enabled.
   * 2. This handle is selected.
   * 3. This handle is not the last handle.
   *
   * @param {Integer} index The position of this component in the DOM.
   * @param {Integer} selectedIndex The position of the selected model in the DOM.
   * @param {Boolean} isKeyboardReorderModeEnabled If the keyboard navigation can be utilized.
   */
  showA11yNextArrow: computed(
    'index',
    'selectedIndex',
    'maxIndex',
    'isKeyboardReorderModeEnabled',
    function getShowA11yNextArrow() {
      const { index, selectedIndex, maxIndex, isKeyboardReorderModeEnabled } = getProperties(
        this,
        'index',
        'maxIndex',
        'selectedIndex',
        'isKeyboardReorderModeEnabled'
      );
      return isKeyboardReorderModeEnabled && selectedIndex === index && index < maxIndex - 1;
    }
  ),

  enableDrag() {
    assert('enableDrag is required');
  },

  disableDrag() {
    assert('disableDrag is required');
  },

  init() {
    this._super(...arguments);

    this._assertProperties();
  },

  /**
   * Asserts that required properties are defined correctly.
   */
  _assertProperties() {
    assert(`a11yText is required`, get(this, 'a11yText'));
    assert(`index is required`, get(this, 'index') !== undefined);
    assert(`maxIndex is required`, get(this, 'maxIndex') !== undefined);
    assert(`selectedIndex is required`, get(this, 'selectedIndex') !== undefined);
    assert(`isKeyboardReorderModeEnabled is required`, get(this, 'isKeyboardReorderModeEnabled') !== undefined);
  },

  /**
   * Enables `draggable-item` to be draggable.
   */
  mouseDown() {
    this.enableDrag();
  },

  /**
   * Disable `draggable-item` from being draggable.
   */
  mouseUp() {
    this.disableDrag();
  },
});

utils/constants.js

export const DRAGGABLE_HANDLE_ATTRIBUTE = 'data-drag-drop-draggable-handle';
export const DRAGGABLE_ITEM_ATTRIBUTE = 'data-drag-drop-draggable-item';
export const ANNOUNCEMENT_ACTION_TYPES = {
  ACTIVATE: true,
  MOVE: true,
  CONFIRM: true,
  CANCEL: true,
};

utils/keyboard-reorder-manager.js

class ReorderRecord {
  constructor(fromIndex, toIndex) {
    this.fromIndex = fromIndex;
    this.toIndex = toIndex;
  }
}

export default class KeyboardReorderManager {
  constructor(modelGroup) {
    this._modelGroup = modelGroup;
    this._record = null;
  }

  move(fromIndex, toIndex) {
    if (fromIndex < 0 || toIndex < 0 || fromIndex >= this._modelGroup.length || toIndex >= this._modelGroup.length) {
      return;
    }

    const modelToBeMoved = this._modelGroup.objectAt(fromIndex);
    this._modelGroup.removeAt(fromIndex);
    this._modelGroup.insertAt(toIndex, modelToBeMoved);

    if (!this._record) {
      this._record = new ReorderRecord(fromIndex, toIndex);
    } else {
      this._record.toIndex = toIndex;
    }
  }

  getModel() {
    return this._modelGroup;
  }

  getRecord() {
    return this._record;
  }

  clearRecord() {
    this._record = null;
  }

  reset() {
    if (this._record) {
      this.move(this._record.toIndex, this._record.fromIndex);
    }
    this.clearRecord();
  }
}

Release Plan

2.0

  • New API with backward incompatible changes
    • Drag drop
    • Animation
    • Test helper
  • Test Infrastructure Modernization
    • remove registerAsyncHelper
    • module and setupHooks syntax
  • Remove jQuery in favor of vanilla.
  • Migration Guide
    • 1.x.x -> 2.x.x

2.1

  • Keyboard support
    • Keyboard navigation (left/right/up/down)
    • Commit
    • Reset

2.2

  • Accessibility support
    • Screen reader announcements
    • Focus management
    • Semantic markup and attributes

2.3

  • Nesting support

Questions to be addressed

  1. With the introduction of Ember Octane and glimmer components, should we use any of their features? How backward compatible should this be?

  2. Will an external animation library be of high value to us? If so, will the extra overhead be problematic?