UNPKG

@aurelia-mdc-web/select

Version:

Wrapper for Material Components Web Select

418 lines 16.2 kB
/** * @license * Copyright 2016 Google Inc. * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ import { MDCFoundation } from '@material/base/foundation'; import { KEY, normalizeKey } from '@material/dom/keyboard'; import { Corner } from '@material/menu-surface/constants'; import { cssClasses, numbers, strings } from '@material/select'; // !!! MODIFIED FOR AURELIA !!! export class MDCSelectFoundationAurelia extends MDCFoundation { static get cssClasses() { return cssClasses; } static get numbers() { return numbers; } static get strings() { return strings; } /** * See {@link MDCSelectAdapter} for typing information on parameters and return types. */ static get defaultAdapter() { // tslint:disable:object-literal-sort-keys Methods should be in the same order as the adapter interface. return { addClass: () => undefined, removeClass: () => undefined, hasClass: () => false, activateBottomLine: () => undefined, deactivateBottomLine: () => undefined, getSelectedIndex: () => -1, setSelectedIndex: () => undefined, hasLabel: () => false, floatLabel: () => undefined, getLabelWidth: () => 0, setLabelRequired: () => undefined, hasOutline: () => false, notchOutline: () => undefined, closeOutline: () => undefined, setRippleCenter: () => undefined, notifyChange: () => undefined, setSelectedText: () => undefined, isSelectAnchorFocused: () => false, getSelectAnchorAttr: () => '', setSelectAnchorAttr: () => undefined, removeSelectAnchorAttr: () => undefined, addMenuClass: () => undefined, removeMenuClass: () => undefined, openMenu: () => undefined, closeMenu: () => undefined, getAnchorElement: () => null, setMenuAnchorElement: () => undefined, setMenuAnchorCorner: () => undefined, setMenuWrapFocus: () => undefined, focusMenuItemAtIndex: () => undefined, getMenuItemCount: () => 0, getMenuItemValues: () => [], getMenuItemTextAtIndex: () => '', isTypeaheadInProgress: () => false, typeaheadMatchItem: () => -1, }; // tslint:enable:object-literal-sort-keys } /* istanbul ignore next: optional argument is not a branch statement */ /** * @param adapter * @param foundationMap Map from subcomponent names to their subfoundations. */ constructor(adapter, foundationMap = {}) { super({ ...MDCSelectFoundationAurelia.defaultAdapter, ...adapter }); // Disabled state this.disabled = false; // isMenuOpen is used to track the state of the menu by listening to the // MDCMenuSurface:closed event For reference, menu.open will return false if // the menu is still closing, but isMenuOpen returns false only after the menu // has closed this.isMenuOpen = false; // By default, select is invalid if it is required but no value is selected. this.useDefaultValidation = true; this.customValidity = true; this.lastSelectedIndex = numbers.UNSET_INDEX; this.leadingIcon = foundationMap.leadingIcon; this.helperText = foundationMap.helperText; } /** Returns the index of the currently selected menu item, or -1 if none. */ getSelectedIndex() { return this.adapter.getSelectedIndex(); } setSelectedIndex(index, closeMenu = false, skipNotify = false) { if (index >= this.adapter.getMenuItemCount()) { return; } // !!! MODIFIED FOR AURELIA TO SUPPORT TEXT IN "EMPTY" ITEMS !!! const menuItemValues = this.adapter.getMenuItemValues(); if (index === numbers.UNSET_INDEX || menuItemValues[index] === undefined || menuItemValues[index] === null) { this.adapter.setSelectedText(''); } else { this.adapter.setSelectedText(this.adapter.getMenuItemTextAtIndex(index).trim()); } this.adapter.setSelectedIndex(index); if (closeMenu) { this.adapter.closeMenu(); } if (!skipNotify && this.lastSelectedIndex !== index) { this.handleChange(); } this.lastSelectedIndex = index; } // !!! MODIFIED FOR AURELIA !!! setValue(value, skipNotify = false) { const index = this.adapter.getMenuItemValues().indexOf(value); this.setSelectedIndex(index, /** closeMenu */ false, skipNotify); } // !!! MODIFIED FOR AURELIA !!! getValue() { const index = this.adapter.getSelectedIndex(); const menuItemValues = this.adapter.getMenuItemValues(); return index !== numbers.UNSET_INDEX ? menuItemValues[index] : undefined; } getDisabled() { return this.disabled; } setDisabled(isDisabled) { this.disabled = isDisabled; if (this.disabled) { this.adapter.addClass(cssClasses.DISABLED); this.adapter.closeMenu(); } else { this.adapter.removeClass(cssClasses.DISABLED); } if (this.leadingIcon) { this.leadingIcon.setDisabled(this.disabled); } if (this.disabled) { // Prevent click events from focusing select. Simply pointer-events: none // is not enough since screenreader clicks may bypass this. this.adapter.removeSelectAnchorAttr('tabindex'); } else { this.adapter.setSelectAnchorAttr('tabindex', '0'); } this.adapter.setSelectAnchorAttr('aria-disabled', this.disabled.toString()); } /** Opens the menu. */ openMenu() { this.adapter.addClass(cssClasses.ACTIVATED); this.adapter.openMenu(); this.isMenuOpen = true; this.adapter.setSelectAnchorAttr('aria-expanded', 'true'); } /** * @param content Sets the content of the helper text. */ setHelperTextContent(content) { if (this.helperText) { this.helperText.setContent(content); } } /** * Re-calculates if the notched outline should be notched and if the label * should float. */ layout() { if (this.adapter.hasLabel()) { const optionHasValue = this.getValue() !== undefined; // !!! MODIFIED FOR AURELIA !!! const isFocused = this.adapter.hasClass(cssClasses.FOCUSED); const shouldFloatAndNotch = optionHasValue || isFocused; const isRequired = this.adapter.hasClass(cssClasses.REQUIRED); this.notchOutline(shouldFloatAndNotch); this.adapter.floatLabel(shouldFloatAndNotch); this.adapter.setLabelRequired(isRequired); } } /** * Synchronizes the list of options with the state of the foundation. Call * this whenever menu options are dynamically updated. */ layoutOptions() { const menuItemValues = this.adapter.getMenuItemValues(); const selectedIndex = menuItemValues.indexOf(this.getValue()); this.setSelectedIndex(selectedIndex, /** closeMenu */ false, /** skipNotify */ true); } handleMenuOpened() { if (this.adapter.getMenuItemValues().length === 0) { return; } // Menu should open to the last selected element, should open to first menu item otherwise. const selectedIndex = this.getSelectedIndex(); const focusItemIndex = selectedIndex >= 0 ? selectedIndex : 0; this.adapter.focusMenuItemAtIndex(focusItemIndex); } handleMenuClosed() { this.adapter.removeClass(cssClasses.ACTIVATED); this.isMenuOpen = false; this.adapter.setSelectAnchorAttr('aria-expanded', 'false'); // Unfocus the select if menu is closed without a selection if (!this.adapter.isSelectAnchorFocused()) { this.blur(); } } /** * Handles value changes, via change event or programmatic updates. */ handleChange() { this.layout(); this.adapter.notifyChange(this.getValue()); const isRequired = this.adapter.hasClass(cssClasses.REQUIRED); if (isRequired && this.useDefaultValidation) { this.setValid(this.isValid()); } } handleMenuItemAction(index) { this.setSelectedIndex(index, /** closeMenu */ true); } /** * Handles focus events from select element. */ handleFocus() { this.adapter.addClass(cssClasses.FOCUSED); this.layout(); this.adapter.activateBottomLine(); } /** * Handles blur events from select element. */ handleBlur() { if (this.isMenuOpen) { return; } this.blur(); } handleClick(normalizedX) { if (this.disabled) { return; } if (this.isMenuOpen) { this.adapter.closeMenu(); return; } this.adapter.setRippleCenter(normalizedX); this.openMenu(); } /** * Handles keydown events on select element. Depending on the type of * character typed, does typeahead matching or opens menu. */ handleKeydown(event) { if (this.isMenuOpen || !this.adapter.hasClass(cssClasses.FOCUSED)) { return; } const isEnter = normalizeKey(event) === KEY.ENTER; const isSpace = normalizeKey(event) === KEY.SPACEBAR; const arrowUp = normalizeKey(event) === KEY.ARROW_UP; const arrowDown = normalizeKey(event) === KEY.ARROW_DOWN; // Typeahead if (!isSpace && event.key && event.key.length === 1 || isSpace && this.adapter.isTypeaheadInProgress()) { const key = isSpace ? ' ' : event.key; const typeaheadNextIndex = this.adapter.typeaheadMatchItem(key, this.getSelectedIndex()); if (typeaheadNextIndex >= 0) { this.setSelectedIndex(typeaheadNextIndex); } event.preventDefault(); return; } if (!isEnter && !isSpace && !arrowUp && !arrowDown) { return; } // Increment/decrement index as necessary and open menu. if (arrowUp && this.getSelectedIndex() > 0) { this.setSelectedIndex(this.getSelectedIndex() - 1); } else if (arrowDown && this.getSelectedIndex() < this.adapter.getMenuItemCount() - 1) { this.setSelectedIndex(this.getSelectedIndex() + 1); } this.openMenu(); event.preventDefault(); } /** * Opens/closes the notched outline. */ notchOutline(openNotch) { if (!this.adapter.hasOutline()) { return; } const isFocused = this.adapter.hasClass(cssClasses.FOCUSED); if (openNotch) { const labelScale = numbers.LABEL_SCALE; const labelWidth = this.adapter.getLabelWidth() * labelScale; this.adapter.notchOutline(labelWidth); } else if (!isFocused) { this.adapter.closeOutline(); } } /** * Sets the aria label of the leading icon. */ setLeadingIconAriaLabel(label) { if (this.leadingIcon) { this.leadingIcon.setAriaLabel(label); } } /** * Sets the text content of the leading icon. */ setLeadingIconContent(content) { if (this.leadingIcon) { this.leadingIcon.setContent(content); } } setUseDefaultValidation(useDefaultValidation) { this.useDefaultValidation = useDefaultValidation; } setValid(isValid) { if (!this.useDefaultValidation) { this.customValidity = isValid; } this.adapter.setSelectAnchorAttr('aria-invalid', (!isValid).toString()); if (isValid) { this.adapter.removeClass(cssClasses.INVALID); this.adapter.removeMenuClass(cssClasses.MENU_INVALID); } else { this.adapter.addClass(cssClasses.INVALID); this.adapter.addMenuClass(cssClasses.MENU_INVALID); } this.syncHelperTextValidity(isValid); } isValid() { if (this.useDefaultValidation && this.adapter.hasClass(cssClasses.REQUIRED) && !this.adapter.hasClass(cssClasses.DISABLED)) { // See notes for required attribute under https://www.w3.org/TR/html52/sec-forms.html#the-select-element // TL;DR: Invalid if no index is selected, or if the first index is selected and has an empty value. return this.getSelectedIndex() !== numbers.UNSET_INDEX && (this.getSelectedIndex() !== 0 || Boolean(this.getValue())); } return this.customValidity; } setRequired(isRequired) { if (isRequired) { this.adapter.addClass(cssClasses.REQUIRED); } else { this.adapter.removeClass(cssClasses.REQUIRED); } this.adapter.setSelectAnchorAttr('aria-required', isRequired.toString()); this.adapter.setLabelRequired(isRequired); } getRequired() { return this.adapter.getSelectAnchorAttr('aria-required') === 'true'; } init() { const anchorEl = this.adapter.getAnchorElement(); if (anchorEl) { this.adapter.setMenuAnchorElement(anchorEl); this.adapter.setMenuAnchorCorner(Corner.BOTTOM_START); } this.adapter.setMenuWrapFocus(false); this.setDisabled(this.adapter.hasClass(cssClasses.DISABLED)); this.syncHelperTextValidity(!this.adapter.hasClass(cssClasses.INVALID)); this.layout(); this.layoutOptions(); } /** * Unfocuses the select component. */ blur() { this.adapter.removeClass(cssClasses.FOCUSED); this.layout(); this.adapter.deactivateBottomLine(); const isRequired = this.adapter.hasClass(cssClasses.REQUIRED); if (isRequired && this.useDefaultValidation) { this.setValid(this.isValid()); } } syncHelperTextValidity(isValid) { if (!this.helperText) { return; } this.helperText.setValidity(isValid); const helperTextVisible = this.helperText.isVisible(); const helperTextId = this.helperText.getId(); if (helperTextVisible && helperTextId) { this.adapter.setSelectAnchorAttr(strings.ARIA_DESCRIBEDBY, helperTextId); } else { // Needed because screenreaders will read labels pointed to by // `aria-describedby` even if they are `aria-hidden`. this.adapter.removeSelectAnchorAttr(strings.ARIA_DESCRIBEDBY); } } } // tslint:disable-next-line:no-default-export Needed for backward compatibility with MDC Web v0.44.0 and earlier. export default MDCSelectFoundationAurelia; //# sourceMappingURL=mdc-select-foundation-aurelia.js.map