in src/components/select/select.js [173:669]
function compile(tElement, tAttrs) {
var isMultiple = $mdUtil.parseAttributeBoolean(tAttrs.multiple);
tElement.addClass('md-auto-horizontal-margin');
// add the select value that will hold our placeholder or selected option value
var valueEl = angular.element('<md-select-value><span></span></md-select-value>');
valueEl.append('<span class="md-select-icon" aria-hidden="true"></span>');
valueEl.addClass('md-select-value');
if (!valueEl[0].hasAttribute('id')) {
valueEl.attr('id', 'select_value_label_' + $mdUtil.nextUid());
}
// There's got to be an md-content inside. If there's not one, let's add it.
var mdContentEl = tElement.find('md-content');
if (!mdContentEl.length) {
tElement.append(angular.element('<md-content>').append(tElement.contents()));
mdContentEl = tElement.find('md-content');
}
mdContentEl.attr('role', 'listbox');
mdContentEl.attr('tabindex', '-1');
if (isMultiple) {
mdContentEl.attr('aria-multiselectable', 'true');
} else {
mdContentEl.attr('aria-multiselectable', 'false');
}
// Add progress spinner for md-options-loading
if (tAttrs.mdOnOpen) {
// Show progress indicator while loading async
// Use ng-hide for `display:none` so the indicator does not interfere with the options list
tElement
.find('md-content')
.prepend(angular.element(
'<div>' +
' <md-progress-circular md-mode="indeterminate" ng-if="$$loadingAsyncDone === false"' +
' md-diameter="25px"></md-progress-circular>' +
'</div>'
));
// Hide list [of item options] while loading async
tElement
.find('md-option')
.attr('ng-show', '$$loadingAsyncDone');
}
if (tAttrs.name) {
var autofillClone = angular.element('<select class="md-visually-hidden"></select>');
autofillClone.attr({
'name': tAttrs.name,
'aria-hidden': 'true',
'tabindex': '-1'
});
var opts = tElement.find('md-option');
angular.forEach(opts, function(el) {
var newEl = angular.element('<option>' + el.innerHTML + '</option>');
if (el.hasAttribute('ng-value')) {
newEl.attr('ng-value', el.getAttribute('ng-value'));
}
else if (el.hasAttribute('value')) {
newEl.attr('value', el.getAttribute('value'));
}
autofillClone.append(newEl);
});
// Adds an extra option that will hold the selected value for the
// cases where the select is a part of a non-AngularJS form. This can be done with a ng-model,
// however if the `md-option` is being `ng-repeat`-ed, AngularJS seems to insert a similar
// `option` node, but with a value of `? string: <value> ?` which would then get submitted.
// This also goes around having to prepend a dot to the name attribute.
autofillClone.append(
'<option ng-value="' + tAttrs.ngModel + '" selected></option>'
);
tElement.parent().append(autofillClone);
}
// Use everything that's left inside element.contents() as the contents of the menu
var multipleContent = isMultiple ? 'multiple' : '';
var ngModelOptions = tAttrs.ngModelOptions ? $mdUtil.supplant('ng-model-options="{0}"', [tAttrs.ngModelOptions]) : '';
var selectTemplate = '' +
'<div class="md-select-menu-container" aria-hidden="true" role="presentation">' +
' <md-select-menu role="presentation" {0} {1}>{2}</md-select-menu>' +
'</div>';
selectTemplate = $mdUtil.supplant(selectTemplate, [multipleContent, ngModelOptions, tElement.html()]);
tElement.empty().append(valueEl);
tElement.append(selectTemplate);
if (!tAttrs.tabindex) {
tAttrs.$set('tabindex', 0);
}
return function postLink(scope, element, attrs, ctrls) {
var untouched = true;
var isDisabled;
var containerCtrl = ctrls[0];
var mdSelectCtrl = ctrls[1];
var ngModelCtrl = ctrls[2];
var formCtrl = ctrls[3];
// grab a reference to the select menu value label
var selectValueElement = element.find('md-select-value');
var isReadonly = angular.isDefined(attrs.readonly);
var disableAsterisk = $mdUtil.parseAttributeBoolean(attrs.mdNoAsterisk);
var stopMdMultipleWatch;
var userDefinedLabelledby = angular.isDefined(attrs.ariaLabelledby);
var listboxContentElement = element.find('md-content');
var initialPlaceholder = element.attr('placeholder');
if (disableAsterisk) {
element.addClass('md-no-asterisk');
}
if (containerCtrl) {
var isErrorGetter = containerCtrl.isErrorGetter || function() {
return ngModelCtrl.$invalid && (ngModelCtrl.$touched || (formCtrl && formCtrl.$submitted));
};
if (containerCtrl.input) {
// We ignore inputs that are in the md-select-header.
// One case where this might be useful would be adding as searchbox.
if (element.find('md-select-header').find('input')[0] !== containerCtrl.input[0]) {
throw new Error("<md-input-container> can only have *one* child <input>, <textarea>, or <select> element!");
}
}
containerCtrl.input = element;
if (!containerCtrl.label) {
$mdAria.expect(element, 'aria-label', initialPlaceholder);
var selectLabel = element.attr('aria-label');
if (!selectLabel) {
selectLabel = initialPlaceholder;
}
listboxContentElement.attr('aria-label', selectLabel);
} else {
containerCtrl.label.attr('aria-hidden', 'true');
listboxContentElement.attr('aria-label', containerCtrl.label.text());
containerCtrl.setHasPlaceholder(!!initialPlaceholder);
}
var stopInvalidWatch = scope.$watch(isErrorGetter, containerCtrl.setInvalid);
}
var selectContainer, selectScope, selectMenuCtrl;
selectContainer = findSelectContainer();
$mdTheming(element);
var originalRender = ngModelCtrl.$render;
ngModelCtrl.$render = function() {
originalRender();
syncSelectValueText();
inputCheckValue();
};
var stopPlaceholderObserver = attrs.$observe('placeholder', ngModelCtrl.$render);
var stopRequiredObserver = attrs.$observe('required', function (value) {
if (containerCtrl && containerCtrl.label) {
// Toggle the md-required class on the input containers label, because the input container
// is automatically applying the asterisk indicator on the label.
containerCtrl.label.toggleClass('md-required', value && !disableAsterisk);
}
element.removeAttr('aria-required');
if (value) {
listboxContentElement.attr('aria-required', 'true');
} else {
listboxContentElement.removeAttr('aria-required');
}
});
/**
* Set the contents of the md-select-value element. This element's contents are announced by
* screen readers and used for displaying the value of the select in both single and multiple
* selection modes.
* @param {string=} text A sanitized and trusted HTML string or a pure text string from user
* input.
*/
mdSelectCtrl.setSelectValueText = function(text) {
var useDefaultText = text === undefined || text === '';
// Whether the select label has been given via user content rather than the internal
// template of <md-option>
var isSelectLabelFromUser = false;
mdSelectCtrl.setIsPlaceholder(!text);
if (attrs.mdSelectedText && attrs.mdSelectedHtml) {
throw Error('md-select cannot have both `md-selected-text` and `md-selected-html`');
}
if (attrs.mdSelectedText || attrs.mdSelectedHtml) {
text = $parse(attrs.mdSelectedText || attrs.mdSelectedHtml)(scope);
isSelectLabelFromUser = true;
} else if (useDefaultText) {
// Use placeholder attribute, otherwise fallback to the md-input-container label
var tmpPlaceholder = attrs.placeholder ||
(containerCtrl && containerCtrl.label ? containerCtrl.label.text() : '');
text = tmpPlaceholder || '';
isSelectLabelFromUser = true;
}
var target = selectValueElement.children().eq(0);
if (attrs.mdSelectedHtml) {
// Using getTrustedHtml will run the content through $sanitize if it is not already
// explicitly trusted. If the ngSanitize module is not loaded, this will
// *correctly* throw an sce error.
target.html($sce.getTrustedHtml(text));
} else if (isSelectLabelFromUser) {
target.text(text);
} else {
// If we've reached this point, the text is not user-provided.
target.html(text);
}
if (useDefaultText) {
// Avoid screen readers double announcing the label name when no value has been selected
selectValueElement.attr('aria-hidden', 'true');
if (!userDefinedLabelledby) {
element.removeAttr('aria-labelledby');
}
} else {
selectValueElement.removeAttr('aria-hidden');
if (!userDefinedLabelledby) {
element.attr('aria-labelledby', element[0].id + ' ' + selectValueElement[0].id);
}
}
};
/**
* @param {boolean} isPlaceholder true to mark the md-select-value element and
* input container, if one exists, with classes for styling when a placeholder is present.
* false to remove those classes.
*/
mdSelectCtrl.setIsPlaceholder = function(isPlaceholder) {
if (isPlaceholder) {
selectValueElement.addClass('md-select-placeholder');
// Don't hide the floating label if the md-select has a placeholder.
if (containerCtrl && containerCtrl.label && !element.attr('placeholder')) {
containerCtrl.label.addClass('md-placeholder');
}
} else {
selectValueElement.removeClass('md-select-placeholder');
if (containerCtrl && containerCtrl.label && !element.attr('placeholder')) {
containerCtrl.label.removeClass('md-placeholder');
}
}
};
if (!isReadonly) {
var handleBlur = function(event) {
// Attach before ngModel's blur listener to stop propagation of blur event
// and prevent setting $touched.
if (untouched) {
untouched = false;
if (selectScope._mdSelectIsOpen) {
event.stopImmediatePropagation();
}
}
containerCtrl && containerCtrl.setFocused(false);
inputCheckValue();
};
var handleFocus = function() {
// Always focus the container (if we have one) so floating labels and other styles are
// applied properly
containerCtrl && containerCtrl.setFocused(true);
};
element.on('focus', handleFocus);
element.on('blur', handleBlur);
}
mdSelectCtrl.triggerClose = function() {
$parse(attrs.mdOnClose)(scope);
};
scope.$$postDigest(function() {
initAriaLabel();
syncSelectValueText();
});
function initAriaLabel() {
var labelText = element.attr('aria-label') || element.attr('placeholder');
if (!labelText && containerCtrl && containerCtrl.label) {
labelText = containerCtrl.label.text();
}
$mdAria.expect(element, 'aria-label', labelText);
}
var stopSelectedLabelsWatcher = scope.$watch(function() {
return selectMenuCtrl.getSelectedLabels();
}, syncSelectValueText);
function syncSelectValueText() {
selectMenuCtrl = selectMenuCtrl ||
selectContainer.find('md-select-menu').controller('mdSelectMenu');
mdSelectCtrl.setSelectValueText(selectMenuCtrl.getSelectedLabels());
}
// TODO add tests for mdMultiple
// TODO add docs for mdMultiple
var stopMdMultipleObserver = attrs.$observe('mdMultiple', function(val) {
if (stopMdMultipleWatch) {
stopMdMultipleWatch();
}
var parser = $parse(val);
stopMdMultipleWatch = scope.$watch(function() {
return parser(scope);
}, function(multiple, prevVal) {
var selectMenu = selectContainer.find('md-select-menu');
// assume compiler did a good job
if (multiple === undefined && prevVal === undefined) {
return;
}
if (multiple) {
var setMultipleAttrs = {'multiple': 'multiple'};
element.attr(setMultipleAttrs);
selectMenu.attr(setMultipleAttrs);
} else {
element.removeAttr('multiple');
selectMenu.removeAttr('multiple');
}
element.find('md-content').attr('aria-multiselectable', multiple ? 'true' : 'false');
if (selectContainer) {
selectMenuCtrl.setMultiple(Boolean(multiple));
originalRender = ngModelCtrl.$render;
ngModelCtrl.$render = function() {
originalRender();
syncSelectValueText();
inputCheckValue();
};
ngModelCtrl.$render();
}
});
});
var stopDisabledObserver = attrs.$observe('disabled', function(disabled) {
if (angular.isString(disabled)) {
disabled = true;
}
// Prevent click event being registered twice
if (isDisabled !== undefined && isDisabled === disabled) {
return;
}
isDisabled = disabled;
if (disabled) {
element
.attr({'aria-disabled': 'true'})
.removeAttr('tabindex')
.removeAttr('aria-expanded')
.removeAttr('aria-haspopup')
.off('click', openSelect)
.off('keydown', handleKeypress);
} else {
element
.attr({
'tabindex': attrs.tabindex,
'aria-haspopup': 'listbox'
})
.removeAttr('aria-disabled')
.on('click', openSelect)
.on('keydown', handleKeypress);
}
});
if (!attrs.hasOwnProperty('disabled') && !attrs.hasOwnProperty('ngDisabled')) {
element.attr({'aria-disabled': 'false'});
element.on('click', openSelect);
element.on('keydown', handleKeypress);
}
var ariaAttrs = {
role: 'button',
'aria-haspopup': 'listbox'
};
if (!element[0].hasAttribute('id')) {
ariaAttrs.id = 'select_' + $mdUtil.nextUid();
}
var containerId = 'select_container_' + $mdUtil.nextUid();
selectContainer.attr('id', containerId);
var listboxContentId = 'select_listbox_' + $mdUtil.nextUid();
selectContainer.find('md-content').attr('id', listboxContentId);
// Only add aria-owns if element ownership is NOT represented in the DOM.
if (!element.find('md-select-menu').length) {
ariaAttrs['aria-owns'] = listboxContentId;
}
element.attr(ariaAttrs);
scope.$on('$destroy', function() {
stopRequiredObserver && stopRequiredObserver();
stopDisabledObserver && stopDisabledObserver();
stopMdMultipleWatch && stopMdMultipleWatch();
stopMdMultipleObserver && stopMdMultipleObserver();
stopSelectedLabelsWatcher && stopSelectedLabelsWatcher();
stopPlaceholderObserver && stopPlaceholderObserver();
stopInvalidWatch && stopInvalidWatch();
element.off('focus');
element.off('blur');
$mdSelect
.destroy()
.finally(function() {
if (containerCtrl) {
containerCtrl.setFocused(false);
containerCtrl.setHasValue(false);
containerCtrl.input = null;
}
ngModelCtrl.$setTouched();
});
});
function inputCheckValue() {
// The select counts as having a value if one or more options are selected,
// or if the input's validity state says it has bad input (eg: string in a number input).
// We must do this on nextTick as the $render is sometimes invoked on nextTick.
$mdUtil.nextTick(function () {
containerCtrl && containerCtrl.setHasValue(
selectMenuCtrl.getSelectedLabels().length > 0 || (element[0].validity || {}).badInput);
});
}
function findSelectContainer() {
var selectContainer = angular.element(
element[0].querySelector('.md-select-menu-container')
);
selectScope = scope;
attrs.mdContainerClass && selectContainer.addClass(attrs.mdContainerClass);
selectMenuCtrl = selectContainer.find('md-select-menu').controller('mdSelectMenu');
selectMenuCtrl.init(ngModelCtrl, attrs);
element.on('$destroy', function() {
selectContainer.remove();
});
return selectContainer;
}
/**
* Determine if the select menu should be opened or an option in the select menu should be
* selected.
* @param {KeyboardEvent} e keyboard event to handle
*/
function handleKeypress(e) {
if ($mdConstant.isNavigationKey(e)) {
// prevent page scrolling on interaction
e.preventDefault();
openSelect(e);
} else {
if (shouldHandleKey(e, $mdConstant)) {
e.preventDefault();
var node = selectMenuCtrl.optNodeForKeyboardSearch(e);
if (!node || node.hasAttribute('disabled')) {
return;
}
var optionCtrl = angular.element(node).controller('mdOption');
if (!selectMenuCtrl.isMultiple) {
angular.forEach(Object.keys(selectMenuCtrl.selected), function (key) {
selectMenuCtrl.deselect(key);
});
}
selectMenuCtrl.select(optionCtrl.hashKey, optionCtrl.value);
selectMenuCtrl.refreshViewValue();
}
}
}
function openSelect() {
selectScope._mdSelectIsOpen = true;
element.attr('aria-expanded', 'true');
$mdSelect.show({
scope: selectScope,
preserveScope: true,
skipCompile: true,
element: selectContainer,
target: element[0],
selectCtrl: mdSelectCtrl,
preserveElement: true,
hasBackdrop: true,
loadingAsync: attrs.mdOnOpen ? scope.$eval(attrs.mdOnOpen) || true : false
}).finally(function() {
selectScope._mdSelectIsOpen = false;
element.removeAttr('aria-expanded');
element.removeAttr('aria-activedescendant');
ngModelCtrl.$setTouched();
});
}
};
}