UNPKG

ember-sortable

Version:
742 lines (649 loc) 21.3 kB
import { A } from '@ember/array'; import Component from '@ember/component'; import { set, get, computed, defineProperty } from '@ember/object'; import { next, schedule, scheduleOnce, later } from '@ember/runloop'; import layout from '../templates/components/sortable-group'; import { isEnterKey, isSpaceKey, isEscapeKey, isDownArrowKey, isUpArrowKey, isLeftArrowKey, isRightArrowKey, } from '../utils/keyboard'; import { ANNOUNCEMENT_ACTION_TYPES } from '../utils/constant'; import { deprecate } from '@ember/debug'; const a = A; const NO_MODEL = {}; /** * This component supports re-ordering items in a group via drag-drop and keyboard navigation. * The component is built with accessibility in mind. * * @param {Ember.Object} groupModel The group of models to be rearranged. * @param {Ember.Array} model The array of models. * @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 * {{#sortable-group data-test-vertical-demo-group tagName="ol" a11yAnnouncementConfig=a11yAnnouncementConfig a11yItemName="spanish number" itemVisualClass=itemVisualClass handleVisualClass=handleVisualClass onChange=(action "update") model=model.items as |group|}} * {{#each group.model as |item|}} * {{#group.item data-test-vertical-demo-item tagName="li" model=item as |groupItem|}} * {{item}} * {{#groupItem.handle data-test-vertical-demo-handle class="handle"}} * <span data-item={{item}}> * <span>&vArr;</span> * </span> * {{/groupItem.handle}} * {{/group.item}} * {{/each}} * {{/sortable-group}} **/ export default Component.extend({ layout, tagName: 'ol', attributeBindings: ['data-test-selector', 'tabindex', 'role'], /** Called when order of items has been changed @property onChange @type Action @param object group model (omitted if not set) @param array item models in their new order @param object item model just dragged @default null */ onChange: function() {}, /** @property direction @type string @default y */ direction: 'y', /** @property model @type Any @default null */ model: NO_MODEL, /** * @property a group associated with the model * @type Any * @default null */ groupModel: NO_MODEL, /** * @property an object containing different classes for visual indicators * @type * @default null * @example * { * UP: 'up' * DOWN: 'down', * LEFT: 'left', * RIGHT: 'right', * } */ handleVisualClass: NO_MODEL, /** * @property an object containing functions for producing screen reader announcements * @type * @default null * @example * { * MOVE: function() {}, * ACTIVATE: function() {}, * CONFIRM: function() {}, * CANCEL: function() {}, * } */ a11yAnnouncementConfig: NO_MODEL, /** Primary keyboard utils */ // Tracks the currently selected item _selectedItem: null, // Tracks the current move move: null, // Tracks the status of keyboard reorder mode isKeyboardReorderModeEnabled: false, // Tracks if we're still performing a programmatical focus. isRetainingFocus: false, /** End of keyboard utils */ /** @property items @type Ember.NativeArray */ items: computed(() => a()), init() { this._super(...arguments); deprecate( 'The <SortableGroup> component is deprecated. Please use the modifier version {{sortable-group}}.', false, { id: 'ember-sortable:sortable-group-component-deprecated', until: '3.0.0', for: 'ember-sortable', since: { available: '2.2.6', enabled: '2.2.6', }, } ); this._setGetterSetters(); this.set('moves', []); }, didInsertElement() { this._super(...arguments); // Adding announcer inside an ordered list violates a11y guidelines, so we insert it after our list. const announcer = this._createAnnouncer(); this.set('announcer', announcer); this.element.insertAdjacentElement('afterend', announcer); }, /** * 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 DOM event */ keyDown(event) { if (!this.get('isKeyDownEnabled')) { return; } // Note: If handle is specified, we need to target the keyDown on the handle const isKeyboardReorderModeEnabled = this.get('isKeyboardReorderModeEnabled'); const _selectedItem = this.get('_selectedItem'); if (!isKeyboardReorderModeEnabled && (isEnterKey(event) || isSpaceKey(event))) { this.prepareKeyboardReorderMode(); this._announceAction(ANNOUNCEMENT_ACTION_TYPES.ACTIVATE); this._updateItemVisualIndicators(_selectedItem, true); this._updateHandleVisualIndicators(_selectedItem, true); this.set('isRetainingFocus', true); scheduleOnce('render', () => { this.element.focus(); this.set('isRetainingFocus', false); }); // Prevent the default scroll event.preventDefault(); return; } if (isKeyboardReorderModeEnabled) { this._handleKeyboardReorder(event); event.preventDefault(); } }, /** * 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. */ focusOut() { if (!this.get('isRetainingFocus') && !this._isElementWithinHandle(document.activeElement)) { this.cancelKeyboardSelection(); } }, prepareKeyboardReorderMode() { this._enableKeyboardReorderMode(); this._setupA11yApplicationContainer(); }, /** * Moves the item to its new position and adds the move to our history. * * @param {Object} item the item to be moved. * @param {Integer} delta how much to move index-wise. */ moveItem(item, delta) { const { sortedItems, moves } = this.getProperties('sortedItems', '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 ccurrent 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. */ cancelKeyboardSelection() { const _selectedItem = this.get('_selectedItem'); this._disableKeyboardReorderMode(); // Revert the process by reversing the move. const moves = this.get('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 items = this.get('sortedItems'); const groupModel = this.get('groupModel'); const _selectedItem = this.get('_selectedItem'); const selectedModel = _selectedItem.get('model'); const itemModels = items.mapBy('model'); this.set('moves', []); this._disableKeyboardReorderMode(); this._tearDownA11yApplicationContainer(); this._updateItems(); if (groupModel !== NO_MODEL) { this.onChange(groupModel, itemModels, selectedModel) } else { this.onChange(itemModels, selectedModel); } this._updateItemVisualIndicators(_selectedItem, false); this._updateHandleVisualIndicators(_selectedItem, false); this._resetItemSelection(); }, /** * Enables keyboard navigation */ _activateKeyDown(){ this.set('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. */ _deactivateKeyDown() { this.set('isKeyDownEnabled', false); }, /** Register an item with this group. @method registerItem @param {SortableItem} [item] */ _registerItem(item) { this.get('items').addObject(item); }, /** De-register an item with this group. @method deregisterItem @param {SortableItem} [item] */ _deregisterItem(item) { this.get('items').removeObject(item); }, _setSelectedItem(item) { this.set('_selectedItem', item); }, /** Prepare for sorting. Main purpose is to stash the current itemPosition so we don’t incur expensive re-layouts. @method _=repare */ _prepare() { this._itemPosition = this.get('itemPosition'); }, /** Update item positions (relatively to the first element position). @method update */ _update() { let sortedItems = this.get('sortedItems'); // Position of the first element let position = this._itemPosition; // Just in case we haven’t called prepare first. if (position === undefined) { position = this.get('itemPosition'); } sortedItems.forEach(item => { let dimension; let direction = this.get('direction'); if (!get(item, 'isDragging')) { set(item, direction, position); } // add additional spacing around active element if (get(item, 'isBusy')) { position += get(item, 'spacing') * 2; } if (direction === 'x') { dimension = 'width'; } if (direction === 'y') { dimension = 'height'; } position += get(item, dimension); }); }, /** @method _commit */ _commit() { const items = this.get('sortedItems'); const groupModel = this.get('groupModel'); const itemModels = items.mapBy('model'); const draggedItem = items.findBy('wasDropped', true); let draggedModel; if (draggedItem) { set(draggedItem, 'wasDropped', false); // Reset draggedModel = get(draggedItem, 'model'); } this._updateItems(); if (groupModel !== NO_MODEL) { this.onChange(groupModel, itemModels, draggedModel); } else { this.onChange(itemModels, draggedModel); } }, /** * Keeps the UI in sync with actual changes. * Needed for drag and keyboard operations. */ _updateItems() { const items = this.get('sortedItems'); delete this._itemPosition; schedule('render', () => { items.invoke('freeze'); }); schedule('afterRender', () => { items.invoke('reset'); }); next(() => { schedule('render', () => { items.invoke('thaw'); }); }); }, /** * 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, sortedItems } = this.getProperties('direction', '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.get(direction); item.set(direction, nextItem.get(direction) + (nextItem.get(dimension) - item.get(dimension))); nextItem.set(direction, value); // UP or LEFT } else { value = nextItem.get(direction); nextItem.set(direction, item.get(direction) + (item.get(dimension) - nextItem.get(dimension))); item.set(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) { let { direction, _selectedItem } = this.getProperties('direction', '_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 = this.get('_selectedItem.element'); this._announceAction(ANNOUNCEMENT_ACTION_TYPES.CONFIRM); this.confirmKeyboardSelection(); this.set('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 = this.get('_selectedItem.element'); this._announceAction(ANNOUNCEMENT_ACTION_TYPES.CANCEL); this.cancelKeyboardSelection(); this.set('isRetainingFocus', true); scheduleOnce('render', () => { const moves = this.get('moves'); if (moves && moves.length > 0) { const sortedItems = this.get('sortedItems'); const itemElement = sortedItems[moves[0].fromIndex].element this._focusItem(itemElement); } else { this._focusItem(_selectedItemElement); } this.set('isRetainingFocus', false); }); } }, /** * Sets focus on the current item or its handle. * * @param {Elment} 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.set('isKeyboardReorderModeEnabled', true); }, /** * Disables keyboard reorder mode */ _disableKeyboardReorderMode() { this.set('isKeyboardReorderModeEnabled', false); }, /** * Sets up the group as an application and make it programmatically focusable. */ _setupA11yApplicationContainer() { this.setProperties({ role: 'application', tabindex: -1, }); }, /** * Creates a `visually-hidden` `aria-live` announcement region. */ _createAnnouncer() { const announcer = document.createElement('span'); announcer.setAttribute('aria-live', 'polite'); announcer.classList.add('visually-hidden'); return announcer; }, /** * 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.get('a11yAnnouncementConfig'); const a11yItemName = this.get('a11yItemName'); if (a11yAnnouncementConfig === NO_MODEL || !a11yItemName || !(type in a11yAnnouncementConfig)) { return; } const sortedItems = this.get('sortedItems'); const _selectedItem = this.get('_selectedItem'); const index = sortedItems.indexOf(_selectedItem); const announcer = this.get('announcer'); const config = { a11yItemName, index: index, maxLength : sortedItems.length, direction: this.get('direction'), delta, } const message = a11yAnnouncementConfig[type](config); announcer.textContent = message; // Reset the message after the message is announced. later(() => { announcer.textContent = ''; }, 1000); }, /** * Tears down the `role=application` container. */ _tearDownA11yApplicationContainer() { this.setProperties({ role: undefined, tabindex: undefined, }); }, /** * Reset the selected item. */ _resetItemSelection() { this.set('_selectedItem', null); }, /** * 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} [data-sortable-handle]` ); }, /** * Updates the selected item's visual indicators. * * @param {Object} item the selected item. * @param {Boolean} isActive to activate or deactivate the class. */ _updateItemVisualIndicators(item, isActive) { const itemVisualClass = this.get('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 {Object} item the selected item. * @param {Boolean} isUpdate to update or not update. */ _updateHandleVisualIndicators(item, isUpdate) { const handleVisualClass = this.get('handleVisualClass'); if (handleVisualClass === NO_MODEL || !item) { return; } const sortedItems = this.get('sortedItems'); const direction = this.get('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]]); } }, /** * Defining getters and setters to support native getter and setters until we decide to drop support for ember versions below 3.10 */ _setGetterSetters() { /** Position for the first item. If spacing is present, first item's position will have to change as well. @property itemPosition @type Number */ defineProperty(this, 'itemPosition', { get() { const direction = this.get('direction'); return this.get(`sortedItems.firstObject.${direction}`) - this.get('sortedItems.firstObject.spacing'); } }); /** An array of DOM elements. @property sortedItems @type Array */ defineProperty(this, 'sortedItems', { get() { const items = a(this.get('items')); const direction = this.get('direction'); return a(items.sortBy(direction)); } }) }, actions: { activateKeyDown(){ this._activateKeyDown(); }, deactivateKeyDown() { this._deactivateKeyDown(); }, registerItem(item) { this._registerItem(item); }, deregisterItem(item) { this._deregisterItem(item); }, setSelectedItem(item) { this._setSelectedItem(item); }, prepare() { this._prepare(); }, update() { this._update(); }, commit() { this._commit(); }, } });