makeup-navigation-emitter
Advanced tools
Comparing version 0.5.1 to 0.6.0
@@ -7,108 +7,152 @@ "use strict"; | ||
exports.createLinear = createLinear; | ||
var KeyEmitter = _interopRequireWildcard(require("makeup-key-emitter")); | ||
var ExitEmitter = _interopRequireWildcard(require("makeup-exit-emitter")); | ||
function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } | ||
function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } | ||
function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function"); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, writable: true, configurable: true } }); Object.defineProperty(subClass, "prototype", { writable: false }); if (superClass) _setPrototypeOf(subClass, superClass); } | ||
function _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); } | ||
function _createSuper(Derived) { var hasNativeReflectConstruct = _isNativeReflectConstruct(); return function _createSuperInternal() { var Super = _getPrototypeOf(Derived), result; if (hasNativeReflectConstruct) { var NewTarget = _getPrototypeOf(this).constructor; result = Reflect.construct(Super, arguments, NewTarget); } else { result = Super.apply(this, arguments); } return _possibleConstructorReturn(this, result); }; } | ||
function _possibleConstructorReturn(self, call) { if (call && (typeof call === "object" || typeof call === "function")) { return call; } else if (call !== void 0) { throw new TypeError("Derived constructors may only return object or undefined"); } return _assertThisInitialized(self); } | ||
function _assertThisInitialized(self) { if (self === void 0) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return self; } | ||
function _isNativeReflectConstruct() { if (typeof Reflect === "undefined" || !Reflect.construct) return false; if (Reflect.construct.sham) return false; if (typeof Proxy === "function") return true; try { Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {})); return true; } catch (e) { return false; } } | ||
function _getPrototypeOf(o) { _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) { return o.__proto__ || Object.getPrototypeOf(o); }; return _getPrototypeOf(o); } | ||
function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } | ||
function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; } | ||
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } | ||
var dataSetKey = 'data-makeup-index'; // todo: autoInit: -1, autoReset: -1 are used for activeDescendant behaviour. These values can be abstracted away with | ||
// a new "type" option (roving or active) | ||
var defaultOptions = { | ||
axis: 'both', | ||
autoInit: 0, | ||
autoReset: null, | ||
ignoreButtons: false, | ||
autoInit: 'interactive', | ||
autoReset: 'current', | ||
ignoreByDelegateSelector: null, | ||
wrap: false | ||
}; | ||
var itemFilter = function itemFilter(el) { | ||
return !el.hidden; | ||
}; | ||
function clearData(els) { | ||
els.forEach(function (el) { | ||
return el.removeAttribute(dataSetKey); | ||
}); | ||
function isItemNavigable(el) { | ||
return !el.hidden && el.getAttribute('aria-disabled') !== 'true'; | ||
} | ||
function setData(els) { | ||
els.forEach(function (el, index) { | ||
return el.setAttribute(dataSetKey, index); | ||
}); | ||
function isIndexNavigable(items, index) { | ||
return index >= 0 && index < items.length ? isItemNavigable(items[index]) : false; | ||
} | ||
function isButton(el) { | ||
return el.tagName.toLowerCase() === 'button' || el.type === 'button'; | ||
function findNavigableItems(items) { | ||
return items.filter(isItemNavigable); | ||
} | ||
function onKeyPrev(e) { | ||
if (isButton(e.detail.target) === false || this.options.ignoreButtons === false) { | ||
if (!this.atStart()) { | ||
this.index--; | ||
} else if (this.options.wrap) { | ||
this.index = this.filteredItems.length - 1; | ||
function findFirstNavigableIndex(items) { | ||
return items.findIndex(item => isItemNavigable(item)); | ||
} | ||
function findLastNavigableIndex(items) { | ||
// todo: at(-1) is more performant than reverse(), but Babel is not transpiling it | ||
return items.indexOf(findNavigableItems(items).reverse()[0]); | ||
} | ||
function findIndexByAttribute(items, attribute, value) { | ||
return items.findIndex(item => isItemNavigable(item) && item.getAttribute(attribute) === value); | ||
} | ||
function findFirstNavigableAriaCheckedIndex(items) { | ||
return findIndexByAttribute(items, 'aria-checked', 'true'); | ||
} | ||
function findFirstNavigableAriaSelectedIndex(items) { | ||
return findIndexByAttribute(items, 'aria-selected', 'true'); | ||
} | ||
function findIgnoredByDelegateItems(el, options) { | ||
return options.ignoreByDelegateSelector !== null ? [...el.querySelectorAll(options.ignoreByDelegateSelector)] : []; | ||
} | ||
function findPreviousNavigableIndex(items, index, wrap) { | ||
var previousNavigableIndex = -1; | ||
if (index === null) { | ||
// no-op | ||
} else if (atStart(items, index)) { | ||
if (wrap === true) { | ||
previousNavigableIndex = findLastNavigableIndex(items); | ||
} | ||
} else { | ||
var i = index; | ||
while (--i >= 0) { | ||
if (isItemNavigable(items[i])) { | ||
previousNavigableIndex = i; | ||
break; | ||
} | ||
} | ||
} | ||
return previousNavigableIndex; | ||
} | ||
function onKeyNext(e) { | ||
if (isButton(e.detail.target) === false || this.options.ignoreButtons === false) { | ||
if (!this.atEnd()) { | ||
this.index++; | ||
} else if (this.options.wrap) { | ||
this.index = 0; | ||
function findNextNavigableIndex(items, index, wrap) { | ||
var nextNavigableIndex = -1; | ||
if (index === null) { | ||
nextNavigableIndex = findFirstNavigableIndex(items); | ||
} else if (atEnd(items, index)) { | ||
if (wrap === true) { | ||
nextNavigableIndex = findFirstNavigableIndex(items); | ||
} | ||
} else { | ||
var i = index; | ||
while (++i < items.length) { | ||
if (isItemNavigable(items[i])) { | ||
nextNavigableIndex = i; | ||
break; | ||
} | ||
} | ||
} | ||
return nextNavigableIndex; | ||
} | ||
function onClick(e) { | ||
var element = e.target; | ||
var indexData = element.dataset.makeupIndex; // traverse widget ancestors until interactive element is found | ||
// returning -1 means not found | ||
function findIndexPositionByType(typeOrNum, items, currentIndex) { | ||
var index = -1; | ||
switch (typeOrNum) { | ||
case 'none': | ||
index = null; | ||
break; | ||
case 'current': | ||
index = currentIndex; | ||
break; | ||
case 'interactive': | ||
index = findFirstNavigableIndex(items); | ||
break; | ||
case 'ariaChecked': | ||
index = findFirstNavigableAriaCheckedIndex(items); | ||
break; | ||
case 'ariaSelected': | ||
index = findFirstNavigableAriaSelectedIndex(items); | ||
break; | ||
case 'ariaSelectedOrInteractive': | ||
index = findFirstNavigableAriaSelectedIndex(items); | ||
index = index === -1 ? findFirstNavigableIndex(items) : index; | ||
break; | ||
default: | ||
index = typeof typeOrNum === 'number' || typeOrNum === null ? typeOrNum : -1; | ||
} | ||
return index; | ||
} | ||
function atStart(items, index) { | ||
return index === findFirstNavigableIndex(items); | ||
} | ||
function atEnd(items, index) { | ||
return index === findLastNavigableIndex(items); | ||
} | ||
function onKeyPrev(e) { | ||
var ignoredByDelegateItems = findIgnoredByDelegateItems(this._el, this.options); | ||
while (element !== this._el && !indexData) { | ||
element = element.parentNode; | ||
indexData = element.dataset.makeupIndex; | ||
// todo: update KeyEmitter to deal with ignored items? | ||
if (ignoredByDelegateItems.length === 0 || !ignoredByDelegateItems.includes(e.detail.target)) { | ||
this.index = findPreviousNavigableIndex(this.items, this.index, this.options.wrap); | ||
} | ||
} | ||
function onKeyNext(e) { | ||
var ignoredByDelegateItems = findIgnoredByDelegateItems(this._el, this.options); | ||
if (indexData !== undefined) { | ||
this.index = indexData; | ||
// todo: update KeyEmitter to deal with ignored items? | ||
if (ignoredByDelegateItems.length === 0 || !ignoredByDelegateItems.includes(e.detail.target)) { | ||
this.index = findNextNavigableIndex(this.items, this.index, this.options.wrap); | ||
} | ||
} | ||
function onClick(e) { | ||
var itemIndex = this.indexOf(e.target.closest(this._itemSelector)); | ||
if (isIndexNavigable(this.items, itemIndex)) { | ||
this.index = itemIndex; | ||
} | ||
} | ||
function onKeyHome(e) { | ||
var ignoredByDelegateItems = findIgnoredByDelegateItems(this._el, this.options); | ||
function onKeyHome(e) { | ||
if (isButton(e.detail.target) === false || this.options.ignoreButtons === false) { | ||
this.index = 0; | ||
// todo: update KeyEmitter to deal with ignored items? | ||
if (ignoredByDelegateItems.length === 0 || !ignoredByDelegateItems.includes(e.detail.target)) { | ||
this.index = findFirstNavigableIndex(this.items); | ||
} | ||
} | ||
function onKeyEnd(e) { | ||
var ignoredByDelegateItems = findIgnoredByDelegateItems(this._el, this.options); | ||
function onKeyEnd(e) { | ||
if (isButton(e.detail.target) === false || this.options.ignoreButtons === false) { | ||
this.index = this.filteredItems.length; | ||
// todo: update KeyEmitter to deal with ignored items? | ||
if (ignoredByDelegateItems.length === 0 || !ignoredByDelegateItems.includes(e.detail.target)) { | ||
this.index = findLastNavigableIndex(this.items); | ||
} | ||
} | ||
function onFocusExit() { | ||
@@ -119,109 +163,143 @@ if (this.options.autoReset !== null) { | ||
} | ||
function onMutation(e) { | ||
var fromIndex = this.index; | ||
var toIndex = this.index; | ||
// https://developer.mozilla.org/en-US/docs/Web/API/MutationRecord | ||
var { | ||
addedNodes, | ||
attributeName, | ||
removedNodes, | ||
target, | ||
type | ||
} = e[0]; | ||
if (type === 'attributes') { | ||
if (target === this.currentItem) { | ||
if (attributeName === 'aria-disabled') { | ||
// current item was disabled - keep it as current index (until a keyboard navigation happens) | ||
toIndex = this.index; | ||
} else if (attributeName === 'hidden') { | ||
// current item was hidden and focus is lost - reset index to first interactive element | ||
toIndex = findFirstNavigableIndex(this.items); | ||
} | ||
} else { | ||
toIndex = this.index; | ||
} | ||
} else if (type === 'childList') { | ||
if (removedNodes.length > 0 && [...removedNodes].includes(this._cachedElement)) { | ||
// current item was removed and focus is lost - reset index to first interactive element | ||
toIndex = findFirstNavigableIndex(this.items); | ||
} else if (removedNodes.length > 0 || addedNodes.length > 0) { | ||
// nodes were added and/or removed - keep current item and resync its index | ||
toIndex = this.indexOf(this._cachedElement); | ||
} | ||
} | ||
this._index = toIndex; | ||
this._el.dispatchEvent(new CustomEvent('navigationModelMutation', { | ||
bubbles: false, | ||
detail: { | ||
fromIndex, | ||
toIndex | ||
} | ||
})); | ||
} | ||
class NavigationModel { | ||
/** | ||
* @param {HTMLElement} el | ||
* @param {string} itemSelector | ||
* @param {typeof defaultOptions} selectedOptions | ||
*/ | ||
constructor(el, itemSelector, selectedOptions) { | ||
/** @member {typeof defaultOptions} */ | ||
this.options = Object.assign({}, defaultOptions, selectedOptions); | ||
function onMutation() { | ||
// clear data-makeup-index on ALL items | ||
clearData(this.items); // set data-makeup-index only on filtered items (e.g. non-hidden ones) | ||
/** @member {HTMLElement} */ | ||
this._el = el; | ||
setData(this.filteredItems); | ||
if (this.index >= this.items.length) { | ||
// do not use index setter, it will trigger change event | ||
this._index = this.options.autoReset || this.options.autoInit; | ||
/** @member {string} */ | ||
this._itemSelector = itemSelector; | ||
} | ||
this._el.dispatchEvent(new CustomEvent('navigationModelMutation')); | ||
} | ||
class LinearNavigationModel extends NavigationModel { | ||
/** | ||
* @param {HTMLElement} el | ||
* @param {string} itemSelector | ||
* @param {typeof defaultOptions} selectedOptions | ||
*/ | ||
constructor(el, itemSelector, selectedOptions) { | ||
super(el, itemSelector, selectedOptions); | ||
var fromIndex = this._index; | ||
var toIndex = findIndexPositionByType(this.options.autoInit, this.items, this.index); | ||
var NavigationModel = /*#__PURE__*/_createClass(function NavigationModel(el, itemSelector, selectedOptions) { | ||
_classCallCheck(this, NavigationModel); | ||
// do not use setter as it will trigger a change event | ||
this._index = toIndex; | ||
this.options = Object.assign({}, defaultOptions, selectedOptions); | ||
this._el = el; | ||
this._itemSelector = itemSelector; | ||
}); | ||
// always keep an element reference to the last item (for use in mutation observer) | ||
// todo: convert index to Tuple to store last/current values instead? | ||
this._cachedElement = this.items[toIndex]; | ||
this._el.dispatchEvent(new CustomEvent('navigationModelInit', { | ||
bubbles: false, | ||
detail: { | ||
firstInteractiveIndex: this.firstNavigableIndex, | ||
fromIndex, | ||
items: this.items, | ||
toIndex | ||
} | ||
})); | ||
} | ||
get currentItem() { | ||
return this.items[this.index]; | ||
} | ||
var LinearNavigationModel = /*#__PURE__*/function (_NavigationModel) { | ||
_inherits(LinearNavigationModel, _NavigationModel); | ||
// todo: code smell as getter abstracts that the query selector re-runs every time getter is accessed | ||
get items() { | ||
return [...this._el.querySelectorAll("".concat(this._itemSelector))]; | ||
} | ||
get index() { | ||
return this._index; | ||
} | ||
var _super = _createSuper(LinearNavigationModel); | ||
function LinearNavigationModel(el, itemSelector, selectedOptions) { | ||
var _this; | ||
_classCallCheck(this, LinearNavigationModel); | ||
_this = _super.call(this, el, itemSelector, selectedOptions); | ||
if (_this.options.autoInit !== null) { | ||
_this._index = _this.options.autoInit; | ||
_this._el.dispatchEvent(new CustomEvent('navigationModelInit', { | ||
/** | ||
* @param {number} toIndex - update index position in this.items (non-interactive indexes fail silently) | ||
*/ | ||
set index(toIndex) { | ||
if (toIndex === this.index) { | ||
return; | ||
} else if (!isIndexNavigable(this.items, toIndex)) { | ||
// no-op. throw exception? | ||
} else { | ||
var fromIndex = this.index; | ||
// update cached element reference (for use in mutation observer if DOM node gets removed) | ||
this._cachedElement = this.items[toIndex]; | ||
this._index = toIndex; | ||
this._el.dispatchEvent(new CustomEvent('navigationModelChange', { | ||
bubbles: false, | ||
detail: { | ||
items: _this.filteredItems, | ||
toIndex: _this.options.autoInit | ||
}, | ||
bubbles: false | ||
fromIndex, | ||
toIndex | ||
} | ||
})); | ||
} | ||
return _this; | ||
} | ||
_createClass(LinearNavigationModel, [{ | ||
key: "items", | ||
get: function get() { | ||
return this._el.querySelectorAll(this._itemSelector); | ||
indexOf(element) { | ||
return this.items.indexOf(element); | ||
} | ||
reset() { | ||
var fromIndex = this.index; | ||
var toIndex = findIndexPositionByType(this.options.autoReset, this.items, this.index); | ||
if (toIndex !== fromIndex) { | ||
// do not use setter as it will trigger a navigationModelChange event | ||
this._index = toIndex; | ||
this._el.dispatchEvent(new CustomEvent('navigationModelReset', { | ||
bubbles: false, | ||
detail: { | ||
fromIndex, | ||
toIndex | ||
} | ||
})); | ||
} | ||
}, { | ||
key: "filteredItems", | ||
get: function get() { | ||
return Array.prototype.slice.call(this.items).filter(itemFilter); | ||
} | ||
}, { | ||
key: "index", | ||
get: function get() { | ||
return this._index; | ||
}, | ||
set: function set(newIndex) { | ||
if (newIndex > -1 && newIndex < this.filteredItems.length && newIndex !== this.index) { | ||
this._el.dispatchEvent(new CustomEvent('navigationModelChange', { | ||
detail: { | ||
fromIndex: this.index, | ||
toIndex: newIndex | ||
}, | ||
bubbles: false | ||
})); | ||
} | ||
} | ||
this._index = newIndex; | ||
} | ||
} | ||
}, { | ||
key: "reset", | ||
value: function reset() { | ||
if (this.options.autoReset !== null) { | ||
this._index = this.options.autoReset; // do not use index setter, it will trigger change event | ||
// 2D Grid Model will go here | ||
this._el.dispatchEvent(new CustomEvent('navigationModelReset', { | ||
detail: { | ||
toIndex: this.options.autoReset | ||
}, | ||
bubbles: false | ||
})); | ||
} | ||
} | ||
}, { | ||
key: "atEnd", | ||
value: function atEnd() { | ||
return this.index === this.filteredItems.length - 1; | ||
} | ||
}, { | ||
key: "atStart", | ||
value: function atStart() { | ||
return this.index <= 0; | ||
} | ||
}]); | ||
return LinearNavigationModel; | ||
}(NavigationModel); // 2D Grid Model will go here | ||
/* | ||
@@ -236,7 +314,8 @@ class GridModel extends NavigationModel { | ||
var NavigationEmitter = /*#__PURE__*/function () { | ||
function NavigationEmitter(el, model) { | ||
_classCallCheck(this, NavigationEmitter); | ||
class NavigationEmitter { | ||
/** | ||
* @param {HTMLElement} el | ||
* @param {LinearNavigationModel} model | ||
*/ | ||
constructor(el, model) { | ||
this.model = model; | ||
@@ -251,7 +330,5 @@ this.el = el; | ||
this._observer = new MutationObserver(onMutation.bind(model)); | ||
setData(model.filteredItems); | ||
KeyEmitter.addKeyDown(this.el); | ||
ExitEmitter.addFocusExit(this.el); | ||
var axis = model.options.axis; | ||
if (axis === 'both' || axis === 'x') { | ||
@@ -261,3 +338,2 @@ this.el.addEventListener('arrowLeftKeyDown', this._keyPrevListener); | ||
} | ||
if (axis === 'both' || axis === 'y') { | ||
@@ -267,3 +343,2 @@ this.el.addEventListener('arrowUpKeyDown', this._keyPrevListener); | ||
} | ||
this.el.addEventListener('homeKeyDown', this._keyHomeListener); | ||
@@ -273,32 +348,24 @@ this.el.addEventListener('endKeyDown', this._keyEndListener); | ||
this.el.addEventListener('focusExit', this._focusExitListener); | ||
this._observer.observe(this.el, { | ||
childList: true, | ||
subtree: true, | ||
attributeFilter: ['hidden'], | ||
attributes: true | ||
attributeFilter: ['aria-disabled', 'hidden'], | ||
attributes: true, | ||
attributeOldValue: true | ||
}); | ||
} | ||
_createClass(NavigationEmitter, [{ | ||
key: "destroy", | ||
value: function destroy() { | ||
KeyEmitter.removeKeyDown(this.el); | ||
ExitEmitter.removeFocusExit(this.el); | ||
this.el.removeEventListener('arrowLeftKeyDown', this._keyPrevListener); | ||
this.el.removeEventListener('arrowRightKeyDown', this._keyNextListener); | ||
this.el.removeEventListener('arrowUpKeyDown', this._keyPrevListener); | ||
this.el.removeEventListener('arrowDownKeyDown', this._keyNextListener); | ||
this.el.removeEventListener('homeKeyDown', this._keyHomeListener); | ||
this.el.removeEventListener('endKeyDown', this._keyEndListener); | ||
this.el.removeEventListener('click', this._clickListener); | ||
this.el.removeEventListener('focusExit', this._focusExitListener); | ||
this._observer.disconnect(); | ||
} | ||
}]); | ||
return NavigationEmitter; | ||
}(); | ||
destroy() { | ||
KeyEmitter.removeKeyDown(this.el); | ||
ExitEmitter.removeFocusExit(this.el); | ||
this.el.removeEventListener('arrowLeftKeyDown', this._keyPrevListener); | ||
this.el.removeEventListener('arrowRightKeyDown', this._keyNextListener); | ||
this.el.removeEventListener('arrowUpKeyDown', this._keyPrevListener); | ||
this.el.removeEventListener('arrowDownKeyDown', this._keyNextListener); | ||
this.el.removeEventListener('homeKeyDown', this._keyHomeListener); | ||
this.el.removeEventListener('endKeyDown', this._keyEndListener); | ||
this.el.removeEventListener('click', this._clickListener); | ||
this.el.removeEventListener('focusExit', this._focusExitListener); | ||
this._observer.disconnect(); | ||
} | ||
} | ||
function createLinear(el, itemSelector, selectedOptions) { | ||
@@ -308,2 +375,3 @@ var model = new LinearNavigationModel(el, itemSelector, selectedOptions); | ||
} | ||
/* | ||
@@ -310,0 +378,0 @@ static createGrid(el, rowSelector, colSelector, selectedOptions) { |
import * as KeyEmitter from "makeup-key-emitter"; | ||
import * as ExitEmitter from "makeup-exit-emitter"; | ||
const dataSetKey = "data-makeup-index"; | ||
const defaultOptions = { | ||
axis: "both", | ||
autoInit: 0, | ||
autoReset: null, | ||
ignoreButtons: false, | ||
autoInit: "interactive", | ||
autoReset: "current", | ||
ignoreByDelegateSelector: null, | ||
wrap: false | ||
}; | ||
const itemFilter = (el) => !el.hidden; | ||
function clearData(els) { | ||
els.forEach((el) => el.removeAttribute(dataSetKey)); | ||
function isItemNavigable(el) { | ||
return !el.hidden && el.getAttribute("aria-disabled") !== "true"; | ||
} | ||
function setData(els) { | ||
els.forEach((el, index) => el.setAttribute(dataSetKey, index)); | ||
function isIndexNavigable(items, index) { | ||
return index >= 0 && index < items.length ? isItemNavigable(items[index]) : false; | ||
} | ||
function isButton(el) { | ||
return el.tagName.toLowerCase() === "button" || el.type === "button"; | ||
function findNavigableItems(items) { | ||
return items.filter(isItemNavigable); | ||
} | ||
function onKeyPrev(e) { | ||
if (isButton(e.detail.target) === false || this.options.ignoreButtons === false) { | ||
if (!this.atStart()) { | ||
this.index--; | ||
} else if (this.options.wrap) { | ||
this.index = this.filteredItems.length - 1; | ||
function findFirstNavigableIndex(items) { | ||
return items.findIndex((item) => isItemNavigable(item)); | ||
} | ||
function findLastNavigableIndex(items) { | ||
return items.indexOf(findNavigableItems(items).reverse()[0]); | ||
} | ||
function findIndexByAttribute(items, attribute, value) { | ||
return items.findIndex((item) => isItemNavigable(item) && item.getAttribute(attribute) === value); | ||
} | ||
function findFirstNavigableAriaCheckedIndex(items) { | ||
return findIndexByAttribute(items, "aria-checked", "true"); | ||
} | ||
function findFirstNavigableAriaSelectedIndex(items) { | ||
return findIndexByAttribute(items, "aria-selected", "true"); | ||
} | ||
function findIgnoredByDelegateItems(el, options) { | ||
return options.ignoreByDelegateSelector !== null ? [...el.querySelectorAll(options.ignoreByDelegateSelector)] : []; | ||
} | ||
function findPreviousNavigableIndex(items, index, wrap) { | ||
let previousNavigableIndex = -1; | ||
if (index === null) { | ||
} else if (atStart(items, index)) { | ||
if (wrap === true) { | ||
previousNavigableIndex = findLastNavigableIndex(items); | ||
} | ||
} else { | ||
let i = index; | ||
while (--i >= 0) { | ||
if (isItemNavigable(items[i])) { | ||
previousNavigableIndex = i; | ||
break; | ||
} | ||
} | ||
} | ||
return previousNavigableIndex; | ||
} | ||
function onKeyNext(e) { | ||
if (isButton(e.detail.target) === false || this.options.ignoreButtons === false) { | ||
if (!this.atEnd()) { | ||
this.index++; | ||
} else if (this.options.wrap) { | ||
this.index = 0; | ||
function findNextNavigableIndex(items, index, wrap) { | ||
let nextNavigableIndex = -1; | ||
if (index === null) { | ||
nextNavigableIndex = findFirstNavigableIndex(items); | ||
} else if (atEnd(items, index)) { | ||
if (wrap === true) { | ||
nextNavigableIndex = findFirstNavigableIndex(items); | ||
} | ||
} else { | ||
let i = index; | ||
while (++i < items.length) { | ||
if (isItemNavigable(items[i])) { | ||
nextNavigableIndex = i; | ||
break; | ||
} | ||
} | ||
} | ||
return nextNavigableIndex; | ||
} | ||
function onClick(e) { | ||
let element = e.target; | ||
let indexData = element.dataset.makeupIndex; | ||
while (element !== this._el && !indexData) { | ||
element = element.parentNode; | ||
indexData = element.dataset.makeupIndex; | ||
function findIndexPositionByType(typeOrNum, items, currentIndex) { | ||
let index = -1; | ||
switch (typeOrNum) { | ||
case "none": | ||
index = null; | ||
break; | ||
case "current": | ||
index = currentIndex; | ||
break; | ||
case "interactive": | ||
index = findFirstNavigableIndex(items); | ||
break; | ||
case "ariaChecked": | ||
index = findFirstNavigableAriaCheckedIndex(items); | ||
break; | ||
case "ariaSelected": | ||
index = findFirstNavigableAriaSelectedIndex(items); | ||
break; | ||
case "ariaSelectedOrInteractive": | ||
index = findFirstNavigableAriaSelectedIndex(items); | ||
index = index === -1 ? findFirstNavigableIndex(items) : index; | ||
break; | ||
default: | ||
index = typeof typeOrNum === "number" || typeOrNum === null ? typeOrNum : -1; | ||
} | ||
if (indexData !== void 0) { | ||
this.index = indexData; | ||
return index; | ||
} | ||
function atStart(items, index) { | ||
return index === findFirstNavigableIndex(items); | ||
} | ||
function atEnd(items, index) { | ||
return index === findLastNavigableIndex(items); | ||
} | ||
function onKeyPrev(e) { | ||
const ignoredByDelegateItems = findIgnoredByDelegateItems(this._el, this.options); | ||
if (ignoredByDelegateItems.length === 0 || !ignoredByDelegateItems.includes(e.detail.target)) { | ||
this.index = findPreviousNavigableIndex(this.items, this.index, this.options.wrap); | ||
} | ||
} | ||
function onKeyNext(e) { | ||
const ignoredByDelegateItems = findIgnoredByDelegateItems(this._el, this.options); | ||
if (ignoredByDelegateItems.length === 0 || !ignoredByDelegateItems.includes(e.detail.target)) { | ||
this.index = findNextNavigableIndex(this.items, this.index, this.options.wrap); | ||
} | ||
} | ||
function onClick(e) { | ||
const itemIndex = this.indexOf(e.target.closest(this._itemSelector)); | ||
if (isIndexNavigable(this.items, itemIndex)) { | ||
this.index = itemIndex; | ||
} | ||
} | ||
function onKeyHome(e) { | ||
if (isButton(e.detail.target) === false || this.options.ignoreButtons === false) { | ||
this.index = 0; | ||
const ignoredByDelegateItems = findIgnoredByDelegateItems(this._el, this.options); | ||
if (ignoredByDelegateItems.length === 0 || !ignoredByDelegateItems.includes(e.detail.target)) { | ||
this.index = findFirstNavigableIndex(this.items); | ||
} | ||
} | ||
function onKeyEnd(e) { | ||
if (isButton(e.detail.target) === false || this.options.ignoreButtons === false) { | ||
this.index = this.filteredItems.length; | ||
const ignoredByDelegateItems = findIgnoredByDelegateItems(this._el, this.options); | ||
if (ignoredByDelegateItems.length === 0 || !ignoredByDelegateItems.includes(e.detail.target)) { | ||
this.index = findLastNavigableIndex(this.items); | ||
} | ||
@@ -65,9 +142,28 @@ } | ||
} | ||
function onMutation() { | ||
clearData(this.items); | ||
setData(this.filteredItems); | ||
if (this.index >= this.items.length) { | ||
this._index = this.options.autoReset || this.options.autoInit; | ||
function onMutation(e) { | ||
const fromIndex = this.index; | ||
let toIndex = this.index; | ||
const { addedNodes, attributeName, removedNodes, target, type } = e[0]; | ||
if (type === "attributes") { | ||
if (target === this.currentItem) { | ||
if (attributeName === "aria-disabled") { | ||
toIndex = this.index; | ||
} else if (attributeName === "hidden") { | ||
toIndex = findFirstNavigableIndex(this.items); | ||
} | ||
} else { | ||
toIndex = this.index; | ||
} | ||
} else if (type === "childList") { | ||
if (removedNodes.length > 0 && [...removedNodes].includes(this._cachedElement)) { | ||
toIndex = findFirstNavigableIndex(this.items); | ||
} else if (removedNodes.length > 0 || addedNodes.length > 0) { | ||
toIndex = this.indexOf(this._cachedElement); | ||
} | ||
} | ||
this._el.dispatchEvent(new CustomEvent("navigationModelMutation")); | ||
this._index = toIndex; | ||
this._el.dispatchEvent(new CustomEvent("navigationModelMutation", { | ||
bubbles: false, | ||
detail: { fromIndex, toIndex } | ||
})); | ||
} | ||
@@ -84,51 +180,53 @@ class NavigationModel { | ||
super(el, itemSelector, selectedOptions); | ||
if (this.options.autoInit !== null) { | ||
this._index = this.options.autoInit; | ||
this._el.dispatchEvent(new CustomEvent("navigationModelInit", { | ||
detail: { | ||
items: this.filteredItems, | ||
toIndex: this.options.autoInit | ||
}, | ||
bubbles: false | ||
})); | ||
} | ||
const fromIndex = this._index; | ||
const toIndex = findIndexPositionByType(this.options.autoInit, this.items, this.index); | ||
this._index = toIndex; | ||
this._cachedElement = this.items[toIndex]; | ||
this._el.dispatchEvent(new CustomEvent("navigationModelInit", { | ||
bubbles: false, | ||
detail: { | ||
firstInteractiveIndex: this.firstNavigableIndex, | ||
fromIndex, | ||
items: this.items, | ||
toIndex | ||
} | ||
})); | ||
} | ||
get currentItem() { | ||
return this.items[this.index]; | ||
} | ||
get items() { | ||
return this._el.querySelectorAll(this._itemSelector); | ||
return [...this._el.querySelectorAll(`${this._itemSelector}`)]; | ||
} | ||
get filteredItems() { | ||
return Array.prototype.slice.call(this.items).filter(itemFilter); | ||
} | ||
get index() { | ||
return this._index; | ||
} | ||
set index(newIndex) { | ||
if (newIndex > -1 && newIndex < this.filteredItems.length && newIndex !== this.index) { | ||
set index(toIndex) { | ||
if (toIndex === this.index) { | ||
return; | ||
} else if (!isIndexNavigable(this.items, toIndex)) { | ||
} else { | ||
const fromIndex = this.index; | ||
this._cachedElement = this.items[toIndex]; | ||
this._index = toIndex; | ||
this._el.dispatchEvent(new CustomEvent("navigationModelChange", { | ||
detail: { | ||
fromIndex: this.index, | ||
toIndex: newIndex | ||
}, | ||
bubbles: false | ||
bubbles: false, | ||
detail: { fromIndex, toIndex } | ||
})); | ||
this._index = newIndex; | ||
} | ||
} | ||
indexOf(element) { | ||
return this.items.indexOf(element); | ||
} | ||
reset() { | ||
if (this.options.autoReset !== null) { | ||
this._index = this.options.autoReset; | ||
const fromIndex = this.index; | ||
const toIndex = findIndexPositionByType(this.options.autoReset, this.items, this.index); | ||
if (toIndex !== fromIndex) { | ||
this._index = toIndex; | ||
this._el.dispatchEvent(new CustomEvent("navigationModelReset", { | ||
detail: { | ||
toIndex: this.options.autoReset | ||
}, | ||
bubbles: false | ||
bubbles: false, | ||
detail: { fromIndex, toIndex } | ||
})); | ||
} | ||
} | ||
atEnd() { | ||
return this.index === this.filteredItems.length - 1; | ||
} | ||
atStart() { | ||
return this.index <= 0; | ||
} | ||
} | ||
@@ -146,3 +244,2 @@ class NavigationEmitter { | ||
this._observer = new MutationObserver(onMutation.bind(model)); | ||
setData(model.filteredItems); | ||
KeyEmitter.addKeyDown(this.el); | ||
@@ -166,4 +263,5 @@ ExitEmitter.addFocusExit(this.el); | ||
subtree: true, | ||
attributeFilter: ["hidden"], | ||
attributes: true | ||
attributeFilter: ["aria-disabled", "hidden"], | ||
attributes: true, | ||
attributeOldValue: true | ||
}); | ||
@@ -170,0 +268,0 @@ } |
{ | ||
"name": "makeup-navigation-emitter", | ||
"description": "Emits custom events based on keyboard navigation of one or two dimensional model", | ||
"version": "0.5.1", | ||
"version": "0.6.0", | ||
"main": "./dist/cjs/index.js", | ||
@@ -6,0 +6,0 @@ "module": "dist/mjs/index.js", |
@@ -5,3 +5,3 @@ # makeup-navigation-emitter | ||
This module can be used as the underlying logic & state for both roving-tabindex and active-descendant behaviour. | ||
This module can be used as the underlying logic & state for both roving-tabindex and active-descendant (hierarchical & programmatic) behaviour. | ||
@@ -12,2 +12,4 @@ ## Experimental | ||
**NOTE**: All examples below show *abstract* markup examples/structures. In an effort to make clear what this module does and does not do, all examples **do not** include ARIA roles, state or properties. | ||
## Example 1 | ||
@@ -17,3 +19,3 @@ | ||
With keyboard focus on any list item element, arrow keys will update the underlying model. | ||
With keyboard focus on any list item element, arrow keys will update the underlying index position in relation to the list of items. | ||
@@ -47,6 +49,8 @@ **NOTE:** this module will not actually modify the DOM or change any keyboard focus, that is the job of an observer such as makeup-roving-tabindex (which consumes this module). | ||
Example support for an active descendant model of navigation with keyboard focus on ancestor of items (typical of listbox pattern). Again, the list items form a one-dimensional model of navigation. | ||
Example support for an active descendant model of navigation with keyboard focus on ancestor of items (typical of listbox pattern). | ||
With keyboard focus on the widget, arrow keys will update the underlying model. | ||
With keyboard focus on the widget, arrow keys will update the underlying index position in relation to the list of items. | ||
Note that this module will not highlight the active item, that is the job of an observer such as makeup-active-descendant. | ||
```html | ||
@@ -67,3 +71,3 @@ <div class="widget" tabindex="0"> | ||
var emitter = NavigationEmitter.createLinear(widgetEl, 'li', { autoInit: -1, autoReset: -1 })); | ||
var emitter = NavigationEmitter.createLinear(widgetEl, 'li')); | ||
@@ -77,5 +81,5 @@ widgetEl.addEventListener('navigationModelChange', function(e) { | ||
Example support for an active descendant model of navigation with focus on non-ancestor of items (typical of combobox pattern). Once more, the list elements form the one-dimensional model. | ||
Example support for an active descendant model of navigation with keyboard focus on non-ancestor of items (typical of combobox pattern). | ||
With keyboard focus on the textbox, arrow keys will update the underlying model. | ||
With keyboard focus on the textbox, arrow keys will update the underlying index position in relation to the list of items. | ||
@@ -100,3 +104,3 @@ Note that this module will not highlight the active item, that is the job of an observer such as makeup-active-descendant. | ||
var emitter = NavigationEmitter.createLinear(widgetEl, 'li', { autoInit: -1, autoReset: -1 })); | ||
var emitter = NavigationEmitter.createLinear(widgetEl, 'li', { autoInit: 'none', autoReset: 'none' })); | ||
@@ -110,6 +114,18 @@ widgetEl.addEventListener('navigationModelChange', function(e) { | ||
* `autoInit`: specify an integer or -1 for initial index (default: 0) | ||
* `autoReset`: specify an integer or -1 for index position when focus exits widget (default: null) | ||
* `autoInit`: declares the initial item (default: "interactive"). Possible values are: | ||
* "none": no index position is set (useful in programmatic active-descendant) | ||
* "interactive": first non aria-disabled or hidden element (default) | ||
* "ariaChecked": first element with aria-checked=true (useful in ARIA menu) | ||
* "ariaSelected": first element with aria-selected=true (useful in ARIA tabs) | ||
* "ariaSelectedOrInteractive": first element with aria-selected=true, falling back to "interactive" if not found (useful in ARIA listbox) | ||
* *number*: specific index position of items (throws error if non-interactive) | ||
* `autoReset`: declares the item after a reset and/or when keyboard focus exits the widget (default: "current"). Possible values are: | ||
* "none": no index position is set (useful in programmatic active-descendant) | ||
* "current": index remains current (radio button like behaviour) | ||
* "interactive": index moves to first non aria-disabled or hidden element | ||
* "ariaChecked": index moves to first element with aria-checked=true | ||
* "ariaSelected": index moves to first element with aria-selected=true | ||
* *number*: specific index position of items (throws error if non-interactive) | ||
* `axis` : specify 'x' for left/right arrow keys, 'y' for up/down arrow keys, or 'both' (default: 'both') | ||
* `ignoreButtons`: if set to true, nested button elements will not trigger navigationModelChange events. This is useful in a combobox + button scenario, where only the textbox should trigger navigationModelChange events (default: false) | ||
* `ignoreByDelegateSelector`: CSS selector of descendant elements that will be ignored by the key event delegation (i.e. these elements will *not* operate the navigation emitter) (default: null) | ||
* `wrap` : specify whether arrow keys should wrap/loop (default: false) | ||
@@ -124,11 +140,10 @@ | ||
* `items`: returns all items that match item selector | ||
* `filteredItems`: returns filtered items (e.g. non-hidden items) | ||
* `matchingItems`: returns all items that match item selector | ||
* `navigableItems`: returns navigable subset of matchingItems (e.g. non-hidden & non aria-disabled items) | ||
## Events | ||
* `navigationModelInit` - fired when the model is auto initialised | ||
* `navigationModelChange` - fired when the index is set by any means other than auto init or auto reset | ||
* `navigationModelReset` - fired when the model is auto reset | ||
For all 3 events, the event detail object contains the `fromIndex` and `toIndex`. | ||
* `navigationModelInit` - fired when the model is auto initialised (bubbles: false) | ||
* `navigationModelChange` - fired when the index is set by any means other than auto init or auto reset (bubbles: false) | ||
* `navigationModelReset` - fired when the model is auto reset (bubbles: false) | ||
* `navigationModelMutation` - fired when any changes to the elements DOM (bubbles: false) |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
33353
7
705
141
1