carbon-components-angular
Version:
Next generation components
1,273 lines (1,267 loc) • 80.3 kB
JavaScript
import * as i0 from '@angular/core';
import { Directive, Input, Output, Injectable, EventEmitter, TemplateRef, Component, ContentChild, ViewChild, HostBinding, HostListener, ViewChildren, NgModule } from '@angular/core';
import { NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';
import { Subscription, of, fromEvent, isObservable, Observable } from 'rxjs';
import * as i2 from 'carbon-components-angular/utils';
import { closestAttr, hasScrollableParents, getScrollableParents, UtilsModule } from 'carbon-components-angular/utils';
import * as i1$1 from 'carbon-components-angular/i18n';
import { I18nModule } from 'carbon-components-angular/i18n';
import { position } from '@carbon/utils-position';
import * as i1 from 'carbon-components-angular/placeholder';
import { PlaceholderModule } from 'carbon-components-angular/placeholder';
import * as i4 from '@angular/common';
import { CommonModule } from '@angular/common';
import * as i5 from 'carbon-components-angular/icon';
import { IconModule } from 'carbon-components-angular/icon';
import { debounceTime, map, filter, first } from 'rxjs/operators';
/**
* A component that intends to be used within `Dropdown` must provide an implementation that extends this base class.
* It also must provide the base class in the `@Component` meta-data.
* ex: `providers: [{provide: AbstractDropdownView, useExisting: forwardRef(() => MyDropdownView)}]`
*/
class AbstractDropdownView {
constructor() {
/**
* Specifies whether or not the `DropdownList` supports selecting multiple items as opposed to single
* item selection.
*/
this.type = "single";
/**
* Specifies the render size of the items within the `AbstractDropdownView`.
*/
this.size = "md";
}
/**
* The items to be displayed in the list within the `AbstractDropDownView`.
*/
set items(value) { }
get items() { return; }
/**
* Returns the `ListItem` that is subsequent to the selected item in the `DropdownList`.
*/
getNextItem() { return; }
/**
* Returns a boolean if the currently selected item is preceded by another
*/
hasNextElement() { return; }
/**
* Returns the `HTMLElement` for the item that is subsequent to the selected item.
*/
getNextElement() { return; }
/**
* Returns the `ListItem` that precedes the selected item within `DropdownList`.
*/
getPrevItem() { return; }
/**
* Returns a boolean if the currently selected item is followed by another
*/
hasPrevElement() { return; }
/**
* Returns the `HTMLElement` for the item that precedes the selected item.
*/
getPrevElement() { return; }
/**
* Returns the selected leaf level item(s) within the `DropdownList`.
*/
getSelected() { return; }
/**
* Returns the `ListItem` that is selected within `DropdownList`.
*/
getCurrentItem() { return; }
/**
* Returns the `HTMLElement` for the item that is selected within the `DropdownList`.
*/
getCurrentElement() { return; }
/**
* Guaranteed to return the current items as an Array.
*/
getListItems() { return; }
/**
* Transforms array input list of items to the correct state by updating the selected item(s).
*/
propagateSelected(value) { }
/**
*
* @param value value to filter the list by
*/
filterBy(value) { }
/**
* Initializes focus in the list
* In most cases this just calls `getCurrentElement().focus()`
*/
initFocus() { }
/**
* Subscribe the function passed to an internal observable that will resolve once the items are ready
*/
onItemsReady(subcription) { }
/**
* Reorder selected items bringing them to the top of the list
*/
reorderSelected(moveFocus) { }
}
AbstractDropdownView.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "14.3.0", ngImport: i0, type: AbstractDropdownView, deps: [], target: i0.ɵɵFactoryTarget.Directive });
AbstractDropdownView.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "14.3.0", type: AbstractDropdownView, selector: "[cdsAbstractDropdownView], [ibmAbstractDropdownView]", inputs: { items: "items" }, outputs: { select: "select", blurIntent: "blurIntent" }, ngImport: i0 });
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "14.3.0", ngImport: i0, type: AbstractDropdownView, decorators: [{
type: Directive,
args: [{
selector: "[cdsAbstractDropdownView], [ibmAbstractDropdownView]"
}]
}], propDecorators: { items: [{
type: Input
}], select: [{
type: Output
}], blurIntent: [{
type: Output
}] } });
const defaultOffset = { top: 0, left: 0 };
class DropdownService {
constructor(placeholderService, animationFrameService) {
this.placeholderService = placeholderService;
this.animationFrameService = animationFrameService;
/**
* Maintains an Event Observable Subscription for the global requestAnimationFrame.
* requestAnimationFrame is tracked only if the `Dropdown` is appended to the body otherwise we don't need it
*/
this.animationFrameSubscription = new Subscription();
this._offset = defaultOffset;
}
set offset(value) {
this._offset = Object.assign({}, defaultOffset, value);
}
get offset() {
return this._offset;
}
/**
* Appends the menu to the body, or a `cds-placeholder` (if defined)
*
* @param parentRef container to position relative to
* @param menuRef menu to be appended to body
* @param classList any extra classes we should wrap the container with
*/
appendToBody(parentRef, menuRef, classList) {
// build the dropdown list container
menuRef.style.display = "block";
const dropdownWrapper = document.createElement("div");
dropdownWrapper.className = `dropdown ${classList}`;
dropdownWrapper.style.width = parentRef.offsetWidth + "px";
dropdownWrapper.style.position = "absolute";
dropdownWrapper.appendChild(menuRef);
// append it to the placeholder
if (this.placeholderService.hasPlaceholderRef()) {
this.placeholderService.appendElement(dropdownWrapper);
// or append it directly to the body
}
else {
document.body.appendChild(dropdownWrapper);
}
this.menuInstance = dropdownWrapper;
this.animationFrameSubscription = this.animationFrameService.tick.subscribe(() => {
this.positionDropdown(parentRef, dropdownWrapper);
});
// run one position in sync, so we're less likely to have the view "jump" as we focus
this.positionDropdown(parentRef, dropdownWrapper);
return dropdownWrapper;
}
/**
* Reattach the dropdown menu to the parent container
* @param hostRef container to append to
*/
appendToDropdown(hostRef) {
// if the instance is already removed don't try and remove it again
if (!this.menuInstance) {
return;
}
const instance = this.menuInstance;
const menu = instance.firstElementChild;
// clean up the instance
this.menuInstance = null;
menu.style.display = "none";
hostRef.appendChild(menu);
this.animationFrameSubscription.unsubscribe();
if (this.placeholderService.hasPlaceholderRef() && this.placeholderService.hasElement(instance)) {
this.placeholderService.removeElement(instance);
}
else if (document.body.contains(instance)) {
document.body.removeChild(instance);
}
return instance;
}
/**
* position an open dropdown relative to the given parentRef
*/
updatePosition(parentRef) {
this.positionDropdown(parentRef, this.menuInstance);
}
ngOnDestroy() {
this.animationFrameSubscription.unsubscribe();
}
positionDropdown(parentRef, menuRef) {
if (!menuRef) {
return;
}
let leftOffset = 0;
const boxMenu = menuRef.querySelector(".cds--list-box__menu");
if (boxMenu) {
// If the parentRef and boxMenu are in a different left position relative to the
// window, the the boxMenu position has already been flipped and a check needs to be done
// to see if it needs to stay flipped.
if (parentRef.getBoundingClientRect().left !== boxMenu.getBoundingClientRect().left) {
// The getBoundingClientRect().right of the boxMenu if it were hypothetically flipped
// back into the original position before the flip.
const testBoxMenuRightEdgePos = parentRef.getBoundingClientRect().left - boxMenu.getBoundingClientRect().left + boxMenu.getBoundingClientRect().right;
if (testBoxMenuRightEdgePos > (window.innerWidth || document.documentElement.clientWidth)) {
leftOffset = parentRef.offsetWidth - boxMenu.offsetWidth;
}
// If it has not already been flipped, check if it is necessary to flip, ie. if the
// boxMenu is outside of the right viewPort.
}
else if (boxMenu.getBoundingClientRect().right > (window.innerWidth || document.documentElement.clientWidth)) {
leftOffset = parentRef.offsetWidth - boxMenu.offsetWidth;
}
}
// If cds-placeholder has a parent with a position(relative|fixed|absolute) account for the parent offset
const closestMenuWithPos = closestAttr("position", ["relative", "fixed", "absolute"], menuRef.parentElement);
const topPos = closestMenuWithPos ? closestMenuWithPos.getBoundingClientRect().top * -1 : this.offset.top;
const leftPos = closestMenuWithPos ? closestMenuWithPos.getBoundingClientRect().left * -1 : this.offset.left + leftOffset;
let pos = position.findAbsolute(parentRef, menuRef, "bottom");
pos = position.addOffset(pos, topPos, leftPos);
position.setElement(menuRef, pos);
}
}
DropdownService.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "14.3.0", ngImport: i0, type: DropdownService, deps: [{ token: i1.PlaceholderService }, { token: i2.AnimationFrameService }], target: i0.ɵɵFactoryTarget.Injectable });
DropdownService.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "14.3.0", ngImport: i0, type: DropdownService });
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "14.3.0", ngImport: i0, type: DropdownService, decorators: [{
type: Injectable
}], ctorParameters: function () { return [{ type: i1.PlaceholderService }, { type: i2.AnimationFrameService }]; } });
/**
* Drop-down lists enable users to select one or more items from a list.
*
* #### Opening behavior/List DOM placement
* By default the dropdown will try to figure out the best placement for the dropdown list.
*
* If it's not contained within any scrolling elements, it will open inline, if it _is_
* contained within a scrolling container it will try to open in the body, or an `cds-placeholder`.
*
* To control this behavior you can use the `appendInline` input:
* - `[appendInline]="null"` is the default (auto detection)
* - `[appendInline]="false"` will always append to the body/`cds-placeholder`
* - `[appendInline]="true"` will always append inline (next to the dropdown button)
*
* Get started with importing the module:
*
* ```typescript
* import { DropdownModule } from 'carbon-components-angular';
* ```
*
* [See demo](../../?path=/story/components-dropdown--basic)
*/
class Dropdown {
/**
* Creates an instance of Dropdown.
*/
constructor(elementRef, i18n, dropdownService, elementService) {
this.elementRef = elementRef;
this.i18n = i18n;
this.dropdownService = dropdownService;
this.elementService = elementService;
this.id = `dropdown-${Dropdown.dropdownCount++}`;
/**
* Hide label while keeping it accessible for screen readers
*/
this.hideLabel = false;
/**
* Value displayed if no item is selected.
*/
this.placeholder = "";
/**
* The selected value from the `Dropdown`. Can be a string or template.
*/
this.displayValue = "";
/**
* Sets the optional clear button tooltip text.
*/
this.clearText = this.i18n.get().DROPDOWN.CLEAR;
/**
* Size to render the dropdown field.
*/
this.size = "md";
/**
* Defines whether or not the `Dropdown` supports selecting multiple items as opposed to single
* item selection.
*/
this.type = "single";
/**
* @deprecated since v5 - Use `cdsLayer` directive instead
* `light` or `dark` dropdown theme
*/
this.theme = "dark";
/**
* Set to `true` to disable the dropdown.
*/
this.disabled = false;
/**
* Set to `true` for a loading dropdown.
*/
this.skeleton = false;
/**
* Set to `true` for an inline dropdown.
*/
this.inline = false;
/**
* Set to `true` for a dropdown without arrow key activation.
*/
this.disableArrowKeys = false;
/**
* Set to `true` for invalid state.
*/
this.invalid = false;
/**
* Set to `true` to show a warning (contents set by warningText)
*/
this.warn = false;
/**
* set to `true` to place the dropdown view inline with the component
*/
this.appendInline = null;
/**
* Specify feedback (mode) of the selection.
* `top`: selected item jumps to top
* `fixed`: selected item stays at it's position
* `top-after-reopen`: selected item jump to top after reopen dropdown
*/
this.selectionFeedback = "top-after-reopen";
/**
* Accessible label for the button that opens the dropdown list.
* Defaults to the `DROPDOWN.OPEN` value from the i18n service.
*/
this.menuButtonLabel = this.i18n.get().DROPDOWN.OPEN;
/**
* Provides the label for the "# selected" text.
* Defaults to the `DROPDOWN.SELECTED` value from the i18n service.
*/
this.selectedLabel = this.i18n.get().DROPDOWN.SELECTED;
/**
* Emits selection events.
*/
this.selected = new EventEmitter();
/**
* Emits event notifying to other classes that the `Dropdown` has been closed (collapsed).
*/
this.onClose = new EventEmitter();
/**
* Emits event notifying to other classes that the `Dropdown` has been closed (collapsed).
*/
this.close = new EventEmitter();
this.hostClass = true;
/**
* Set to `true` if the dropdown is closed (not expanded).
*/
this.menuIsClosed = true;
/**
* controls whether the `drop-up` class is applied
*/
this._dropUp = false;
// .bind creates a new function, so we declare the methods below
// but .bind them up here
this.noop = this._noop.bind(this);
this.outsideClick = this._outsideClick.bind(this);
this.outsideKey = this._outsideKey.bind(this);
this.keyboardNav = this._keyboardNav.bind(this);
this.visibilitySubscription = new Subscription();
this.onTouchedCallback = this._noop;
// primarily used to capture and propagate input to `writeValue` before the content is available
this._writtenValue = [];
/**
* function passed in by `registerOnChange`
*/
this.propagateChange = (_) => { };
}
get writtenValue() {
return this._writtenValue;
}
set writtenValue(val) {
if (val && val.length === 0) {
this.clearSelected();
}
this._writtenValue = val;
}
/**
* Updates the `type` property in the `@ContentChild`.
* The `type` property specifies whether the `Dropdown` allows single selection or multi selection.
*/
ngOnInit() {
if (this.view) {
this.view.type = this.type;
}
}
/**
* Initializes classes and subscribes to events for single or multi selection.
*/
ngAfterContentInit() {
if (!this.view) {
return;
}
if ((this.writtenValue && this.writtenValue.length) || typeof this.writtenValue === "number") {
this.writeValue(this.writtenValue);
}
this.view.type = this.type;
this.view.size = this.size;
// function to check if the event is organic (isUpdate === false) or programmatic
const isUpdate = event => event && event.isUpdate;
this.view.select.subscribe(event => {
if (this.type === "single" && !isUpdate(event) && !Array.isArray(event)) {
this.closeMenu();
if (event.item && event.item.selected) {
if (this.itemValueKey) {
this.propagateChange(event.item[this.itemValueKey]);
}
else {
this.propagateChange(event.item);
}
}
else {
this.propagateChange(null);
}
}
if (this.type === "multi" && !isUpdate(event)) {
// if we have a `value` selector and selected items map them appropriately
if (this.itemValueKey && this.view.getSelected()) {
const values = this.view.getSelected().map(item => item[this.itemValueKey]);
this.propagateChange(values);
// otherwise just pass up the values from `getSelected`
}
else {
this.propagateChange(this.view.getSelected());
}
}
// only emit selected for "organic" selections
if (!isUpdate(event)) {
this.checkForReorder();
this.selected.emit(event);
}
});
}
ngAfterViewInit() {
// if appendInline is default valued (null) we should:
// 1. if there are scrollable parents (not including body) don't append inline
// this should also cover the case where the dropdown is in a modal
// (where we _do_ want to append to the placeholder)
if (this.appendInline === null && hasScrollableParents(this.elementRef.nativeElement)) {
this.appendInline = false;
// 2. otherwise we should append inline
}
else if (this.appendInline === null) {
this.appendInline = true;
}
this.checkForReorder();
}
/**
* Removing the `Dropdown` from the body if it is appended to the body.
*/
ngOnDestroy() {
if (!this.appendInline) {
this._appendToDropdown();
}
}
/**
* Propagates the injected `value`.
*/
writeValue(value) {
// cache the written value so we can use it in `AfterContentInit`
this.writtenValue = value;
this.view.onItemsReady(() => {
// propagate null/falsey as an array (deselect everything)
if (!value) {
this.view.propagateSelected([value]);
}
else if (this.type === "single") {
if (this.itemValueKey) {
// clone the specified item and update its state
const newValue = Object.assign({}, this.view.getListItems().find(item => item[this.itemValueKey] === value));
newValue.selected = true;
this.view.propagateSelected([newValue]);
}
else {
// pass the singular value as an array of ListItem
this.view.propagateSelected([value]);
}
}
else {
if (this.itemValueKey) {
// clone the items and update their state based on the received value array
// this way we don't lose any additional metadata that may be passed in via the `items` Input
let newValues = [];
for (const v of value) {
for (const item of this.view.getListItems()) {
if (item[this.itemValueKey] === v) {
newValues.push(Object.assign({}, item, { selected: true }));
}
}
}
this.view.propagateSelected(newValues);
}
else {
// we can safely assume we're passing an array of `ListItem`s
this.view.propagateSelected(value);
}
}
this.checkForReorder();
});
}
onBlur() {
this.onTouchedCallback();
}
registerOnChange(fn) {
this.propagateChange = fn;
}
/**
* Registering the function injected to control the touch use of the `Dropdown`.
*/
registerOnTouched(fn) {
this.onTouchedCallback = fn;
}
/**
* `ControlValueAccessor` method to programmatically disable the dropdown.
*
* ex: `this.formGroup.get("myDropdown").disable();`
*
* @param isDisabled `true` to disable the input
*/
setDisabledState(isDisabled) {
this.disabled = isDisabled;
}
/**
* Adds keyboard functionality for navigation, selection and closing of the `Dropdown`.
*/
onKeyDown(event) {
if ((event.key === "Escape") && !this.menuIsClosed) {
event.stopImmediatePropagation(); // don't unintentionally close other widgets that listen for Escape
}
if (event.key === "Escape") {
event.preventDefault();
this.closeMenu();
this.dropdownButton.nativeElement.focus();
}
else if (this.menuIsClosed && (event.key === " " || event.key === "ArrowDown" || event.key === "ArrowUp")) {
if (this.disableArrowKeys && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
return;
}
event.preventDefault();
this.openMenu();
}
if (!this.menuIsClosed && event.key === "Tab" && this.dropdownMenu.nativeElement.contains(event.target)) {
this.closeMenu();
}
if (!this.menuIsClosed && event.key === "Tab" && event.shiftKey) {
this.closeMenu();
}
if (this.type === "multi") {
return;
}
if (this.menuIsClosed) {
this.closedDropdownNavigation(event);
}
}
closedDropdownNavigation(event) {
if (event.key === "ArrowDown") {
event.preventDefault();
this.view.getCurrentItem().selected = false;
let item = this.view.getNextItem();
if (item) {
item.selected = true;
}
}
else if (event.key === "ArrowUp") {
event.preventDefault();
this.view.getCurrentItem().selected = false;
let item = this.view.getPrevItem();
if (item) {
item.selected = true;
}
}
}
/**
* Returns the display value if there is a selection and displayValue is set,
* if there is just a selection the ListItem content property will be returned,
* otherwise the placeholder will be returned.
*/
getDisplayStringValue() {
if (!this.view || this.skeleton) {
return;
}
let selected = this.view.getSelected();
if (selected.length && (!this.displayValue || !this.isRenderString())) {
if (this.type === "multi") {
return of(this.placeholder);
}
else {
return of(selected[0].content);
}
}
else if (selected.length && this.isRenderString()) {
return of(this.displayValue);
}
return of(this.placeholder);
}
isRenderString() {
return typeof this.displayValue === "string";
}
getRenderTemplateContext() {
if (!this.view) {
return;
}
let selected = this.view.getSelected();
if (this.type === "multi") {
return { items: selected };
}
else if (selected && selected.length > 0) {
return { item: selected[0] }; // this is to be compatible with the dropdown-list template
}
else {
return {};
}
}
getSelectedCount() {
if (this.view.getSelected()) {
return this.view.getSelected().length;
}
}
clearSelected() {
if (this.disabled || this.getSelectedCount() === 0) {
return;
}
for (const item of this.view.getListItems()) {
item.selected = false;
}
this.selected.emit([]);
this.propagateChange([]);
}
/**
* Returns `true` if there is a value selected.
*/
valueSelected() {
if (this.view.getSelected()) {
return true;
}
return false;
}
_noop() { }
/**
* Handles clicks outside of the `Dropdown`.
*/
_outsideClick(event) {
if (!this.elementRef.nativeElement.contains(event.target) &&
// if we're appendToBody the list isn't within the _elementRef,
// so we've got to check if our target is possibly in there too.
!this.dropdownMenu.nativeElement.contains(event.target)) {
this.closeMenu();
}
}
_outsideKey(event) {
if (!this.menuIsClosed && event.key === "Tab" && this.dropdownMenu.nativeElement.contains(event.target)) {
this.closeMenu();
}
}
/**
* Handles keyboard events so users are controlling the `Dropdown` instead of unintentionally controlling outside elements.
*/
_keyboardNav(event) {
if (event.key === "Escape" && !this.menuIsClosed) {
event.stopImmediatePropagation(); // don't unintentionally close modal if inside of it
}
if (event.key === "Escape") {
event.preventDefault();
this.closeMenu();
this.dropdownButton.nativeElement.focus();
}
else if (!this.menuIsClosed && event.key === "Tab") {
// this way focus will start on the next focusable item from the dropdown
// not the top of the body!
this.dropdownButton.nativeElement.focus();
this.dropdownButton.nativeElement.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "Tab" }));
this.closeMenu();
}
}
/**
* Creates the `Dropdown` list appending it to the dropdown parent object instead of the body.
*/
_appendToDropdown() {
this.dropdownService.appendToDropdown(this.elementRef.nativeElement);
this.dropdownMenu.nativeElement.removeEventListener("keydown", this.keyboardNav, true);
}
/**
* Creates the `Dropdown` list as an element that is appended to the DOM body.
*/
_appendToBody() {
const lightClass = this.theme === "light" ? " cds--list-box--light" : "";
const expandedClass = !this.menuIsClosed ? " cds--list-box--expanded" : "";
this.dropdownService.appendToBody(this.dropdownButton.nativeElement, this.dropdownMenu.nativeElement, `${this.elementRef.nativeElement.className}${lightClass}${expandedClass}`);
this.dropdownMenu.nativeElement.addEventListener("keydown", this.keyboardNav, true);
}
/**
* Detects whether or not the `Dropdown` list is visible within all scrollable parents.
* This can be overridden by passing in a value to the `dropUp` input.
*/
_shouldDropUp() {
// check if dropdownMenu exists first.
const menu = this.dropdownMenu && this.dropdownMenu.nativeElement.querySelector(".cds--list-box__menu");
// check if menu exists first.
const menuRect = menu && menu.getBoundingClientRect();
if (menu && menuRect) {
const scrollableParents = getScrollableParents(menu);
return scrollableParents.reduce((shouldDropUp, parent) => {
const parentRect = parent.getBoundingClientRect();
const isBelowParent = !(menuRect.bottom <= parentRect.bottom);
return shouldDropUp || isBelowParent;
}, false);
}
return false;
}
/**
* Expands the dropdown menu in the view.
*/
openMenu() {
// prevents the dropdown from opening when list of items is empty
if (this.view.getListItems().length === 0) {
return;
}
this._dropUp = false;
this.menuIsClosed = false;
// move the dropdown list to the body if we're not appending inline
// and position it relative to the dropdown wrapper
if (!this.appendInline) {
const target = this.dropdownButton.nativeElement;
const parent = this.elementRef.nativeElement;
this.visibilitySubscription = this.elementService
.visibility(target, parent)
.subscribe(value => {
if (!value.visible) {
this.closeMenu();
}
});
this._appendToBody();
}
// set the dropdown menu to drop up if it's near the bottom of the screen
// setTimeout lets us measure after it's visible in the DOM
setTimeout(() => {
if (this.dropUp === null || this.dropUp === undefined) {
this._dropUp = this._shouldDropUp();
}
}, 0);
// we bind noop to document.body.firstElementChild to allow safari to fire events
// from document. Then we unbind everything later to keep things light.
document.body.firstElementChild.addEventListener("click", this.noop, true);
document.body.firstElementChild.addEventListener("keydown", this.noop, true);
document.addEventListener("click", this.outsideClick, true);
document.addEventListener("keydown", this.outsideKey, true);
setTimeout(() => this.view.initFocus(), 0);
}
/**
* Collapsing the dropdown menu and removing unnecessary `EventListeners`.
*/
closeMenu() {
// return early if the menu is already closed
if (this.menuIsClosed) {
return;
}
this.menuIsClosed = true;
this.checkForReorder();
this.onClose.emit();
this.close.emit();
// focus the trigger button when we close ...
this.dropdownButton.nativeElement.focus();
// remove the conditional once this api is settled and part of abstract-dropdown-view.class
if (this.view["disableScroll"]) {
this.view["disableScroll"]();
}
// move the list back in the component on close
if (!this.appendInline) {
this.visibilitySubscription.unsubscribe();
this._appendToDropdown();
}
document.body.firstElementChild.removeEventListener("click", this.noop, true);
document.body.firstElementChild.removeEventListener("keydown", this.noop, true);
document.removeEventListener("click", this.outsideClick, true);
document.removeEventListener("keydown", this.outsideKey, true);
}
/**
* Controls toggling menu states between open/expanded and closed/collapsed.
*/
toggleMenu() {
if (this.menuIsClosed) {
this.openMenu();
}
else {
this.closeMenu();
}
}
isTemplate(value) {
return value instanceof TemplateRef;
}
/**
* Controls when it's needed to apply the selection feedback
*/
checkForReorder() {
const topAfterReopen = this.menuIsClosed && this.selectionFeedback === "top-after-reopen";
if ((this.type === "multi") && (topAfterReopen || this.selectionFeedback === "top")) {
this.view.reorderSelected();
}
}
}
Dropdown.dropdownCount = 0;
Dropdown.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "14.3.0", ngImport: i0, type: Dropdown, deps: [{ token: i0.ElementRef }, { token: i1$1.I18n }, { token: DropdownService }, { token: i2.ElementService }], target: i0.ɵɵFactoryTarget.Component });
Dropdown.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "14.3.0", type: Dropdown, selector: "cds-dropdown, ibm-dropdown", inputs: { id: "id", label: "label", hideLabel: "hideLabel", helperText: "helperText", placeholder: "placeholder", displayValue: "displayValue", clearText: "clearText", size: "size", type: "type", theme: "theme", disabled: "disabled", skeleton: "skeleton", inline: "inline", disableArrowKeys: "disableArrowKeys", invalid: "invalid", invalidText: "invalidText", warn: "warn", warnText: "warnText", appendInline: "appendInline", scrollableContainer: "scrollableContainer", itemValueKey: "itemValueKey", selectionFeedback: "selectionFeedback", menuButtonLabel: "menuButtonLabel", selectedLabel: "selectedLabel", dropUp: "dropUp" }, outputs: { selected: "selected", onClose: "onClose", close: "close" }, host: { listeners: { "keydown": "onKeyDown($event)" }, properties: { "class.cds--dropdown__wrapper": "this.hostClass" } }, providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: Dropdown,
multi: true
}
], queries: [{ propertyName: "view", first: true, predicate: AbstractDropdownView, descendants: true, static: true }], viewQueries: [{ propertyName: "dropdownButton", first: true, predicate: ["dropdownButton"], descendants: true, static: true }, { propertyName: "dropdownMenu", first: true, predicate: ["dropdownMenu"], descendants: true, static: true }], ngImport: i0, template: `
<label
*ngIf="label && !skeleton"
[for]="id"
class="cds--label"
[ngClass]="{
'cds--label--disabled': disabled,
'cds--visually-hidden': hideLabel
}">
<ng-container *ngIf="!isTemplate(label)">{{label}}</ng-container>
<ng-template *ngIf="isTemplate(label)" [ngTemplateOutlet]="label"></ng-template>
</label>
<div
class="cds--list-box"
[ngClass]="{
'cds--dropdown': type !== 'multi',
'cds--multiselect': type === 'multi',
'cds--multi-select--selected': type === 'multi' && getSelectedCount() > 0,
'cds--dropdown--light': theme === 'light',
'cds--list-box--light': theme === 'light',
'cds--list-box--inline': inline,
'cds--skeleton': skeleton,
'cds--dropdown--disabled cds--list-box--disabled': disabled,
'cds--dropdown--invalid': invalid,
'cds--dropdown--warning cds--list-box--warning': warn,
'cds--dropdown--sm cds--list-box--sm': size === 'sm',
'cds--dropdown--md cds--list-box--md': size === 'md',
'cds--dropdown--lg cds--list-box--lg': size === 'lg',
'cds--list-box--expanded': !menuIsClosed
}">
<button
#dropdownButton
[id]="id"
type="button"
class="cds--list-box__field"
[ngClass]="{'a': !menuIsClosed}"
[attr.aria-expanded]="!menuIsClosed"
[attr.aria-disabled]="disabled"
aria-haspopup="listbox"
(click)="disabled ? $event.stopPropagation() : toggleMenu()"
(blur)="onBlur()"
[attr.disabled]="disabled ? true : null">
<div
(click)="clearSelected()"
(keydown.enter)="clearSelected()"
*ngIf="type === 'multi' && getSelectedCount() > 0"
class="cds--list-box__selection cds--tag--filter cds--list-box__selection--multi"
tabindex="0"
[title]="clearText">
{{getSelectedCount()}}
<svg
focusable="false"
preserveAspectRatio="xMidYMid meet"
style="will-change: transform;"
role="img"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
aria-hidden="true">
<path d="M12 4.7l-.7-.7L8 7.3 4.7 4l-.7.7L7.3 8 4 11.3l.7.7L8 8.7l3.3 3.3.7-.7L8.7 8z"></path>
</svg>
</div>
<span *ngIf="isRenderString()" class="cds--list-box__label">{{getDisplayStringValue() | async}}</span>
<ng-template
*ngIf="!isRenderString()"
[ngTemplateOutletContext]="getRenderTemplateContext()"
[ngTemplateOutlet]="displayValue">
</ng-template>
<svg
*ngIf="invalid"
class="cds--dropdown__invalid-icon"
cdsIcon="warning--filled"
size="16">
</svg>
<svg
*ngIf="!invalid && warn"
cdsIcon="warning--alt--filled"
size="16"
class="cds--list-box__invalid-icon cds--list-box__invalid-icon--warning">
</svg>
<span class="cds--list-box__menu-icon">
<svg
*ngIf="!skeleton"
cdsIcon="chevron--down"
size="16"
[attr.aria-label]="menuButtonLabel"
[ngClass]="{'cds--list-box__menu-icon--open': !menuIsClosed }">
</svg>
</span>
</button>
<div
#dropdownMenu
[ngClass]="{
'cds--list-box--up': this.dropUp !== null && this.dropUp !== undefined ? dropUp : _dropUp
}">
<ng-content *ngIf="!menuIsClosed"></ng-content>
</div>
</div>
<div
*ngIf="helperText && !invalid && !warn && !skeleton"
class="cds--form__helper-text"
[ngClass]="{
'cds--form__helper-text--disabled': disabled
}">
<ng-container *ngIf="!isTemplate(helperText)">{{helperText}}</ng-container>
<ng-template *ngIf="isTemplate(helperText)" [ngTemplateOutlet]="helperText"></ng-template>
</div>
<div *ngIf="invalid" class="cds--form-requirement">
<ng-container *ngIf="!isTemplate(invalidText)">{{ invalidText }}</ng-container>
<ng-template *ngIf="isTemplate(invalidText)" [ngTemplateOutlet]="invalidText"></ng-template>
</div>
<div *ngIf="!invalid && warn" class="cds--form-requirement">
<ng-container *ngIf="!isTemplate(warnText)">{{warnText}}</ng-container>
<ng-template *ngIf="isTemplate(warnText)" [ngTemplateOutlet]="warnText"></ng-template>
</div>
`, isInline: true, dependencies: [{ kind: "directive", type: i4.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i4.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i4.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "directive", type: i5.IconDirective, selector: "[cdsIcon], [ibmIcon]", inputs: ["ibmIcon", "cdsIcon", "size", "title", "ariaLabel", "ariaLabelledBy", "ariaHidden", "isFocusable"] }, { kind: "pipe", type: i4.AsyncPipe, name: "async" }] });
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "14.3.0", ngImport: i0, type: Dropdown, decorators: [{
type: Component,
args: [{
selector: "cds-dropdown, ibm-dropdown",
template: `
<label
*ngIf="label && !skeleton"
[for]="id"
class="cds--label"
[ngClass]="{
'cds--label--disabled': disabled,
'cds--visually-hidden': hideLabel
}">
<ng-container *ngIf="!isTemplate(label)">{{label}}</ng-container>
<ng-template *ngIf="isTemplate(label)" [ngTemplateOutlet]="label"></ng-template>
</label>
<div
class="cds--list-box"
[ngClass]="{
'cds--dropdown': type !== 'multi',
'cds--multiselect': type === 'multi',
'cds--multi-select--selected': type === 'multi' && getSelectedCount() > 0,
'cds--dropdown--light': theme === 'light',
'cds--list-box--light': theme === 'light',
'cds--list-box--inline': inline,
'cds--skeleton': skeleton,
'cds--dropdown--disabled cds--list-box--disabled': disabled,
'cds--dropdown--invalid': invalid,
'cds--dropdown--warning cds--list-box--warning': warn,
'cds--dropdown--sm cds--list-box--sm': size === 'sm',
'cds--dropdown--md cds--list-box--md': size === 'md',
'cds--dropdown--lg cds--list-box--lg': size === 'lg',
'cds--list-box--expanded': !menuIsClosed
}">
<button
#dropdownButton
[id]="id"
type="button"
class="cds--list-box__field"
[ngClass]="{'a': !menuIsClosed}"
[attr.aria-expanded]="!menuIsClosed"
[attr.aria-disabled]="disabled"
aria-haspopup="listbox"
(click)="disabled ? $event.stopPropagation() : toggleMenu()"
(blur)="onBlur()"
[attr.disabled]="disabled ? true : null">
<div
(click)="clearSelected()"
(keydown.enter)="clearSelected()"
*ngIf="type === 'multi' && getSelectedCount() > 0"
class="cds--list-box__selection cds--tag--filter cds--list-box__selection--multi"
tabindex="0"
[title]="clearText">
{{getSelectedCount()}}
<svg
focusable="false"
preserveAspectRatio="xMidYMid meet"
style="will-change: transform;"
role="img"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
aria-hidden="true">
<path d="M12 4.7l-.7-.7L8 7.3 4.7 4l-.7.7L7.3 8 4 11.3l.7.7L8 8.7l3.3 3.3.7-.7L8.7 8z"></path>
</svg>
</div>
<span *ngIf="isRenderString()" class="cds--list-box__label">{{getDisplayStringValue() | async}}</span>
<ng-template
*ngIf="!isRenderString()"
[ngTemplateOutletContext]="getRenderTemplateContext()"
[ngTemplateOutlet]="displayValue">
</ng-template>
<svg
*ngIf="invalid"
class="cds--dropdown__invalid-icon"
cdsIcon="warning--filled"
size="16">
</svg>
<svg
*ngIf="!invalid && warn"
cdsIcon="warning--alt--filled"
size="16"
class="cds--list-box__invalid-icon cds--list-box__invalid-icon--warning">
</svg>
<span class="cds--list-box__menu-icon">
<svg
*ngIf="!skeleton"
cdsIcon="chevron--down"
size="16"
[attr.aria-label]="menuButtonLabel"
[ngClass]="{'cds--list-box__menu-icon--open': !menuIsClosed }">
</svg>
</span>
</button>
<div
#dropdownMenu
[ngClass]="{
'cds--list-box--up': this.dropUp !== null && this.dropUp !== undefined ? dropUp : _dropUp
}">
<ng-content *ngIf="!menuIsClosed"></ng-content>
</div>
</div>
<div
*ngIf="helperText && !invalid && !warn && !skeleton"
class="cds--form__helper-text"
[ngClass]="{
'cds--form__helper-text--disabled': disabled
}">
<ng-container *ngIf="!isTemplate(helperText)">{{helperText}}</ng-container>
<ng-template *ngIf="isTemplate(helperText)" [ngTemplateOutlet]="helperText"></ng-template>
</div>
<div *ngIf="invalid" class="cds--form-requirement">
<ng-container *ngIf="!isTemplate(invalidText)">{{ invalidText }}</ng-container>
<ng-template *ngIf="isTemplate(invalidText)" [ngTemplateOutlet]="invalidText"></ng-template>
</div>
<div *ngIf="!invalid && warn" class="cds--form-requirement">
<ng-container *ngIf="!isTemplate(warnText)">{{warnText}}</ng-container>
<ng-template *ngIf="isTemplate(warnText)" [ngTemplateOutlet]="warnText"></ng-template>
</div>
`,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: Dropdown,
multi: true
}
]
}]
}], ctorParameters: function () { return [{ type: i0.ElementRef }, { type: i1$1.I18n }, { type: DropdownService }, { type: i2.ElementService }]; }, propDecorators: { id: [{
type: Input
}], label: [{
type: Input
}], hideLabel: [{
type: Input
}], helperText: [{
type: Input
}], placeholder: [{
type: Input
}], displayValue: [{
type: Input
}], clearText: [{
type: Input
}], size: [{
type: Input
}], type: [{
type: Input
}], theme: [{
type: Input
}], disabled: [{
type: Input
}], skeleton: [{
type: Input
}], inline: [{
type: Input
}], disableArrowKeys: [{
type: Input
}], invalid: [{
type: Input
}], invalidText: [{
type: Input
}], warn: [{
type: Input
}], warnText: [{
type: Input
}], appendInline: [{
type: Input
}], scrollableContainer: [{
type: Input
}], itemValueKey: [{
type: Input
}], selectionFeedback: [{
type: Input
}], menuButtonLabel: [{
type: Input
}], selectedLabel: [{
type: Input
}], dropUp: [{
type: Input
}], selected: [{
type: Output
}], onClose: [{
type: Output
}], close: [{
type: Output
}], view: [{
type: ContentChild,
args: [AbstractDropdownView, { static: true }]
}], dropdownButton: [{
type: ViewChild,
args: ["dropdownButton", { static: true }]
}], dropdownMenu: [{
type: ViewChild,
args: ["dropdownMenu", { static: true }]
}], hostClass: [{
type: HostBinding,
args: ["class.cds--dropdown__wrapper"]
}], onKeyDown: [{
type: HostListener,
args: ["keydown", ["$event"]]
}] } });
/**
* returns an observable bound to keydown events that
* filters to a single element where the first letter of
* it's textContent matches the key pressed
*
* @param target element to watch
* @param elements elements to search
*/
function watchFocusJump(target, elements) {
return fromEvent(target, "keydown")
.pipe(debounceTime(150), map((ev) => {
let el = elements.find((itemEl) => itemEl.textContent.trim().toLowerCase().startsWith(ev.key));
if (el) {
return el;
}
}), filter(el => !!el));
}
/** bundle of functions to aid in manipulating tree structures */
const treetools = {
/** finds an item in a set of items and returns the item and path to the item as an array */
find: function (items, itemToFind, path = []) {
let found;
for (let i of items) {
if (i === itemToFind) {
path.push(i);
found = i;
}
if (i.items && !found) {
path.push(i);
found = this.find(i.items, itemToFind, path).found;
if (!found) {
path = [];
}
}
}
return { found, path };
}
};
/**
* ```html
* <cds-dropdown-list [items]="listItems"></cds-dropdown-list>
* ```
* ```typescript
* listItems = [
* {
* content: "item one",
* selected: false
* },
* {
* content: "item two",
* selected: false,
* },
* {
* content: "item three",
* selected: false
* },
* {
* content: "item four",
* selected: false
* }
* ];
* ```
*/
class DropdownList {
/**
* Creates an instance of `DropdownList`.
*/
constructor(elementRef, i18n, appRef) {
this.elementRef = elementRef;
this.i18n = i18n;
this.appRef = appRef;
this.ariaLabel = this.i18n.get().DROPDOWN_LIST.LABEL;
/**
* Template to bind to items in the `DropdownList` (optional).
*/
this.listTpl = null;
/**
* Event to emit selection of a list item within the `DropdownList`.
*/
this.select = new EventEmitter();
/**
* Event to emit scroll event of a list within the `DropdownList`.
*/
this.scroll = new EventEmitter();
/**
* Event to suggest a blur on the view.
* Emits _after_ the first/last item has been focused.
* ex.
* ArrowUp -> focus first item
* ArrowUp -> emit event
*
* When this event fires focus should be placed on some element outside of the list - blurring the list as a result
*/
this.blurIntent = new EventEmitter();
/**
* Defines whether or not the `DropdownList` supports selecting multiple items as opposed to single
* item selection.
*/
this.type = "single";
/**
* Defines whether to show title attribute or not
*/
this.showTitles = true;
/**
* Defines the rendering size of the `DropdownList` input component.
*/
this.size = "md";
this.listId = `listbox-${DropdownList.listCount++}`;
this.highlightedItem = null;
/**
* Holds the list of items that will be displayed in the `DropdownList`.
* It differs from the the complete set of items when filtering is used (but
* it is always a subset of the total items in `DropdownList`).
*/
this.displayItems = [];
/**
* Maintains the index for the selected item within the `DropdownList`.
*/
this.index = -1;
/**
* Useful representation of the items, should be accessed via `getListItems`.
*/
this._items = [];
}
/**
* The list items belonging to the `DropdownList`.
*/
set items(value) {
if (isObservable(value)) {
if (this._itemsSubscription) {
this._itemsSubscription.unsubscribe();