- Problem Statement
- Features
- Design/Architecture
- API
- Implementation
- Release Plan
- Questions to be addressed
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.
- 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
The new ember sortable will be designed using contextual components. It will be made up of 3 main components:
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.
Represents the individual model
of the group of models.
- yields
sortable-handle
and other properties as needed.
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.
{{#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}}
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.
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.
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.
1. Keyboard Reorder Mode
- To initiate
keyboardReorderMode
, asortable-handle
must befocused
and anEnter/Space
must be pressed. - This operation selects
sortable-item
parent of thesortable-handle
and enable sorting thesortable-item
within thesortable-group
via up/down/left/right arrow keys. - We will set
sortable-group
as a dedicatedcontainer
by addingrole
attribute and programmatically set thefocus
onto it. - We will create a
visual color indicator
around the selectedsortable-item
. A screen reader announcement will also be made to inform the user that thesortable-item
has been selected andsorting
has been enabled. - To create
visual indicators
,sortable-item
will append someclasses
, which by default tries to createarrow
visual indcators aroundsortable-item
. However, consumers are free to override theclass
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 anEnter/Space
key has been pressed. If committed, thefocus
will move from thesortable-group
back to thesortable-handle
.role
will be removed.
3. Reset
- The user can also exit
keyboardReorderMode
viaEscape
key or on focus lost. This will reset the reordering back to its initial state andfocus
is retained on thesortable-handle
.role
will be removed. - We should not need animation for this.
Drag drop will retain the same behavior as the current ember-sortable
.
- Internally, we can use a
announceActionConfig
object to map anaction
to atext
. We can have some default texts. - To support
i18n
, the consumer can supply their ownannounceActionConfig
with their translated strings.
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
.
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
.
TBD
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
.
{{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
)
}}
/**
* 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,
});
},
},
});
{{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)
)
)
}}
/**
* 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();
},
},
});
{
{
yield;
}
}
<span class="visually-hidden">{{ a11yText }}</span>;
/**
* 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();
},
});
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,
};
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();
}
}
- New API with backward incompatible changes
- Drag drop
- Animation
- Test helper
- Test Infrastructure Modernization
- remove
registerAsyncHelper
module
andsetupHooks
syntax
- remove
- Remove
jQuery
in favor of vanilla. - Migration Guide
- 1.x.x -> 2.x.x
- Keyboard support
- Keyboard navigation (left/right/up/down)
- Commit
- Reset
- Accessibility support
- Screen reader announcements
- Focus management
- Semantic markup and attributes
- Nesting support
-
With the introduction of Ember Octane and glimmer components, should we use any of their features? How backward compatible should this be?
-
Will an external animation library be of high value to us? If so, will the extra overhead be problematic?