@anypoint-web-components/anypoint-dropdown-menu
Version:
Accessible dropdown menu for Anypoint platform
875 lines (809 loc) • 23.9 kB
JavaScript
import { html, LitElement } from 'lit-element';
import { ControlStateMixin } from '@anypoint-web-components/anypoint-control-mixins';
import { ValidatableMixin } from '@anypoint-web-components/validatable-mixin';
import '@anypoint-web-components/anypoint-dropdown/anypoint-dropdown.js';
import '@anypoint-web-components/anypoint-button/anypoint-icon-button.js';
import { arrowDown } from './icons.js';
import DropdownStyles from './Styles.js';
/** @typedef {import('@anypoint-web-components/validatable-mixin/src/ValidatableMixin').ValidationResult} ValidationResult */
/* eslint-disable class-methods-use-this */
/* eslint-disable no-plusplus */
/**
* Accessible dropdown menu for Anypoint platform.
*
* The element works perfectly with `anypoint-listbox` which together creates an
* accessible list of options. The listbox can be replaced by any other element
* that support similar functionality but make sure it has an appropriate aria
* support.
*
* See README.md file for detailed documentation.
*/
export class AnypointDropdownMenu extends ValidatableMixin(ControlStateMixin(LitElement)) {
get styles() {
return DropdownStyles;
}
render() {
const {
opened,
horizontalAlign,
verticalAlign,
dynamicAlign,
horizontalOffset,
verticalOffset,
noOverlap,
openAnimationConfig,
closeAnimationConfig,
noAnimations,
allowOutsideScroll,
restoreFocusOnClose,
value,
invalidMessage,
infoMessage,
compatibility,
_labelClass,
_errorAddonClass,
_infoAddonClass,
_triggerClass,
_inputContainerClass,
fitPositionTarget,
name,
} = this;
const renderValue = value || '';
return html`<style>
${this.styles}
</style>
<div class="${_inputContainerClass}">
<div class="${_labelClass}" id="${name}">
<slot name="label"></slot>
</div>
<div class="input-wrapper">
<div class="input">
${renderValue}
<span class="input-spacer"> </span>
</div>
<anypoint-icon-button
="${this.toggle}"
aria-label="Toggles dropdown menu"
tabindex="-1"
class="${_triggerClass}"
?compatibility="${compatibility}"
>
<span class="trigger-icon ${opened ? 'opened' : ''}"
>${arrowDown}</span
>
</anypoint-icon-button>
</div>
<anypoint-dropdown
.fitPositionTarget="${fitPositionTarget}"
.opened="${opened}"
.horizontalAlign="${horizontalAlign}"
.verticalAlign="${verticalAlign}"
.dynamicAlign="${dynamicAlign}"
.horizontalOffset="${horizontalOffset}"
.verticalOffset="${verticalOffset}"
.noOverlap="${noOverlap}"
.openAnimationConfig="${openAnimationConfig}"
.closeAnimationConfig="${closeAnimationConfig}"
.noAnimations="${noAnimations}"
.allowOutsideScroll="${allowOutsideScroll}"
.restoreFocusOnClose="${restoreFocusOnClose}"
="${this._dropdownClosed}"
="${this._dropdownOpened}"
="${this._selectHandler}"
="${this._deselectHandler}"
aria-labelledby="${name}"
>
<div slot="dropdown-content" class="dropdown-content">
<slot id="content" name="dropdown-content"></slot>
</div>
</anypoint-dropdown>
</div>
<div class="assistive-info">
${infoMessage
? html`<p class="${_infoAddonClass}">${infoMessage}</p>`
: ''}
${invalidMessage
? html`<p class="${_errorAddonClass}">${invalidMessage}</p>`
: ''}
</div> `;
}
get validationStates() {
return this._validationStates;
}
set validationStates(value) {
const old = this._validationStates;
/* istanbul ignore if */
if (old === value) {
return;
}
this._validationStates = value;
/* istanbul ignore else */
if (this.requestUpdate) {
this.requestUpdate('validationStates', old);
}
this._hasValidationMessage = !!(value && value.length);
this._validationStatesChanged(value);
this.dispatchEvent(
new CustomEvent('validationstates-changed', {
detail: {
value,
},
})
);
}
get hasValidationMessage() {
return this._hasValidationMessage;
}
get _hasValidationMessage() {
return this.__hasValidationMessage;
}
set _hasValidationMessage(value) {
const old = this.__hasValidationMessage;
/* istanbul ignore if */
if (old === value) {
return;
}
this.__hasValidationMessage = value;
/* istanbul ignore else */
if (this.requestUpdate) {
this.requestUpdate('hasValidationMessage', old);
}
this.__hasValidationMessage = value;
this.dispatchEvent(
new CustomEvent('hasvalidationmessage-changed', {
detail: {
value,
},
})
);
}
get autoValidate() {
return this._autoValidate;
}
set autoValidate(value) {
const old = this._autoValidate;
/* istanbul ignore if */
if (old === value) {
return;
}
this._autoValidate = value;
this._autoValidateChanged(value);
}
get invalidMessage() {
return this._invalidMessage;
}
set invalidMessage(value) {
const old = this._invalidMessage;
/* istanbul ignore if */
if (old === value) {
return;
}
this._invalidMessage = value;
/* istanbul ignore else */
if (this.requestUpdate) {
this.requestUpdate('invalidMessage', old);
}
this._hasValidationMessage = this.invalid && !!value;
}
get _labelClass() {
const labelFloating = !!this.value;
let className = 'label';
if (labelFloating && this.noLabelFloat) {
className += ' hidden';
} else {
className += labelFloating ? ' floating' : ' resting';
}
if (this._formDisabled || this.disabled) {
className += ' form-disabled';
}
if (this.compatibility) {
className += ' compatibility';
}
return className;
}
get _infoAddonClass() {
let className = 'info';
const isInvalidWithMessage = !!this.invalidMessage && this.invalid;
if (isInvalidWithMessage) {
className += ' label-hidden';
}
return className;
}
get _errorAddonClass() {
let className = 'invalid';
if (!this.invalid) {
className += ' label-hidden';
}
if (this.infoMessage) {
className += ' info-offset';
}
return className;
}
get _triggerClass() {
let className = 'trigger-button';
if (this._formDisabled || this.disabled) {
className += ' form-disabled';
}
return className;
}
get _inputContainerClass() {
let className = 'input-container';
if (this._formDisabled || this.disabled) {
className += ' form-disabled';
}
return className;
}
get selectedItem() {
return this._selectedItem;
}
get _selectedItem() {
return this.__selectedItem;
}
set _selectedItem(value) {
const old = this.__selectedItem;
/* istanbul ignore if */
if (old === value) {
return;
}
this.__selectedItem = value;
this._selectedItemChanged(value);
}
get opened() {
return this._opened || false;
}
set opened(value) {
const old = this._opened;
if (old === value) {
return;
}
if (value && (this._disabled || this._formDisabled)) {
return;
}
this._opened = value;
this.requestUpdate('opened', old);
this._openedChanged(value);
this.dispatchEvent(
new CustomEvent('opened-changed', {
detail: {
value,
},
})
);
}
/**
* @return {HTMLElement|null} The content element that is contained by the dropdown menu, if any.
*/
get contentElement() {
const slot = /** @type HTMLSlotElement */ (this.shadowRoot.querySelector(
'slot[name="dropdown-content"]'
));
if (!slot) {
return null;
}
const nodes = slot.assignedElements();
for (let i = 0, l = nodes.length; i < l; i++) {
if (nodes[i].nodeType === Node.ELEMENT_NODE) {
return /** @type HTMLElement */ (nodes[i]);
}
}
return null;
}
get value() {
return this._value;
}
set value(value) {
const old = this._value;
if (old === value) {
return;
}
this._value = value;
this.requestUpdate('value', old);
}
get disabled() {
return this._disabled || false;
}
set disabled(value) {
const old = this._disabled;
if (old === value) {
return;
}
this._disabled = value;
this.requestUpdate('disabled', old);
if (this.opened) {
this.opened = false;
}
}
get legacy() {
return this.compatibility;
}
set legacy(value) {
this.compatibility = value;
}
static get properties() {
return {
/**
* An animation config. If provided, this will be used to animate the
* opening of the dropdown. Pass an Array for multiple animations.
* See `neon-animation` documentation for more animation configuration
* details.
*/
openAnimationConfig: { type: Object },
/**
* An animation config. If provided, this will be used to animate the
* closing of the dropdown. Pass an Array for multiple animations.
* See `neon-animation` documentation for more animation configuration
* details.
*/
closeAnimationConfig: { type: Object },
/**
* Set to true to disable animations when opening and closing the
* dropdown.
*/
noAnimations: { type: Boolean, reflect: true },
/**
* By default, the dropdown will constrain scrolling on the page
* to itself when opened.
* Set to true in order to prevent scroll from being constrained
* to the dropdown when it opens.
* This property is a shortcut to set `scrollAction` to lock or refit.
* Prefer directly setting the `scrollAction` property.
*/
allowOutsideScroll: { type: Boolean, reflect: true },
/**
* The orientation against which to align the element vertically
* relative to the `positionTarget`. Possible values are "top", "bottom",
* "middle", "auto".
*/
verticalAlign: { type: String },
/**
* The orientation against which to align the element horizontally
* relative to the `positionTarget`. Possible values are "left", "right",
* "center", "auto".
*/
horizontalAlign: { type: String },
/**
* A pixel value that will be added to the position calculated for the
* given `verticalAlign`, in the direction of alignment. You can think
* of it as increasing or decreasing the distance to the side of the
* screen given by `verticalAlign`.
*
* If `verticalAlign` is "top" or "middle", this offset will increase or
* decrease the distance to the top side of the screen: a negative offset
* will move the dropdown upwards; a positive one, downwards.
*
* Conversely if `verticalAlign` is "bottom", this offset will increase
* or decrease the distance to the bottom side of the screen: a negative
* offset will move the dropdown downwards; a positive one, upwards.
*/
verticalOffset: { type: Number },
/**
* A pixel value that will be added to the position calculated for the
* given `horizontalAlign`, in the direction of alignment. You can think
* of it as increasing or decreasing the distance to the side of the
* screen given by `horizontalAlign`.
*
* If `horizontalAlign` is "left" or "center", this offset will increase or
* decrease the distance to the left side of the screen: a negative offset
* will move the dropdown to the left; a positive one, to the right.
*
* Conversely if `horizontalAlign` is "right", this offset will increase
* or decrease the distance to the right side of the screen: a negative
* offset will move the dropdown to the right; a positive one, to the left.
*/
horizontalOffset: { type: Number },
/**
* If true, it will use `horizontalAlign` and `verticalAlign` values as
* preferred alignment and if there's not enough space, it will pick the
* values which minimize the cropping.
*/
dynamicAlign: { type: Boolean, reflect: true },
/**
* True if the list is currently displayed.
*/
opened: { type: Boolean, reflect: true },
/**
* Selected item value calculated as it's (in order) label property, label
* attribute, and `innerText` value.
*/
value: { type: String },
/**
* Name of the form control.
* Note, form-associated custom elements may not be supported as first
* implementation was released in Chrome M77 in July 2019. It may require
* using custom form element to gather form data.
*/
name: { type: String },
/**
* When set it marks the element as required. Calling the `validate`
* function will mark this control as invalid when no value is selected.
*/
required: { type: Boolean, reflect: true },
/**
* Automatically calls `validate()` function when dropdown closes.
*/
autoValidate: { type: Boolean, reflect: true },
/**
* The error message to display when the input is invalid.
*/
invalidMessage: { type: String },
/**
* Assistive text value.
* Rendered below the input.
*/
infoMessage: { type: String },
/**
* After calling `validate()` this will be populated by latest result of the test for each
* validator. Result item will contain following properties:
*
* - validator {String} Name of the validator
* - valid {Boolean} Result of the test
* - message {String} Error message, populated only if `valid` equal `false`
*
* This property is `undefined` if `validator` is not set.
*/
validationStates: { type: Array },
/**
* Value computed from `invalidMessage`, `invalid` and `validationStates`.
* True if the validation message should be displayed.
*/
_hasValidationMessage: { type: Boolean },
/**
* Will position the list around the button without overlapping
* it.
*/
noOverlap: { type: Boolean },
/**
* Enables outlined theme.
*/
outlined: { type: Boolean, reflect: true },
/**
* Enables compatibility with Anypoint components.
*/
compatibility: { type: Boolean, reflect: true },
/**
* @deprecated Use `compatibility` instead
*/
legacy: { type: Boolean },
/**
* When set the label is rendered only when not selected state.
* It is useful when using the dropdown in an application menu bar.
*/
noLabelFloat: { type: Boolean, reflect: true },
/**
* When set the control is rendered as disabled form control.
*/
disabled: { type: Boolean, reflect: true },
/**
* Fits the dropdown content width to the dropdown selector. Default to `false`.
*/
fitPositionTarget: { type: Boolean },
};
}
constructor() {
super();
this.horizontalAlign = 'left';
this.verticalAlign = 'top';
this.noAnimations = false;
this.allowOutsideScroll = false;
this.dynamicAlign = false;
this.noOverlap = false;
this.horizontalOffset = 0;
this.verticalOffset = 0;
this.restoreFocusOnClose = false;
this.value = '';
this.name = undefined;
this.openAnimationConfig = undefined;
this.closeAnimationConfig = undefined;
this.infoMessage = undefined;
this.noLabelFloat = false;
this.required = false;
this.fitPositionTarget = false;
this._clickHandler = this._clickHandler.bind(this);
this._onKeydown = this._onKeydown.bind(this);
this._focusHandler = this._focusHandler.bind(this);
}
connectedCallback() {
/* istanbul ignore else */
if (super.connectedCallback) {
super.connectedCallback();
}
if (!this.hasAttribute('tabindex')) {
this.setAttribute('tabindex', '0');
}
if (!this.hasAttribute('aria-haspopup')) {
this.setAttribute('aria-haspopup', 'listbox');
}
// aria-expanded is set with `opened` flag which is initialized in the constructor.
this.addEventListener('click', this._clickHandler);
this.addEventListener('keydown', this._onKeydown);
this.addEventListener('focus', this._focusHandler);
}
disconnectedCallback() {
/* istanbul ignore else */
if (super.disconnectedCallback) {
super.disconnectedCallback();
}
this.removeEventListener('click', this._clickHandler);
this.removeEventListener('keydown', this._onKeydown);
this.removeEventListener('focus', this._focusHandler);
}
/**
* When form-associated custom elements are supported in the browser it
* is called when for disabled state changed.
* @param {Boolean} disabled Form disabled state
*/
formDisabledCallback(disabled) {
const old = this._formDisabled;
this._formDisabled = disabled;
if (disabled && this.opened) {
this.opened = false;
}
this.requestUpdate('_formDisabled', old);
}
/**
* When form-associated custom elements are supported in the browser it
* is called when the form has been reset
*/
formResetCallback() {
this.value = '';
const node = this.contentElement;
/* istanbul ignore else */
if (node) {
// @ts-ignore
node.selected = undefined;
}
}
firstUpdated() {
this._openedChanged(this.opened);
const { contentElement } = this;
// @ts-ignore
const item = contentElement && contentElement.selectedItem;
if (item) {
this._selectedItem = item;
}
}
/**
* Handler for `click` event.
* Opens the list of the click originated from the shadow DOM.
* @param {MouseEvent} e
*/
_clickHandler(e) {
// @ts-ignore
const path = e.path || (e.composedPath && e.composedPath());
/* istanbul ignore if */
if (!path) {
return;
}
/* istanbul ignore else */
if (path.indexOf(this) !== -1 && !this.opened) {
this.opened = true;
e.preventDefault();
e.stopPropagation();
}
}
/**
* Focuses on the listbox, if available.
*/
_focusContent() {
const node = this.contentElement;
if (node) {
node.focus();
}
}
/**
* Handler for the `focus` event.
* Focuses on the listbox when opened.
*/
_focusHandler() {
if (this.opened) {
this._focusContent();
}
}
/**
* Handler for the keydown event.
* @param {KeyboardEvent} e
*/
_onKeydown(e) {
if (e.key === 'ArrowDown') {
this._onDownKey(e);
} else if (e.key === 'ArrowUp') {
this._onUpKey(e);
} else if (e.key === 'Escape') {
this._onEscKey();
}
}
/**
* Handler for ArrowDown button press.
* Opens the list if it's not open and focuses on the list otherwise.
*
* The event should be cancelled or it may cause unwanted behavior.
*
* @param {KeyboardEvent} e
*/
_onDownKey(e) {
if (!this.opened) {
this.opened = true;
} else {
this._focusContent();
}
e.preventDefault();
e.stopPropagation();
}
/**
* Handler for ArrowUp button press.
* Opens the list if it's not open and focuses on the list otherwise.
*
* The event should be cancelled or it may cause unwanted behavior.
*
* @param {KeyboardEvent} e
*/
_onUpKey(e) {
if (!this.opened) {
this.opened = true;
} else {
this._focusContent();
}
e.preventDefault();
e.stopPropagation();
}
/**
* Handler for Escape button press.
* Closes the list if it's open.
*/
_onEscKey() {
if (this.opened) {
this.opened = false;
}
}
/**
* Compute the label for the dropdown given a selected item.
*
* @param {Element} selectedItem A selected Element item, with an
* optional `label` property.
*/
_selectedItemChanged(selectedItem) {
let value = '';
if (selectedItem) {
value =
// @ts-ignore
selectedItem.label ||
selectedItem.getAttribute('label') ||
selectedItem.getAttribute('data-label') ||
selectedItem.textContent.trim();
}
this.value = value;
}
/**
* Toggles `opened` state.
*
* @param {MouseEvent=} e When set it cancels the event
*/
toggle(e) {
if (this.disabled || this._formDisabled) {
return;
}
this.opened = !this.opened;
if (e && e.preventDefault) {
e.preventDefault();
e.stopPropagation();
}
}
/**
* Show the dropdown content.
*/
open() {
if (this.disabled || this._formDisabled) {
return;
}
this.opened = true;
}
/**
* Hide the dropdown content.
*/
close() {
if (this.disabled || this._formDisabled) {
return;
}
this.opened = false;
}
_dropdownClosed() {
this.opened = false;
if (this.autoValidate) {
this.validate(this.value);
}
this.focus();
}
_dropdownOpened() {
this._focusContent();
}
_selectHandler(e) {
this.opened = false;
this._selectedItem = e.detail.item;
}
_deselectHandler() {
this._selectedItem = null;
}
/**
* Returns false if the element is required and does not have a selection,
* and true otherwise.
*
* @return {boolean} true if `required` is false, or if `required` is true
* and the element has a valid selection.
*/
_getValidity() {
return (
this.disabled ||
this._formDisabled ||
!this.required ||
(this.required && !!this.value)
);
}
_openedChanged(opened) {
const openState = opened ? 'true' : 'false';
this.setAttribute('aria-expanded', openState);
const e = this.contentElement;
if (e) {
e.setAttribute('aria-expanded', openState);
}
}
checkValidity() {
return this._getValidity();
}
/**
* Called when validation states changed.
* Validation states are set by validatable mixin and is a result of calling
* a custom validator. Each validator returns an object with `valid` and `message`
* properties.
*
* See `ValidatableMixin` for more information.
*
* @param {ValidationResult[]} states
*/
_validationStatesChanged(states) {
if (!states || !states.length) {
return;
}
const parts = [];
for (let i = 0, len = states.length; i < len; i++) {
if (!states[i].valid) {
parts[parts.length] = states[i].message;
}
}
this.invalidMessage = parts.join('. ');
}
/**
* Calls when `autoValidate` changed
* @param {Boolean} value
*/
_autoValidateChanged(value) {
if (value) {
this.validate(this.value);
}
}
/**
* From `ValidatableMixin`
* @param {Boolean} value Current invalid sate
*/
_invalidChanged(value) {
super._invalidChanged(value);
this._hasValidationMessage = value && !!this.invalidMessage;
this._ensureInvalidAlertSate(value);
}
_ensureInvalidAlertSate(invalid) {
if (!this.invalidMessage) {
return;
}
const node = this.shadowRoot.querySelector('p.invalid');
if (!node) {
return;
}
if (invalid) {
node.setAttribute('role', 'alert');
} else {
node.removeAttribute('role');
}
setTimeout(() => {
node.removeAttribute('role');
}, 1000);
}
}