lion/packages/listbox/src/ListboxMixin.js
2020-09-09 12:21:22 +02:00

697 lines
21 KiB
JavaScript

import { ChoiceGroupMixin, FormControlMixin, FormRegistrarMixin } from '@lion/form-core';
import { css, html, dedupeMixin, ScopedElementsMixin, SlotMixin } from '@lion/core';
import '@lion/core/src/differentKeyEventNamesShimIE.js';
import { LionOptions } from './LionOptions.js';
// TODO: extract ListNavigationWithActiveDescendantMixin that can be reused in [role="menu"]
// having children with [role="menuitem|menuitemcheckbox|menuitemradio|option"] and
// list items that can be found via MutationObserver or registration (.formElements)
/**
* @typedef {import('./LionOption').LionOption} LionOption
* @typedef {import('../types/ListboxMixinTypes').ListboxMixin} ListboxMixin
* @typedef {import('../types/ListboxMixinTypes').ListboxHost} ListboxHost
* @typedef {import('@lion/form-core/types/registration/FormRegistrarPortalMixinTypes').FormRegistrarPortalHost} FormRegistrarPortalHost
*/
function uuid() {
return Math.random().toString(36).substr(2, 10);
}
/**
* @param {HTMLElement} container
* @param {HTMLElement} element
* @param {Boolean} [partial]
*/
function isInView(container, element, partial = false) {
const cTop = container.scrollTop;
const cBottom = cTop + container.clientHeight;
const eTop = element.offsetTop;
const eBottom = eTop + element.clientHeight;
const isTotal = eTop >= cTop && eBottom <= cBottom;
let isPartial;
if (partial === true) {
isPartial = (eTop < cTop && eBottom > cTop) || (eBottom > cBottom && eTop < cBottom);
} else if (typeof partial === 'number') {
if (eTop < cTop && eBottom > cTop) {
isPartial = ((eBottom - cTop) * 100) / element.clientHeight > partial;
} else if (eBottom > cBottom && eTop < cBottom) {
isPartial = ((cBottom - eTop) * 100) / element.clientHeight > partial;
}
}
return isTotal || isPartial;
}
/**
* @type {ListboxMixin}
* @param {import('@open-wc/dedupe-mixin').Constructor<import('@lion/core').LitElement>} superclass
*/
const ListboxMixinImplementation = superclass =>
class ListboxMixin extends FormControlMixin(
ScopedElementsMixin(ChoiceGroupMixin(SlotMixin(FormRegistrarMixin(superclass)))),
) {
static get properties() {
return {
orientation: String,
selectionFollowsFocus: {
type: Boolean,
attribute: 'selection-follows-focus',
},
rotateKeyboardNavigation: {
type: Boolean,
attribute: 'rotate-keyboard-navigation',
},
hasNoDefaultSelected: {
type: Boolean,
reflect: true,
attribute: 'has-no-default-selected',
},
};
}
static get styles() {
return [
css`
:host {
display: block;
}
:host([hidden]) {
display: none;
}
:host([disabled]) {
color: #adadad;
}
:host([orientation='horizontal']) ::slotted([role='listbox']) {
display: flex;
}
`,
];
}
static get scopedElements() {
return {
...super.scopedElements,
'lion-options': LionOptions,
};
}
get slots() {
return {
...super.slots,
input: () => {
const lionOptions = /** @type {HTMLElement & FormRegistrarPortalHost} */ (document.createElement(
ListboxMixin.getScopedTagName('lion-options'),
));
lionOptions.registrationTarget = this;
return lionOptions;
},
};
}
get _listboxNode() {
return /** @type {LionOptions} */ (this._inputNode);
}
get _listboxActiveDescendantNode() {
return this._listboxNode.querySelector(`#${this._listboxActiveDescendant}`);
}
get serializedValue() {
return this.modelValue;
}
// Duplicating from FormGroupMixin, because you cannot independently inherit/override getter + setter.
// If you override one, gotta override the other, they go in pairs.
set serializedValue(value) {
super.serializedValue = value;
}
/**
* @type {number | number[]}
*/
get checkedIndex() {
const options = this.formElements;
if (!this.multipleChoice) {
return options.indexOf(options.find(o => o.checked));
}
return options.filter(o => o.checked).map(o => options.indexOf(o));
}
/**
* @deprecated
* This setter exists for backwards compatibility of single choice groups.
* A setter api would be confusing for a multipleChoice group. Use `setCheckedIndex` instead.
* @param {number} index
*/
set checkedIndex(index) {
this.setCheckedIndex(index);
}
/**
* When `multipleChoice` is false, will toggle, else will check provided index
* @param {Number} index
*/
setCheckedIndex(index) {
if (this.formElements[index]) {
if (!this.multipleChoice) {
this.formElements[index].checked = true;
} else {
this.formElements[index].checked = !this.formElements[index].checked;
// __onChildCheckedChanged, which also responds to programmatic (model)value changes
// of children, will do the rest
}
}
}
get activeIndex() {
return this.formElements.findIndex(el => el.active === true);
}
get _scrollTargetNode() {
return this._listboxNode;
}
set activeIndex(index) {
if (this.formElements[index]) {
const el = this.formElements[index];
el.active = true;
if (!isInView(this._scrollTargetNode, el)) {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
}
constructor() {
super();
// this.disabled = false;
/**
* When setting this to true, on initial render, no option will be selected.
* It it advisable to override `_noSelectionTemplate` method in the select-invoker
* to render some kind of placeholder initially
*/
this.hasNoDefaultSelected = false;
/**
* Informs screen reader and affects keyboard navigation.
* By default 'vertical'
*/
this.orientation = 'vertical';
/**
* Will give first option active state when navigated to the next option from
* last option.
*/
this.rotateKeyboardNavigation = false;
/**
* When true, will synchronize activedescendant and selected element on
* arrow key navigation.
* This behavior can usually be seen on <select> on the Windows platform.
* Note that this behavior cannot be used when multiple-choice is true.
* See: https://www.w3.org/TR/wai-aria-practices/#kbd_selection_follows_focus
*/
this.selectionFollowsFocus = false;
/** @type {number | null} */
this._listboxActiveDescendant = null;
this.__hasInitialSelectedFormElement = false;
this._repropagationRole = 'choice-group'; // configures FormControlMixin
/** @type {EventListener} */
this._listboxOnKeyDown = this._listboxOnKeyDown.bind(this);
/** @type {EventListener} */
this._listboxOnClick = this._listboxOnClick.bind(this);
/** @type {EventListener} */
this._listboxOnKeyUp = this._listboxOnKeyUp.bind(this);
/** @type {EventListener} */
this._onChildActiveChanged = this._onChildActiveChanged.bind(this);
/** @type {EventListener} */
this.__proxyChildModelValueChanged = this.__proxyChildModelValueChanged.bind(this);
}
connectedCallback() {
if (this._listboxNode) {
// if there is none yet, it will be supplied via static get slots
this._listboxNode.registrationTarget = this;
}
super.connectedCallback();
this.__setupListboxNode();
this.__setupEventListeners();
this.registrationComplete.then(() => {
this.__initInteractionStates();
});
}
/**
* @param {import('lit-element').PropertyValues } changedProperties
*/
firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
this.__moveOptionsToListboxNode();
}
/**
* Moves options put in regulat slot to slot wiht role=listbox
*/
__moveOptionsToListboxNode() {
const slot = /** @type {HTMLSlotElement} */ (
/** @type {ShadowRoot} */ (this.shadowRoot).getElementById('options-outlet')
);
if (slot) {
slot.addEventListener('slotchange', () => {
slot.assignedNodes().forEach(node => {
this._listboxNode.appendChild(node);
});
});
}
}
disconnectedCallback() {
super.disconnectedCallback();
this._teardownListboxNode();
this.__teardownEventListeners();
}
/**
* In the select disabled options are still going to a possible value for example
* when prefilling or programmatically setting it.
*
* @override
*/
_getCheckedElements() {
return this.formElements.filter(el => el.checked);
}
__initInteractionStates() {
this.initInteractionState();
}
// TODO: inherit from FormControl ?
get _inputNode() {
return /** @type {HTMLElement} */ (this.querySelector('[slot="input"]'));
}
/**
* @param {import('lit-element').PropertyValues } changedProperties
*/
updated(changedProperties) {
super.updated(changedProperties);
if (this.formElements.length === 1) {
this.singleOption = true;
// this._invokerNode.singleOption = true;
}
if (changedProperties.has('disabled')) {
if (this.disabled) {
// this._invokerNode.makeRequestToBeDisabled();
this.__requestOptionsToBeDisabled();
} else {
// this._invokerNode.retractRequestToBeDisabled();
this.__retractRequestOptionsToBeDisabled();
}
}
}
/**
* @override
*/
// eslint-disable-next-line
_inputGroupInputTemplate() {
return html`
<div class="input-group__input">
<slot name="input"></slot>
<slot id="options-outlet"></slot>
</div>
`;
}
/**
* Overrides FormRegistrar adding to make sure children have specific default states when added
*
* @override
* @param {LionOption} child
* @param {Number} indexToInsertAt
*/
// @ts-expect-error
addFormElement(child, indexToInsertAt) {
// @ts-expect-error
super.addFormElement(/** @type {FormControl} */ child, indexToInsertAt);
// we need to adjust the elements being registered
/* eslint-disable no-param-reassign */
child.id = child.id || `${this.localName}-option-${uuid()}`;
if (this.disabled) {
child.makeRequestToBeDisabled();
}
// the first elements checked by default
if (
!this.hasNoDefaultSelected &&
!this.__hasInitialSelectedFormElement &&
(!child.disabled || this.disabled)
) {
child.active = true;
child.checked = true;
this.__hasInitialSelectedFormElement = true;
}
// TODO: small perf improvement could be made if logic below would be scheduled to next update,
// so it occurs once for all options
this.__setAttributeForAllFormElements('aria-setsize', this.formElements.length);
this.formElements.forEach((el, idx) => {
el.setAttribute('aria-posinset', idx + 1);
});
this.__proxyChildModelValueChanged(
/** @type {Event & { target: LionOption; }} */ ({ target: child }),
);
this.resetInteractionState();
/* eslint-enable no-param-reassign */
}
__setupEventListeners() {
this._listboxNode.addEventListener(
'active-changed',
/** @type {EventListener} */ (this._onChildActiveChanged),
);
this._listboxNode.addEventListener(
'model-value-changed',
/** @type {EventListener} */ (this.__proxyChildModelValueChanged),
);
// this._listboxNode.addEventListener('checked-changed', this.__onChildCheckedChanged);
}
__teardownEventListeners() {
this._listboxNode.removeEventListener(
'active-changed',
/** @type {EventListener} */ (this._onChildActiveChanged),
);
this._listboxNode.removeEventListener(
'model-value-changed',
/** @type {EventListener} */ (this.__proxyChildModelValueChanged),
);
}
/**
* @param {Event & { target: LionOption }} ev
*/
_onChildActiveChanged({ target }) {
if (target.active === true) {
this.formElements.forEach(formElement => {
if (formElement !== target) {
// eslint-disable-next-line no-param-reassign
formElement.active = false;
}
});
this._listboxNode.setAttribute('aria-activedescendant', target.id);
}
}
/**
* @param {Event & { target: LionOption }} cfgOrEvent
*/
__onChildCheckedChanged(cfgOrEvent) {
const { target } = cfgOrEvent;
if (cfgOrEvent.stopPropagation) {
cfgOrEvent.stopPropagation();
}
if (target.checked) {
if (!this.multipleChoice) {
this.formElements.forEach(formElement => {
if (formElement !== target) {
// eslint-disable-next-line no-param-reassign
formElement.checked = false;
}
});
}
}
}
/**
* // TODO: add to choiceGroup
* @param {string} attribute
* @param {number} value
*/
__setAttributeForAllFormElements(attribute, value) {
this.formElements.forEach(formElement => {
formElement.setAttribute(attribute, value);
});
}
/**
* @param {Event & { target: LionOption; }} ev
*/
__proxyChildModelValueChanged(ev) {
// We need to redispatch the model-value-changed event on 'this', so it will
// align with FormControl.__repropagateChildrenValues method. Also, this makes
// it act like a portal, in case the listbox is put in a modal overlay on body level.
if (ev.stopPropagation) {
ev.stopPropagation();
}
this.__onChildCheckedChanged(ev);
this.requestUpdate('modelValue');
this.dispatchEvent(
new CustomEvent('model-value-changed', { detail: { element: ev.target } }),
);
}
/**
* @param {number} currentIndex
* @param {number} offset
*/
__getNextOption(currentIndex, offset) {
/**
* @param {number} i
*/
const until = i => (offset === 1 ? i < this.formElements.length : i >= 0);
for (let i = currentIndex + offset; until(i); i += offset) {
if (this.formElements[i] && !this.formElements[i].disabled) {
return i;
}
}
if (this.rotateKeyboardNavigation) {
const startIndex = offset === -1 ? this.formElements.length - 1 : 0;
for (let i = startIndex; until(i); i += 1) {
if (this.formElements[i] && !this.formElements[i].disabled) {
return i;
}
}
}
return currentIndex;
}
/**
* @param {number} currentIndex
* @param {number} [offset=1]
*/
_getNextEnabledOption(currentIndex, offset = 1) {
return this.__getNextOption(currentIndex, offset);
}
/**
* @param {number} currentIndex
* @param {number} [offset=-1]
*/
_getPreviousEnabledOption(currentIndex, offset = -1) {
return this.__getNextOption(currentIndex, offset);
}
/**
* @desc
* Handle various keyboard controls; UP/DOWN will shift focus; SPACE selects
* an item.
*
* @param {KeyboardEvent} ev - the keydown event object
*/
_listboxOnKeyDown(ev) {
if (this.disabled) {
return;
}
const { key } = ev;
switch (key) {
case 'Enter':
case ' ':
ev.preventDefault();
this.setCheckedIndex(this.activeIndex);
break;
case 'ArrowUp':
ev.preventDefault();
if (this.orientation === 'vertical') {
this.activeIndex = this._getPreviousEnabledOption(this.activeIndex);
}
break;
case 'ArrowLeft':
ev.preventDefault();
if (this.orientation === 'horizontal') {
this.activeIndex = this._getPreviousEnabledOption(this.activeIndex);
}
break;
case 'ArrowDown':
ev.preventDefault();
if (this.orientation === 'vertical') {
this.activeIndex = this._getNextEnabledOption(this.activeIndex);
}
break;
case 'ArrowRight':
ev.preventDefault();
if (this.orientation === 'horizontal') {
this.activeIndex = this._getNextEnabledOption(this.activeIndex);
}
break;
case 'Home':
ev.preventDefault();
this.activeIndex = this._getNextEnabledOption(0, 0);
break;
case 'End':
ev.preventDefault();
this.activeIndex = this._getPreviousEnabledOption(this.formElements.length - 1, 0);
break;
/* no default */
}
const keys = ['ArrowUp', 'ArrowDown', 'Home', 'End'];
if (keys.includes(key) && this.selectionFollowsFocus && !this.multipleChoice) {
this.setCheckedIndex(this.activeIndex);
}
}
// TODO: move to ChoiceGroupMixin?
__requestOptionsToBeDisabled() {
this.formElements.forEach(el => {
if (el.makeRequestToBeDisabled) {
el.makeRequestToBeDisabled();
}
});
}
__retractRequestOptionsToBeDisabled() {
this.formElements.forEach(el => {
if (el.retractRequestToBeDisabled) {
el.retractRequestToBeDisabled();
}
});
}
/**
* For ShadyDom the listboxNode is available right from the start so we can add those events
* immediately.
* For native ShadowDom the select gets render before the listboxNode is available so we
* will add an event to the slotchange and add the events once available.
*/
__setupListboxNode() {
if (this._listboxNode) {
this._setupListboxNodeInteractions();
} else {
const inputSlot = /** @type {ShadowRoot} */ (this.shadowRoot).querySelector(
'slot[name=input]',
);
if (inputSlot) {
inputSlot.addEventListener('slotchange', () => {
this._setupListboxNodeInteractions();
});
}
}
this.__preventScrollingWithArrowKeys = this.__preventScrollingWithArrowKeys.bind(this);
this._scrollTargetNode.addEventListener('keydown', this.__preventScrollingWithArrowKeys);
}
/**
* @overridable
* @param {MouseEvent} ev
*/
// eslint-disable-next-line class-methods-use-this, no-unused-vars
_listboxOnClick(ev) {
const option = /** @type {HTMLElement} */ (ev.target).closest('[role=option]');
const foundIndex = this.formElements.indexOf(option);
if (foundIndex > -1) {
this.activIndex = foundIndex;
}
}
/**
* @overridable
* @param {KeyboardEvent} ev
*/
// eslint-disable-next-line class-methods-use-this, no-unused-vars
_listboxOnKeyUp(ev) {
if (this.disabled) {
return;
}
const { key } = ev;
// eslint-disable-next-line default-case
switch (key) {
case 'ArrowUp':
case 'ArrowDown':
case 'Home':
case 'End':
case ' ':
case 'Enter':
ev.preventDefault();
}
}
_setupListboxNodeInteractions() {
this._listboxNode.setAttribute('role', 'listbox');
this._listboxNode.setAttribute('aria-orientation', this.orientation);
this._listboxNode.setAttribute('aria-multiselectable', `${this.multipleChoice}`);
this._listboxNode.setAttribute('tabindex', '0');
this._listboxNode.addEventListener('click', this._listboxOnClick);
this._listboxNode.addEventListener('keyup', this._listboxOnKeyUp);
this._listboxNode.addEventListener('keydown', this._listboxOnKeyDown);
}
_teardownListboxNode() {
if (this._listboxNode) {
this._listboxNode.removeEventListener('keydown', this._listboxOnKeyDown);
this._listboxNode.removeEventListener('click', this._listboxOnClick);
this._listboxNode.removeEventListener('keyup', this._listboxOnKeyUp);
}
}
/**
* @param {KeyboardEvent} ev
*/
__preventScrollingWithArrowKeys(ev) {
if (this.disabled) {
return;
}
const { key } = ev;
switch (key) {
case 'ArrowUp':
case 'ArrowDown':
case 'Home':
case 'End':
ev.preventDefault();
/* no default */
}
}
// TODO: move to FormControl / ValidateMixin?
/**
* @param {string} value
*/
set fieldName(value) {
this.__fieldName = value;
}
get fieldName() {
const label =
this.label ||
(this.querySelector('[slot=label]') && this.querySelector('[slot=label]')?.textContent);
return this.__fieldName || label || this.name;
}
};
export const ListboxMixin = dedupeMixin(ListboxMixinImplementation);