UNPKG

carbon-components-angular

Version:
1,273 lines (1,267 loc) 80.3 kB
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();