@material/select
Version:
The Material Components web select (text field drop-down) component
448 lines • 19.2 kB
JavaScript
/**
* @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 { __assign, __extends } from "tslib";
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 './constants';
var MDCSelectFoundation = /** @class */ (function (_super) {
__extends(MDCSelectFoundation, _super);
/* istanbul ignore next: optional argument is not a branch statement */
/**
* @param adapter
* @param foundationMap Map from subcomponent names to their subfoundations.
*/
function MDCSelectFoundation(adapter, foundationMap) {
if (foundationMap === void 0) { foundationMap = {}; }
var _this = _super.call(this, __assign(__assign({}, MDCSelectFoundation.defaultAdapter), adapter)) || this;
// 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.clickDebounceTimeout = 0;
_this.recentlyClicked = false;
_this.leadingIcon = foundationMap.leadingIcon;
_this.helperText = foundationMap.helperText;
return _this;
}
Object.defineProperty(MDCSelectFoundation, "cssClasses", {
get: function () {
return cssClasses;
},
enumerable: false,
configurable: true
});
Object.defineProperty(MDCSelectFoundation, "numbers", {
get: function () {
return numbers;
},
enumerable: false,
configurable: true
});
Object.defineProperty(MDCSelectFoundation, "strings", {
get: function () {
return strings;
},
enumerable: false,
configurable: true
});
Object.defineProperty(MDCSelectFoundation, "defaultAdapter", {
/**
* See {@link MDCSelectAdapter} for typing information on parameters and return types.
*/
get: function () {
// tslint:disable:object-literal-sort-keys Methods should be in the same order as the adapter interface.
return {
addClass: function () { return undefined; },
removeClass: function () { return undefined; },
hasClass: function () { return false; },
activateBottomLine: function () { return undefined; },
deactivateBottomLine: function () { return undefined; },
getSelectedIndex: function () { return -1; },
setSelectedIndex: function () { return undefined; },
hasLabel: function () { return false; },
floatLabel: function () { return undefined; },
getLabelWidth: function () { return 0; },
setLabelRequired: function () { return undefined; },
hasOutline: function () { return false; },
notchOutline: function () { return undefined; },
closeOutline: function () { return undefined; },
setRippleCenter: function () { return undefined; },
notifyChange: function () { return undefined; },
setSelectedText: function () { return undefined; },
isSelectAnchorFocused: function () { return false; },
getSelectAnchorAttr: function () { return ''; },
setSelectAnchorAttr: function () { return undefined; },
removeSelectAnchorAttr: function () { return undefined; },
addMenuClass: function () { return undefined; },
removeMenuClass: function () { return undefined; },
openMenu: function () { return undefined; },
closeMenu: function () { return undefined; },
getAnchorElement: function () { return null; },
setMenuAnchorElement: function () { return undefined; },
setMenuAnchorCorner: function () { return undefined; },
setMenuWrapFocus: function () { return undefined; },
focusMenuItemAtIndex: function () { return undefined; },
getMenuItemCount: function () { return 0; },
getMenuItemValues: function () { return []; },
getMenuItemTextAtIndex: function () { return ''; },
isTypeaheadInProgress: function () { return false; },
typeaheadMatchItem: function () { return -1; },
};
// tslint:enable:object-literal-sort-keys
},
enumerable: false,
configurable: true
});
/** Returns the index of the currently selected menu item, or -1 if none. */
MDCSelectFoundation.prototype.getSelectedIndex = function () {
return this.adapter.getSelectedIndex();
};
MDCSelectFoundation.prototype.setSelectedIndex = function (index, closeMenu, skipNotify) {
if (closeMenu === void 0) { closeMenu = false; }
if (skipNotify === void 0) { skipNotify = false; }
if (index >= this.adapter.getMenuItemCount()) {
return;
}
if (index === numbers.UNSET_INDEX) {
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;
};
MDCSelectFoundation.prototype.setValue = function (value, skipNotify) {
if (skipNotify === void 0) { skipNotify = false; }
var index = this.adapter.getMenuItemValues().indexOf(value);
this.setSelectedIndex(index, /** closeMenu */ false, skipNotify);
};
MDCSelectFoundation.prototype.getValue = function () {
var index = this.adapter.getSelectedIndex();
var menuItemValues = this.adapter.getMenuItemValues();
return index !== numbers.UNSET_INDEX ? menuItemValues[index] : '';
};
MDCSelectFoundation.prototype.getDisabled = function () {
return this.disabled;
};
MDCSelectFoundation.prototype.setDisabled = function (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. */
MDCSelectFoundation.prototype.openMenu = function () {
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.
*/
MDCSelectFoundation.prototype.setHelperTextContent = function (content) {
if (this.helperText) {
this.helperText.setContent(content);
}
};
/**
* Re-calculates if the notched outline should be notched and if the label
* should float.
*/
MDCSelectFoundation.prototype.layout = function () {
if (this.adapter.hasLabel()) {
var optionHasValue = this.getValue().length > 0;
var isFocused = this.adapter.hasClass(cssClasses.FOCUSED);
var shouldFloatAndNotch = optionHasValue || isFocused;
var 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.
*/
MDCSelectFoundation.prototype.layoutOptions = function () {
var menuItemValues = this.adapter.getMenuItemValues();
var selectedIndex = menuItemValues.indexOf(this.getValue());
this.setSelectedIndex(selectedIndex, /** closeMenu */ false, /** skipNotify */ true);
};
MDCSelectFoundation.prototype.handleMenuOpened = function () {
if (this.adapter.getMenuItemValues().length === 0) {
return;
}
// Menu should open to the last selected element, should open to first menu item otherwise.
var selectedIndex = this.getSelectedIndex();
var focusItemIndex = selectedIndex >= 0 ? selectedIndex : 0;
this.adapter.focusMenuItemAtIndex(focusItemIndex);
};
MDCSelectFoundation.prototype.handleMenuClosing = function () {
this.adapter.setSelectAnchorAttr('aria-expanded', 'false');
};
MDCSelectFoundation.prototype.handleMenuClosed = function () {
this.adapter.removeClass(cssClasses.ACTIVATED);
this.isMenuOpen = 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.
*/
MDCSelectFoundation.prototype.handleChange = function () {
this.layout();
this.adapter.notifyChange(this.getValue());
var isRequired = this.adapter.hasClass(cssClasses.REQUIRED);
if (isRequired && this.useDefaultValidation) {
this.setValid(this.isValid());
}
};
MDCSelectFoundation.prototype.handleMenuItemAction = function (index) {
this.setSelectedIndex(index, /** closeMenu */ true);
};
/**
* Handles focus events from select element.
*/
MDCSelectFoundation.prototype.handleFocus = function () {
this.adapter.addClass(cssClasses.FOCUSED);
this.layout();
this.adapter.activateBottomLine();
};
/**
* Handles blur events from select element.
*/
MDCSelectFoundation.prototype.handleBlur = function () {
if (this.isMenuOpen) {
return;
}
this.blur();
};
MDCSelectFoundation.prototype.handleClick = function (normalizedX) {
if (this.disabled || this.recentlyClicked) {
return;
}
this.setClickDebounceTimeout();
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.
*/
MDCSelectFoundation.prototype.handleKeydown = function (event) {
if (this.isMenuOpen || !this.adapter.hasClass(cssClasses.FOCUSED)) {
return;
}
var isEnter = normalizeKey(event) === KEY.ENTER;
var isSpace = normalizeKey(event) === KEY.SPACEBAR;
var arrowUp = normalizeKey(event) === KEY.ARROW_UP;
var arrowDown = normalizeKey(event) === KEY.ARROW_DOWN;
var isModifier = event.ctrlKey || event.metaKey;
// Typeahead
if (!isModifier &&
(!isSpace && event.key && event.key.length === 1 ||
isSpace && this.adapter.isTypeaheadInProgress())) {
var key = isSpace ? ' ' : event.key;
var typeaheadNextIndex = this.adapter.typeaheadMatchItem(key, this.getSelectedIndex());
if (typeaheadNextIndex >= 0) {
this.setSelectedIndex(typeaheadNextIndex);
}
event.preventDefault();
return;
}
if (!isEnter && !isSpace && !arrowUp && !arrowDown) {
return;
}
this.openMenu();
event.preventDefault();
};
/**
* Opens/closes the notched outline.
*/
MDCSelectFoundation.prototype.notchOutline = function (openNotch) {
if (!this.adapter.hasOutline()) {
return;
}
var isFocused = this.adapter.hasClass(cssClasses.FOCUSED);
if (openNotch) {
var labelScale = numbers.LABEL_SCALE;
var labelWidth = this.adapter.getLabelWidth() * labelScale;
this.adapter.notchOutline(labelWidth);
}
else if (!isFocused) {
this.adapter.closeOutline();
}
};
/**
* Sets the aria label of the leading icon.
*/
MDCSelectFoundation.prototype.setLeadingIconAriaLabel = function (label) {
if (this.leadingIcon) {
this.leadingIcon.setAriaLabel(label);
}
};
/**
* Sets the text content of the leading icon.
*/
MDCSelectFoundation.prototype.setLeadingIconContent = function (content) {
if (this.leadingIcon) {
this.leadingIcon.setContent(content);
}
};
MDCSelectFoundation.prototype.getUseDefaultValidation = function () {
return this.useDefaultValidation;
};
MDCSelectFoundation.prototype.setUseDefaultValidation = function (useDefaultValidation) {
this.useDefaultValidation = useDefaultValidation;
};
MDCSelectFoundation.prototype.setValid = function (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);
};
MDCSelectFoundation.prototype.isValid = function () {
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;
};
MDCSelectFoundation.prototype.setRequired = function (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);
};
MDCSelectFoundation.prototype.getRequired = function () {
return this.adapter.getSelectAnchorAttr('aria-required') === 'true';
};
MDCSelectFoundation.prototype.init = function () {
var 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.
*/
MDCSelectFoundation.prototype.blur = function () {
this.adapter.removeClass(cssClasses.FOCUSED);
this.layout();
this.adapter.deactivateBottomLine();
var isRequired = this.adapter.hasClass(cssClasses.REQUIRED);
if (isRequired && this.useDefaultValidation) {
this.setValid(this.isValid());
}
};
MDCSelectFoundation.prototype.syncHelperTextValidity = function (isValid) {
if (!this.helperText) {
return;
}
this.helperText.setValidity(isValid);
var helperTextVisible = this.helperText.isVisible();
var 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);
}
};
MDCSelectFoundation.prototype.setClickDebounceTimeout = function () {
var _this = this;
clearTimeout(this.clickDebounceTimeout);
this.clickDebounceTimeout = setTimeout(function () {
_this.recentlyClicked = false;
}, numbers.CLICK_DEBOUNCE_TIMEOUT_MS);
this.recentlyClicked = true;
};
return MDCSelectFoundation;
}(MDCFoundation));
export { MDCSelectFoundation };
// tslint:disable-next-line:no-default-export Needed for backward compatibility with MDC Web v0.44.0 and earlier.
export default MDCSelectFoundation;
//# sourceMappingURL=foundation.js.map