https://angular.carbondesignsystem.com/?path=/story/components-dropdown—basic
组件及属性
dropdown
Code
import {Component,Input,Output,OnDestroy,EventEmitter,TemplateRef,AfterViewInit,ViewChild,ElementRef,ViewChildren,QueryList} from "@angular/core";import { Observable, isObservable, Subscription, of } from "rxjs";import { first } from "rxjs/operators";import { I18n } from "carbon-components-angular/i18n";import { AbstractDropdownView } from "../abstract-dropdown-view.class";import { ListItem } from "../list-item.interface";import { watchFocusJump } from "../dropdowntools";import { ScrollCustomEvent } from "./scroll-custom-event.interface";/*** ```html* <ibm-dropdown-list [items]="listItems"></ibm-dropdown-list>*
- ```typescript
- listItems = [
- {
- content: “item one”,
- selected: false
- },
- {
- content: “item two”,
- selected: false,
- },
- {
- content: “item three”,
- selected: false
- },
- {
- content: “item four”,
- selected: false
- }
- ];
*/@Component({selector: "ibm-dropdown-list",template: `<ul#listrole="listbox"class="bx--list-box__menu bx--multi-select"(scroll)="emitScroll($event)"(keydown)="navigateList($event)"tabindex="-1"[attr.aria-label]="ariaLabel"[attr.aria-activedescendant]="highlightedItem"><lirole="option"*ngFor="let item of displayItems; let i = index"(click)="doClick($event, item)"class="bx--list-box__menu-item"[attr.aria-selected]="item.selected"[id]="getItemId(i)"[attr.title]=" showTitles ? item.content : null"[ngClass]="{'bx--list-box__menu-item--active': item.selected,'bx--list-box__menu-item--highlighted': highlightedItem === getItemId(i),disabled: item.disabled}"><div#listItemtabindex="-1"class="bx--list-box__menu-item__option"><div*ngIf="!listTpl && type === 'multi'"class="bx--form-item bx--checkbox-wrapper"><label[attr.data-contained-checkbox-state]="item.selected"class="bx--checkbox-label"><inputclass="bx--checkbox"type="checkbox"[checked]="item.selected"[disabled]="item.disabled"tabindex="-1"><span class="bx--checkbox-appearance"></span><span class="bx--checkbox-label-text">{{item.content}}</span></label></div><ng-container *ngIf="!listTpl && type === 'single'">{{item.content}}</ng-container><svg*ngIf="!listTpl && type === 'single'"ibmIcon="checkmark"size="16"class="bx--list-box__menu-item__selected-icon"></svg><ng-template*ngIf="listTpl"[ngTemplateOutletContext]="{item: item}"[ngTemplateOutlet]="listTpl"></ng-template></div></li></ul>`,providers: [{provide: AbstractDropdownView,useExisting: DropdownList}]})// 注意,这里实现了 AbstractDropdownView 类export class DropdownList implements AbstractDropdownView, AfterViewInit, OnDestroy {static listCount = 0;@Input() ariaLabel = this.i18n.get().DROPDOWN_LIST.LABEL;/*** The list items belonging to the `DropdownList`.*/@Input() set items (value: Array<ListItem> | Observable<Array<ListItem>>) {if (isObservable(value)) {if (this._itemsSubscription) {this._itemsSubscription.unsubscribe();}this._itemsReady = new Observable<boolean>((observer) => {this._itemsSubscription = value.subscribe(v => {this.updateList(v);observer.next(true);observer.complete();});});this.onItemsReady(null);} else {this.updateList(value);}this._originalItems = value;}get items(): Array<ListItem> | Observable<Array<ListItem>> {return this._originalItems;}/*** Template to bind to items in the `DropdownList` (optional).*/@Input() listTpl: string | TemplateRef<any> = null;/*** Event to emit selection of a list item within the `DropdownList`.*/@Output() select: EventEmitter<{ item: ListItem, isUpdate?: boolean } | ListItem[]> = new EventEmitter();/*** Event to emit scroll event of a list within the `DropdownList`.*/@Output() scroll: EventEmitter<ScrollCustomEvent> = 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*/@Output() blurIntent = new EventEmitter<"top" | "bottom">();/*** Maintains a reference to the view DOM element for the unordered list of items within the `DropdownList`.*/// @ts-ignore@ViewChild("list", { static: true }) list: ElementRef;/*** Defines whether or not the `DropdownList` supports selecting multiple items as opposed to single* item selection.*/@Input() type: "single" | "multi" = "single";/*** Defines whether to show title attribute or not*/@Input() showTitles = true;/*** Defines the rendering size of the `DropdownList` input component.** @deprecated since v4*/public size: "sm" | "md" | "xl" = "md";public listId = `listbox-${DropdownList.listCount++}`;public 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`).*/public displayItems: Array<ListItem> = [];/*** Maintains the index for the selected item within the `DropdownList`.*/protected index = -1;/*** An array holding the HTML list elements in the view.*/@ViewChildren("listItem") protected listElementList: QueryList<ElementRef>;/*** Observable bound to keydown events to control filtering.*/protected focusJump;/*** Tracks the current (if any) subscription to the items observable so we can clean up when the input is updated.*/protected _itemsSubscription: Subscription;/*** Used to retain the original items passed to the setter.*/protected _originalItems: Array<ListItem> | Observable<Array<ListItem>>;/*** Useful representation of the items, should be accessed via `getListItems`.*/protected _items: Array<ListItem> = [];/*** Used to wait for items in case they are passed through an observable.*/protected _itemsReady: Observable<boolean>;/*** Creates an instance of `DropdownList`.*/constructor(public elementRef: ElementRef, protected i18n: I18n) {}/*** Retrieves array of list items and index of the selected item after view has rendered.* Additionally, any Observables for the `DropdownList` are initialized.*/ngAfterViewInit() {this.index = this.getListItems().findIndex(item => item.selected);this.setupFocusObservable();setTimeout(() => {this.doEmitSelect(true);});}/*** Removes any Observables on destruction of the component.*/ngOnDestroy() {if (this.focusJump) {this.focusJump.unsubscribe();}if (this._itemsSubscription) {this._itemsSubscription.unsubscribe();}}doEmitSelect(isUpdate = true) {if (this.type === "single") {this.select.emit({ item: this._items.find(item => item.selected), isUpdate: isUpdate });} else {// abuse javascripts object mutability until we can break the API and switch to// { items: [], isUpdate: true }const selected = this.getSelected() || [];selected["isUpdate"] = isUpdate;this.select.emit(selected);}}getItemId(index: number) {return `${this.listId}-${index}`;}/*** Updates the displayed list of items and then retrieves the most current properties for the `DropdownList` from the DOM.*/updateList(items) {this._items = items.map(item => Object.assign({}, item));this.displayItems = this._items;this.updateIndex();this.setupFocusObservable();setTimeout(() => {if (this.getSelected() !== []) { return; }this.doEmitSelect();});}/*** Filters the items being displayed in the DOM list.*/filterBy(query = "") {if (query) {this.displayItems = this.getListItems().filter(item => item.content.toLowerCase().includes(query.toLowerCase()));} else {this.displayItems = this.getListItems();}this.updateIndex();}/*** Initializes (or re-initializes) the Observable that handles switching focus to an element based on* key input matching the first letter of the item in the list.*/setupFocusObservable() {if (!this.list) { return; }if (this.focusJump) {this.focusJump.unsubscribe();}let elList = Array.from(this.list.nativeElement.querySelectorAll("li"));this.focusJump = watchFocusJump(this.list.nativeElement, elList).subscribe(el => {el.focus();});}/*** Returns the `ListItem` that is subsequent to the selected item in the `DropdownList`.*/getNextItem(): ListItem {if (this.index < this.displayItems.length - 1) {this.index++;}return this.displayItems[this.index];}/*** Returns `true` if the selected item is not the last item in the `DropdownList`.*/hasNextElement(): boolean {return this.index < this.displayItems.length - 1 &&(!(this.index === this.displayItems.length - 2) || !this.displayItems[this.index + 1].disabled);}/*** Returns the `HTMLElement` for the item that is subsequent to the selected item.*/getNextElement(): HTMLElement {if (this.index < this.displayItems.length - 1) {this.index++;}let item = this.displayItems[this.index];if (item.disabled) {return this.getNextElement();}let elemList = this.listElementList ? this.listElementList.toArray() : [];// TODO: update to optional chaining after upgrading typescript// to v3.7+if (elemList[this.index] && elemList[this.index].nativeElement) {return elemList[this.index].nativeElement;} else {return null;}}/*** Returns the `ListItem` that precedes the selected item within `DropdownList`.*/getPrevItem(): ListItem {if (this.index > 0) {this.index--;}return this.displayItems[this.index];}/*** Returns `true` if the selected item is not the first in the list.*/hasPrevElement(): boolean {return this.index > 0 && (!(this.index === 1) || !this.displayItems[0].disabled);}/*** Returns the `HTMLElement` for the item that precedes the selected item.*/getPrevElement(): HTMLElement {if (this.index > 0) {this.index--;}let item = this.displayItems[this.index];if (item.disabled) {return this.getPrevElement();}let elemList = this.listElementList ? this.listElementList.toArray() : [];// TODO: update to optional chaining after upgrading typescript// to v3.7+if (elemList[this.index] && elemList[this.index].nativeElement) {return elemList[this.index].nativeElement;} else {return null;}}/*** Returns the `ListItem` that is selected within `DropdownList`.*/getCurrentItem(): ListItem {if (this.index < 0) {return this.displayItems[0];}return this.displayItems[this.index];}/*** Returns the `HTMLElement` for the item that is selected within the `DropdownList`.*/getCurrentElement(): HTMLElement {if (this.index < 0) {return this.listElementList.first.nativeElement;}return this.listElementList.toArray()[this.index].nativeElement;}/*** Returns the items as an Array*/getListItems(): Array<ListItem> {return this._items;}/*** Returns a list containing the selected item(s) in the `DropdownList`.*/getSelected(): ListItem[] {let selected = this.getListItems().filter(item => item.selected);if (selected.length === 0) {return [];}return selected;}/*** Transforms array input list of items to the correct state by updating the selected item(s).*/propagateSelected(value: Array<ListItem>): void {// if we get a non-array, log out an error (since it is one)if (!Array.isArray(value)) {console.error(`${this.constructor.name}.propagateSelected expects an Array<ListItem>, got ${JSON.stringify(value)}`);}this.onItemsReady(() => {// loop through the list items and update the `selected` state for matching items in `value`for (let oldItem of this.getListItems()) {// copy the itemlet tempOldItem: string | ListItem = Object.assign({}, oldItem);// deleted selected because it's what we _want_ to changedelete tempOldItem.selected;// stringify for comparetempOldItem = JSON.stringify(tempOldItem);for (let newItem of value) {// copy the itemlet tempNewItem: string | ListItem = Object.assign({}, newItem);// deleted selected because it's what we _want_ to changedelete tempNewItem.selected;// stringify for comparetempNewItem = JSON.stringify(tempNewItem);// do the compareif (tempOldItem.includes(tempNewItem)) {oldItem.selected = newItem.selected;// if we've found a matching item, we can stop loopingbreak;} else {oldItem.selected = false;}}}});}/*** Initializes focus in the list, effectively a wrapper for `getCurrentElement().focus()`*/initFocus() {if (this.index < 0) {this.updateIndex();}this.list.nativeElement.focus();setTimeout(() => {this.highlightedItem = this.getItemId(this.index);});}updateIndex() {// initialize index on the first selected item or// on the next non disabled item if no items are selectedconst selected = this.getSelected();if (selected.length) {this.index = this.displayItems.indexOf(selected[0]);} else if (this.hasNextElement()) {this.getNextElement();}}/*** Manages the keyboard accessibility for navigation and selection within a `DropdownList`.* @deprecated since v4*/doKeyDown(event: KeyboardEvent, item: ListItem) {// "Spacebar", "Down", and "Up" are IE specific valuesif (event.key === "Enter" || event.key === " " || event.key === "Spacebar") {if (this.listElementList.some(option => option.nativeElement === event.target)) {event.preventDefault();}if (event.key === "Enter") {this.doClick(event, item);}} else if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Down" || event.key === "Up") {event.preventDefault();if (event.key === "ArrowDown" || event.key === "Down") {if (this.hasNextElement()) {// this.getNextElement().focus();this.getNextElement();} else {this.blurIntent.emit("bottom");}} else if (event.key === "ArrowUp" || event.key === "Up") {if (this.hasPrevElement()) {// this.getPrevElement().focus();this.getPrevElement();} else {this.blurIntent.emit("top");}}}}/*** Manages the keyboard accessibility for navigation and selection within a `DropdownList`.*/navigateList(event: KeyboardEvent) {// "Spacebar", "Down", and "Up" are IE specific valuesif (event.key === "Enter" || event.key === " " || event.key === "Spacebar") {if (this.listElementList.some(option => option.nativeElement === event.target)) {event.preventDefault();}if (event.key === "Enter") {this.doClick(event, this.getCurrentItem());}} else if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Down" || event.key === "Up") {event.preventDefault();if (event.key === "ArrowDown" || event.key === "Down") {if (this.hasNextElement()) {this.getNextElement();} else {this.blurIntent.emit("bottom");}} else if (event.key === "ArrowUp" || event.key === "Up") {if (this.hasPrevElement()) {this.getPrevElement();} else {this.blurIntent.emit("top");}}setTimeout(() => {this.highlightedItem = this.getItemId(this.index);});}}/*** Emits the selected item or items after a mouse click event has occurred.*/doClick(event, item) {event.preventDefault();if (!item.disabled) {this.list.nativeElement.focus();if (this.type === "single") {item.selected = true;// reset the selectionfor (let otherItem of this.getListItems()) {if (item !== otherItem) { otherItem.selected = false; }}} else {item.selected = !item.selected;}this.index = this.displayItems.indexOf(item);this.highlightedItem = this.getItemId(this.index);this.doEmitSelect(false);}}onItemFocus(index) {const element = this.listElementList.toArray()[index].nativeElement;element.classList.add("bx--list-box__menu-item--highlighted");element.tabIndex = 0;}onItemBlur(index) {const element = this.listElementList.toArray()[index].nativeElement;element.classList.remove("bx--list-box__menu-item--highlighted");element.tabIndex = -1;}/*** Emits the scroll event of the options list*/emitScroll(event) {const atTop: boolean = event.srcElement.scrollTop === 0;const atBottom: boolean = event.srcElement.scrollHeight - event.srcElement.scrollTop === event.srcElement.clientHeight;const customScrollEvent = { atTop, atBottom, event };this.scroll.emit(customScrollEvent);}/*** Subscribe the function passed to an internal observable that will resolve once the items are ready*/onItemsReady(subcription: () => void): void {// this subscription will auto unsubscribe because of the `first()` pipe(this._itemsReady || of(true)).pipe(first()).subscribe(subcription);}reorderSelected(moveFocus = true): void {this.displayItems = [...this.getSelected(), ...this.getListItems().filter(item => !item.selected)];if (moveFocus) {setTimeout(() => {this.updateIndex();this.highlightedItem = this.getItemId(this.index);});}}}
```typescript import { Input, Output, EventEmitter, Directive } from “@angular/core”; import { ListItem } from “./list-item.interface”; import { Observable } from “rxjs”;
/**
- A component that intends to be used within
Dropdownmust provide an implementation that extends this base class. - It also must provide the base class in the
@Componentmeta-data. ex:
providers: [{provide: AbstractDropdownView, useExisting: forwardRef(() => MyDropdownView)}]/ @Directive({ selector: “[ibmAbstractDropdownView]” }) export class AbstractDropdownView { /*The items to be displayed in the list within the
AbstractDropDownView. */ @Input() set items(value: Array| Observable >) { } get items(): Array
| Observable > { return; } /** - Emits selection events to controlling classes / @Output() select: EventEmitter<{item: ListItem } | ListItem[]>; /*
- 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 *
- It’s recommended that the implementing view include a specific type union of possible blurs
- ex.
@Output() blurIntent = new EventEmitter<"top" | "bottom">();/ @Output() blurIntent: EventEmitter; /* - Specifies whether or not the
DropdownListsupports selecting multiple items as opposed to single - item selection. / public type: “single” | “multi” = “single”; /*
- Specifies the render size of the items within the
AbstractDropdownView. * - @deprecated since v4 / public size: “sm” | “md” | “xl” = “md”; /*
- Returns the
ListItemthat is subsequent to the selected item in theDropdownList. / getNextItem(): ListItem { return; } /* - Returns a boolean if the currently selected item is preceded by another / hasNextElement(): boolean { return; } /*
- Returns the
HTMLElementfor the item that is subsequent to the selected item. / getNextElement(): HTMLElement { return; } /* - Returns the
ListItemthat precedes the selected item withinDropdownList. / getPrevItem(): ListItem { return; } /* - Returns a boolean if the currently selected item is followed by another / hasPrevElement(): boolean { return; } /*
- Returns the
HTMLElementfor the item that precedes the selected item. / getPrevElement(): HTMLElement { return; } /* - Returns the selected leaf level item(s) within the
DropdownList. / getSelected(): ListItem[] { return; } /* - Returns the
ListItemthat is selected withinDropdownList. / getCurrentItem(): ListItem { return; } /* - Returns the
HTMLElementfor the item that is selected within theDropdownList. / getCurrentElement(): HTMLElement { return; } /* - Guaranteed to return the current items as an Array.
/
getListItems(): Array
{ return; } /* - Transforms array input list of items to the correct state by updating the selected item(s).
/
propagateSelected(value: Array
): void {} /** - @param value value to filter the list by / filterBy(value: string): void {} /*
- Initializes focus in the list
- In most cases this just calls
getCurrentElement().focus()/ initFocus(): void {} /* - Subscribe the function passed to an internal observable that will resolve once the items are ready / onItemsReady(subcription: () => void): void {} /*
- Reorder selected items bringing them to the top of the list
*/
reorderSelected(moveFocus?: boolean): void {}
}
typescript import { Injectable, ElementRef, OnDestroy } from “@angular/core”; import { PlaceholderService } from “carbon-components-angular/placeholder”; import { Subscription } from “rxjs”; import { position } from “@carbon/utils-position”; import { AnimationFrameService } from “carbon-components-angular/utils”; import { closestAttr } from “carbon-components-angular/utils”;
const defaultOffset = { top: 0, left: 0 };
@Injectable() export class DropdownService implements OnDestroy { public set offset(value: { top?: number, left?: number }) { this._offset = Object.assign({}, defaultOffset, value); }
public get offset() {return this._offset;}/*** reference to the body appended menu*/protected menuInstance: HTMLElement;/*** 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*/protected animationFrameSubscription = new Subscription();protected _offset = defaultOffset;constructor(protected placeholderService: PlaceholderService,protected animationFrameService: AnimationFrameService) {}/*** Appends the menu to the body, or a `ibm-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: HTMLElement, menuRef: HTMLElement, classList): HTMLElement {// build the dropdown list containermenuRef.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 placeholderif (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 focusthis.positionDropdown(parentRef, dropdownWrapper);return dropdownWrapper;}/*** Reattach the dropdown menu to the parent container* @param hostRef container to append to*/appendToDropdown(hostRef: HTMLElement): HTMLElement {// if the instance is already removed don't try and remove it againif (!this.menuInstance) { return; }const instance = this.menuInstance;const menu = instance.firstElementChild as HTMLElement;// clean up the instancethis.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();}protected positionDropdown(parentRef, menuRef) {if (!menuRef) {return;}let leftOffset = 0;const boxMenu = menuRef.querySelector(".bx--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 ibm-placeholder has a parent with a position(relative|fixed|absolute) account for the parent offsetconst 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);}
} ```
