File

src/pill-input/pill-input.component.ts

Description

Internal component used to render pills and the pill text input. There is a sizeable chunk of logic here handling focus and keyboard state around pills.

Implements

OnChanges AfterViewInit

Example

Metadata

selector ibm-pill-input

Index

Properties
Methods
Inputs
Outputs
HostBindings

Constructor

constructor(elementRef: ElementRef)

instaniates a pill-input

Parameters :
Name Type Optional Description
elementRef ElementRef

Inputs

disabled

is the input disabled. true/false

Default value: false

displayValue

value to display when something is selected

pillDirection

the direction of the pills

Type: "row" | "column"

Default value: row

pills

array of selected items

Type: Array<ListItem>

placeholder

value to display when nothing is selected

size

Type: "sm" | "md" | "lg"

Default value: md

type

"single" or "multi" for single or multi select lists

Type: "single" | "multi"

Default value: single

Outputs

blur

emitted when the component looses focus

$event type: EventEmitter
focus

emitted when the component is focused

$event type: EventEmitter
search

emitted when the user types into an input

$event type: EventEmitter
submit

emitted when the user presses enter and a value is present

$event type: EventEmitter
updatePills

empty event to trigger an update of the selected items

$event type: EventEmitter

HostBindings

style.width.%
style.width.%:
Default value : 100

Methods

Private checkPlaceholderVisibility
checkPlaceholderVisibility()

checks weather the placeholder should be displayed or not.

Returns : void
Public clearInputText
clearInputText(toSkip: )

clears the content of inputs

Parameters :
Name Type Optional Description
toSkip

input element to skip clearing

Returns : void
Public doResize
doResize()

sets the height of the input wrapper to the correct height for all selected pills

Returns : void
Public empty
empty(array: Array)

Helper method to check if an array is empty

Parameters :
Name Type Optional Description
array Array<any>
Returns : boolean
Public focusInput
focusInput(ev: )

focuses the correct input and handles clearing any unnecessary values from any other input

Parameters :
Name Type Optional Description
ev
Returns : void
Public getInputText
getInputText(ev: )

returns the text from an event, the textContent of the first filled pillInput, or an empty string

Parameters :
Name Type Optional Description
ev

optional event to pull text from

Returns : string
ngAfterViewInit
ngAfterViewInit()

Binds events on the view.

Returns : void

null

ngOnChanges
ngOnChanges(changes: )

Updates the pills, and subscribes to their remove events. Updates the displayValue and checks if it should be displayed.

Parameters :
Name Type Optional Description
changes
Returns : void
onKeydown
onKeydown(ev: KeyboardEvent)

handles deleting pills on backspace, submitting user input on enter, and navigating the pill list with arrow left/right

Parameters :
Name Type Optional Description
ev KeyboardEvent
Returns : void
onKeyup
onKeyup(ev: KeyboardEvent)

handles emmiting the search event

Parameters :
Name Type Optional Description
ev KeyboardEvent
Returns : void
Public setFocus
setFocus(state: boolean)

sets focus to state

Parameters :
Name Type Optional Description
state boolean
Returns : void
Private setSelection
setSelection(target: )

selects all the text in a given node

Parameters :
Name Type Optional Description
target

node to set the selection on

Returns : void
Public showMore
showMore(ev: )

toggles the expanded state of the input wrapper to show all pills

Parameters :
Name Type Optional Description
ev
Returns : void

Properties

Public expanded
expanded:
Default value : false

sets the expanded state (hide/show all selected pills)

Public expandedHeight
expandedHeight:
Default value : 0

height of the expanded input

Public focusActive
focusActive:
Default value : false

are we focused? needed because we have a lot of inputs that could steal focus and we need to set visual focus on the wrapper

inputWrapper
inputWrapper:
Decorators : ViewChild

ViewChild for the overall wrapper

Public numberMore
numberMore:
Default value : 0

number of pills hidden by overflow

pillInputs
pillInputs: QueryList<any>
Type : QueryList<any>
Decorators : ViewChildren

List of inputs

pillInstances
pillInstances: QueryList<Pill>
Type : QueryList<Pill>
Decorators : ViewChildren

list of instantiated pills

pillWrapper
pillWrapper:
Decorators : ViewChild

ViewChild of the pill wrapper

Public showPlaceholder
showPlaceholder:
Default value : true

should we show the placeholder value?

singleInput
singleInput:
Decorators : ViewChild

ViewChild for the single input input box

import {
	Component,
	Input,
	Output,
	EventEmitter,
	ElementRef,
	ViewChild,
	ViewChildren,
	QueryList,
	OnChanges,
	AfterViewInit,
	HostBinding
} from "@angular/core";
import { Pill } from "./pill.component";
import { ListItem } from "./../dropdown/list-item.interface";


/**
 * Internal component used to render pills and the pill text input.
 * There is a sizeable chunk of logic here handling focus and keyboard state around pills.
 *
 * @export
 * @class PillInput
 * @implements {OnChanges}
 * @implements {AfterViewInit}
 */
@Component({
	selector: "ibm-pill-input",
	template: `
		<div
			#inputWrapper
			*ngIf="type === 'multi'"
			role="textbox"
			class="pill_input_wrapper"
			[ngClass]="{
				'expand-overflow': expanded,
				focus: focusActive,
				disabled: disabled
			}"
			style="overflow: hidden;"
			(click)="focusInput($event)">
			<span
				*ngIf="showPlaceholder"
				class="input_placeholder">
				{{ placeholder }}
			</span>
			<div
				#pillWrapper
				[ngClass]="{
					'input_pills--column': pillDirection === 'column',
					'input_pills': pillDirection === 'row'
				}">
				<div style="display: flex" *ngFor="let pill of pills; let last = last">
					<ibm-pill
						[item]="pill">
						{{ pill.content }}
					</ibm-pill>
					<div
						#pillInput
						*ngIf="!last"
						class="input"
						contenteditable
						(keydown)="onKeydown($event)"
						(keyup)="onKeyup($event)"></div>
				</div>
				<div
					#pillInput
					class="input"
					contenteditable
					(keydown)="onKeydown($event)"
					(keyup)="onKeyup($event)"></div>
			</div>
			<button
				*ngIf="!expanded && numberMore > 0"
				class="btn--link"
				href=""
				(click)="showMore($event)">{{ numberMore }} more</button>
			<button
				*ngIf="expanded && numberMore > 0"
				class="btn--link"
				href=""
				(click)="showMore($event)">Hide</button>
		</div>
		<input
			#singleInput
			*ngIf="type === 'single'"
			type="text"
			[disabled]="disabled"
			[placeholder]="placeholder"
			(keydown)="onKeydown($event)"
			(keyup)="onKeyup($event)"/>`
})
export class PillInput implements OnChanges, AfterViewInit {
	/** are we focused? needed because we have a lot of inputs that could steal focus and we need to set visual focus on the wrapper */
	public focusActive = false;
	/** height of the expanded input */
	public expandedHeight = 0;
	/** number of pills hidden by overflow */
	public numberMore = 0;
	/** should we show the placeholder value? */
	public showPlaceholder = true;
	/** sets the expanded state (hide/show all selected pills) */
	public expanded = false;
	/** array of selected items */
	@Input() pills: Array<ListItem> = [];
	/** value to display when nothing is selected */
	@Input() placeholder = "";
	/** value to display when something is selected */
	@Input() displayValue = "";
	/** "single" or "multi" for single or multi select lists */
	@Input() type: "single" | "multi" = "single";
	@Input() size: "sm" | "md" | "lg" = "md";
	/** is the input disabled. true/false */
	@Input() disabled = false;
	/** the direction of the pills */
	@Input() pillDirection: "row" | "column" = "row";
	/** empty event to trigger an update of the selected items */
	@Output() updatePills = new EventEmitter();
	/** emitted when the user types into an input */
	@Output() search = new EventEmitter();
	/** emitted when the user presses enter and a value is present */
	@Output() submit = new EventEmitter();
	/** emitted when the component is focused */
	@Output() focus = new EventEmitter();
	/** emitted when the component looses focus */
	@Output() blur = new EventEmitter();
	/** ViewChild of the pill wrapper */
	@ViewChild("pillWrapper") pillWrapper;
	/** ViewChild for the overall wrapper */
	@ViewChild("inputWrapper") inputWrapper;
	/** ViewChild for the single input input box */
	@ViewChild("singleInput") singleInput;
	/** List of inputs */
	@ViewChildren("pillInput") pillInputs: QueryList<any>;
	/** list of instantiated pills */
	@ViewChildren(Pill) pillInstances: QueryList<Pill>;
	// needed since matter doesn't/can't account for the host element
	@HostBinding("style.width.%") width = "100";

	/** instaniates a pill-input */
	constructor(private elementRef: ElementRef) {}

	/**
	 * Updates the pills, and subscribes to their `remove` events.
	 * Updates the displayValue and checks if it should be displayed.
	 * @param changes
	 */
	ngOnChanges(changes) {
		if (changes.pills) {
			this.pills = changes.pills.currentValue;
			if (this.pillDirection === "column") {
				this.numberMore = this.pills.length - 1;
			}

			setTimeout(() => {
				if (this.pillInstances) {
					this.numberMore = 0;
					let pills = this.elementRef.nativeElement.querySelectorAll(".pill");
					if (pills.length > 1) {
						for (let pill of pills) {
							if (pill.offsetTop > 30) {
								this.numberMore++;
							}
						}
					}
					this.pillInstances.forEach(item => {
						item.remove.subscribe(() => {
							this.updatePills.emit();
							this.doResize();
							if (this.numberMore === 0) { this.expanded = false; }
						});
					});
					this.doResize();
				}
			});
		}
		if (changes.displayValue) {
			if (this.type === "single" && this.singleInput) {
				this.singleInput.nativeElement.value = changes.displayValue.currentValue;
			}
			this.checkPlaceholderVisibility();
		}
	}

	/**
	 * Binds events on the view.
	 * @returns null
	 * @memberof PillInput
	 */
	ngAfterViewInit() {
		if (this.inputWrapper) {
			this.inputWrapper.nativeElement.scrollTop = 0;
		}

		// TODO: move these to methods and late bind/eager unbind
		if (this.disabled) { return; }
		// collapse input on outside click
		document.addEventListener("click", ev => {
			if (this.elementRef.nativeElement.contains(ev.target)) {
				this.setFocus(true);
			} else {
				this.setFocus(false);
			}
			this.checkPlaceholderVisibility();
		});
		// keyup here because we want to get the element the event ends on
		// **not** the element the event starts from (that would be keydown)
		document.addEventListener("keyup", ev => {
			if (!this.elementRef.nativeElement.contains(ev.target)) {
				this.setFocus(false);
			} else {
				this.setFocus(true);
			}
			this.checkPlaceholderVisibility();
		});
		this.clearInputText();
	}

	/**
	 * Helper method to check if an array is empty
	 * @param {Array<any>} array
	 */
	public empty(array: Array<any>) {
		if (!array) {
			return true;
		}
		if (array.length === 0) {
			return true;
		}
		return false;
	}

	/**
	 * sets focus to state
	 *
	 * @param {boolean} state
	 */
	public setFocus(state: boolean) {
		this.focusActive = state;

		if (this.focusActive) {
			this.focus.emit();
		} else {
			this.blur.emit();
		}
	}

	/**
	 * focuses the correct input and handles clearing any unnecessary values from any other input
	 *
	 * @param ev
	 */
	public focusInput(ev) {
		if (this.disabled) { return; }
		this.setFocus(true);
		if (this.numberMore > 0 || this.pillDirection === "column") {
			this.expandedHeight = this.pillWrapper.nativeElement.offsetHeight;
			this.expanded = true;
		}
		if (this.pillInputs.find(input => input.nativeElement === ev.target)) {
			if (ev.target.textContent === "") {
				ev.target.textContent = "";
			}
			this.clearInputText(ev.target);
			this.setSelection(ev.target);
		} else if (this.getInputText()) {
			this.pillInputs.forEach(input => {
				if (input.nativeElement.textContent.trim() !== "") {
					this.setSelection(input.nativeElement);
				}
			});
		} else {
			if (this.pillInputs.last.nativeElement.textContent === "") {
				this.pillInputs.last.nativeElement.textContent = "";
			}
			this.setSelection(this.pillInputs.last.nativeElement);
		}
		this.inputWrapper.nativeElement.scrollTop = 0;
	}

	/**
	 * toggles the expanded state of the input wrapper to show all pills
	 *
	 * @param ev
	 */
	public showMore(ev) {
		ev.stopPropagation();
		ev.preventDefault();
		this.expanded = !this.expanded;
		this.doResize();
	}

	/**
	 * sets the height of the input wrapper to the correct height for all selected pills
	 */
	public doResize() {
		if (this.expanded) {
			this.expandedHeight = this.pillWrapper.nativeElement.offsetHeight;
		}
	}

	/**
	 * clears the content of inputs
	 *
	 * @param toSkip input element to skip clearing
	 */
	public clearInputText(toSkip = null) {
		if (this.pillInputs) {
			this.pillInputs.forEach(input => {
				if (!toSkip || input.nativeElement !== toSkip) {
					input.nativeElement.textContent = "";
				}
			});
		}
	}

	/**
	 * returns the text from an event, the textContent of the first filled pillInput, or an empty string
	 *
	 * @param ev optional event to pull text from
	 */
	public getInputText(ev = null): string {
		if (this.type === "multi") {
			if (ev) {
				return ev.target.textContent.trim();
			}
			if (this.pillInputs) {
				let text = this.pillInputs.find(input => input.nativeElement.textContent.trim() !== "");
				return text ? text.nativeElement.textContent.trim() : "";
			}
		}
		if (this.type === "single" && ev) {
			return ev.target.value.trim();
		}
		return "";
	}

	/**
	 * handles deleting pills on backspace, submitting user input on enter, and navigating the pill list with arrow left/right
	 *
	 * @param ev
	 */
	onKeydown(ev: KeyboardEvent) {
		if (ev.key === "Escape") {
			this.expanded = false;
		} else if (ev.key === "Backspace" && ev.target["textContent"] === "" && !this.empty(this.pills)) {
			// stop the window from navigating backwards
			ev.preventDefault();
			let inputIndex = this.pillInputs.toArray().findIndex(input => input.nativeElement === ev.target);
			if (inputIndex > -1) {
				this.pills[inputIndex].selected = false;
				// - 1 because the last one doesn't get removed, so the focus doesn't leave
				if (inputIndex < this.pillInputs.length - 1) {
					this.pillInputs.toArray()[inputIndex + 1].nativeElement.focus();
				}
			}
			this.updatePills.emit();
		} else if (ev.key === "Enter") {
			ev.preventDefault();
			if (this.getInputText()) {
				let inputIndex = this.pillInputs.toArray().findIndex(input => input.nativeElement.textContent.trim() !== "");
				this.submit.emit({
					after: this.pills[inputIndex],
					value: this.getInputText()
				});
				this.clearInputText();
			}
		} else if (ev.key === "ArrowLeft") {
			let index = this.pillInputs.toArray().findIndex(input => input.nativeElement === ev.target);
			let prevInput = this.pillInputs.toArray()[index - 1];
			if (prevInput) { prevInput.nativeElement.focus(); }
		} else if (ev.key === "ArrowRight") {
			let index = this.pillInputs.toArray().findIndex(input => input.nativeElement === ev.target);
			let nextInput = this.pillInputs.toArray()[index + 1];
			if (nextInput) { nextInput.nativeElement.focus(); }
		}
	}

	/** handles emmiting the search event */
	onKeyup(ev: KeyboardEvent) {
		this.doResize();
		this.clearInputText(ev.target);
		this.search.emit(this.getInputText(ev));
	}

	/**
	 * checks weather the placeholder should be displayed or not.
	 */
	private checkPlaceholderVisibility(): void {
		if (this.type === "single") {
			setTimeout(() => this.showPlaceholder = !this.displayValue && !this.focusActive && !this.getInputText());
		} else {
			setTimeout(() => this.showPlaceholder = this.empty(this.pills) && !this.focusActive && !this.getInputText());
		}
	}

	/**
	 * selects all the text in a given node
	 *
	 * @param target node to set the selection on
	 */
	private setSelection(target) {
		let selectionRange = document.createRange();
		let selection = window.getSelection();
		selectionRange.selectNodeContents(target);
		selection.removeAllRanges();
		selection.addRange(selectionRange);
		target.focus();
	}
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""