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
#list
role="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">
<li
role="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
#listItem
tabindex="-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">
<input
class="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 item
let tempOldItem: string | ListItem = Object.assign({}, oldItem);
// deleted selected because it's what we _want_ to change
delete tempOldItem.selected;
// stringify for compare
tempOldItem = JSON.stringify(tempOldItem);
for (let newItem of value) {
// copy the item
let tempNewItem: string | ListItem = Object.assign({}, newItem);
// deleted selected because it's what we _want_ to change
delete tempNewItem.selected;
// stringify for compare
tempNewItem = JSON.stringify(tempNewItem);
// do the compare
if (tempOldItem.includes(tempNewItem)) {
oldItem.selected = newItem.selected;
// if we've found a matching item, we can stop looping
break;
} 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 selected
const 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 values
if (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 values
if (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 selection
for (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
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)}]
/ @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
DropdownList
supports 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
ListItem
that 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
HTMLElement
for the item that is subsequent to the selected item. / getNextElement(): HTMLElement { return; } /* - Returns the
ListItem
that 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
HTMLElement
for 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
ListItem
that is selected withinDropdownList
. / getCurrentItem(): ListItem { return; } /* - Returns the
HTMLElement
for 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 {}
}
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 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: HTMLElement): HTMLElement {
// 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 as HTMLElement;
// 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();
}
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 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);
}
} ```