Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Autocomplete): Autocomplete now works with ChipList #1326

Merged
merged 1 commit into from
Jul 21, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions projects/novo-elements/src/elements/chips/ChipInput.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { BooleanInput, coerceBooleanProperty } from '@angular/cdk/coercion';
import { hasModifierKey } from '@angular/cdk/keycodes';
import { Directive, ElementRef, EventEmitter, forwardRef, Inject, Input, OnChanges, Output } from '@angular/core';
import { NgControl } from '@angular/forms';
import { Key } from '../../utils';
import { NovoChipsDefaultOptions, NOVO_CHIPS_DEFAULT_OPTIONS } from './ChipDefaults';
import { NovoChipList } from './ChipList';
Expand All @@ -19,8 +20,8 @@ export interface NovoChipInputEvent {
let nextUniqueId = 0;

/**
* Directive that adds chip-specific behaviors to an input element inside `<mat-form-field>`.
* May be placed inside or outside of an `<mat-chip-list>`.
* Directive that adds chip-specific behaviors to an input element inside `<novo-form-field>`.
* May be placed inside or outside of an `<novo-chip-list>`.
*/
@Directive({
selector: 'input[novoChipInput]',
Expand Down Expand Up @@ -70,7 +71,7 @@ export class NovoChipInput implements NovoChipTextControl, OnChanges {
@Input() placeholder: string = '';

/** Unique id for the input. */
@Input() id: string = `mat-chip-list-input-${nextUniqueId++}`;
@Input() id: string = `novo-chip-list-input-${nextUniqueId++}`;

/** Whether the input is disabled. */
@Input()
Expand All @@ -94,6 +95,7 @@ export class NovoChipInput implements NovoChipTextControl, OnChanges {
protected _elementRef: ElementRef<HTMLInputElement>,
@Inject(NOVO_CHIPS_DEFAULT_OPTIONS) private _defaultOptions: NovoChipsDefaultOptions,
@Inject(forwardRef(() => NovoChipList)) private _chipList: NovoChipList,
protected ngControl: NgControl,
) {
this._inputElement = this._elementRef.nativeElement as HTMLInputElement;
this._chipList.registerInput(this);
Expand Down Expand Up @@ -156,6 +158,12 @@ export class NovoChipInput implements NovoChipTextControl, OnChanges {
this._inputElement.focus(options);
}

/** Focuses the input. */
clearValue(): void {
this._inputElement.value = '';
this.ngControl?.control.setValue('');
}

/** Checks whether a keycode is one of the configured separators. */
private _isSeparatorKey(event: KeyboardEvent) {
return !hasModifierKey(event) && new Set(this.separatorKeyCodes).has(event.key);
Expand Down
35 changes: 24 additions & 11 deletions projects/novo-elements/src/elements/chips/ChipList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { BooleanInput, coerceBooleanProperty } from '@angular/cdk/coercion';
import { SelectionModel } from '@angular/cdk/collections';
import {
AfterContentInit,
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Expand Down Expand Up @@ -56,7 +57,7 @@ export class NovoChipListChange {
}

/**
* A material design chips component (named ChipList for its similarity to the List component).
* A chip list component (named ChipList for its similarity to the List component).
*/
@Component({
selector: 'novo-chip-list',
Expand Down Expand Up @@ -92,7 +93,7 @@ export class NovoChipListChange {
})
export class NovoChipList
extends _NovoChipListMixinBase
implements NovoFieldControl<any>, ControlValueAccessor, AfterContentInit, DoCheck, OnInit, OnDestroy, CanUpdateErrorState
implements NovoFieldControl<any>, ControlValueAccessor, AfterViewInit, AfterContentInit, DoCheck, OnInit, OnDestroy, CanUpdateErrorState
{
/**
* Implemented as part of NovoFieldControl.
Expand Down Expand Up @@ -212,8 +213,8 @@ export class NovoChipList
return this._value;
}
set value(value: any) {
this.writeValue(value);
this._value = value;
this.writeValue(value);
}
protected _value: any;

Expand Down Expand Up @@ -331,7 +332,7 @@ export class NovoChipList

/** Combined stream of all of the child chips' remove change events. */
get chipRemoveChanges(): Observable<NovoChipEvent> {
return merge(...this.chips.map((chip) => chip.destroyed));
return merge(...this.chips.map((chip) => chip.removed));
}

/** Event emitted when the selected chip list value has been changed by the user. */
Expand Down Expand Up @@ -400,9 +401,6 @@ export class NovoChipList

this._resetChips();

// Reset chips selected/deselected status
this._initializeSelection();

// Check to see if we need to update our tab index
this._updateTabIndex();

Expand All @@ -413,6 +411,11 @@ export class NovoChipList
});
}

ngAfterViewInit() {
// Reset chips selected/deselected status
this._initializeSelection();
}

ngOnInit() {
this._selectionModel = new SelectionModel<NovoChipElement>(this.multiple, undefined, false);
this.stateChanges.next();
Expand Down Expand Up @@ -460,9 +463,19 @@ export class NovoChipList
writeValue(value: any): void {
if (this.chips) {
this._setSelectionByValue(value, false);
this.stateChanges.next();
}
}

addValue(value: any): void {
this.value = [...this.value, value];
this._chipInput.clearValue();
}

removeValue(value: any): void {
this.value = this.value.filter((it) => !this.compareWith(it, value));
}

// Implemented as part of ControlValueAccessor.
registerOnChange(fn: (value: any) => void): void {
this._onChange = fn;
Expand Down Expand Up @@ -620,9 +633,8 @@ export class NovoChipList
// Defer setting the value in order to avoid the "Expression
// has changed after it was checked" errors from Angular.
Promise.resolve().then(() => {
if (this.ngControl || this._value) {
this._setSelectionByValue(this.ngControl ? this.ngControl.value : this._value, false);
this.stateChanges.next();
if (this.ngControl) {
this.value = this.ngControl.value;
}
});
}
Expand Down Expand Up @@ -791,13 +803,14 @@ export class NovoChipList
this._chipRemoveSubscription = this.chipRemoveChanges.subscribe((event) => {
const chip = event.chip;
const chipIndex = this.chips.toArray().indexOf(event.chip);

this.removeValue(chip.value);
// In case the chip that will be removed is currently focused, we temporarily store
// the index in order to be able to determine an appropriate sibling chip that will
// receive focus.
if (this._isValidIndex(chipIndex) && chip._hasFocus) {
this._lastDestroyedChipIndex = chipIndex;
}
this.stateChanges.next();
});
}

Expand Down
2 changes: 2 additions & 0 deletions projects/novo-elements/src/elements/chips/ChipTextControl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,6 @@ export interface NovoChipTextControl {

/** Focuses the text control. */
focus(options?: FocusOptions): void;

clearValue(): void;
}
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ export class NovoOptionBase implements FocusableOption, AfterViewChecked, OnDest
if (!this._selected) {
this._selected = true;
this._changeDetectorRef.markForCheck();
this._emitSelectionChangeEvent();
// this._emitSelectionChangeEvent();
}
}

Expand All @@ -143,7 +143,7 @@ export class NovoOptionBase implements FocusableOption, AfterViewChecked, OnDest
if (this._selected) {
this._selected = false;
this._changeDetectorRef.markForCheck();
this._emitSelectionChangeEvent();
// this._emitSelectionChangeEvent();
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ActiveDescendantKeyManager } from '@angular/cdk/a11y';
import { BooleanInput, coerceBooleanProperty } from '@angular/cdk/coercion';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { hasModifierKey } from '@angular/cdk/keycodes';
import {
AfterContentInit,
Expand All @@ -22,9 +22,10 @@ import {
ViewChild,
ViewEncapsulation,
} from '@angular/core';
import { Key } from '../../../utils';
import { fromEvent, merge, of, Subscription } from 'rxjs';
import { take } from 'rxjs/operators';
import { fromEvent, interval, merge, of, Subscription } from 'rxjs';
import { debounce, take } from 'rxjs/operators';
import { BooleanInput, Key } from '../../../utils';
import type { NovoChipList } from '../../chips';
import {
CanDisable,
CanDisableCtor,
Expand Down Expand Up @@ -111,7 +112,7 @@ export class NovoAutocompleteElement
/** Whether the user should be allowed to select multiple options. */
@Input()
get multiple(): boolean {
return this._multiple || !!this._formField._control?.multiple || this._formField._control?.controlType === 'chip-list';
return this._multiple;
}
set multiple(value: boolean) {
this._multiple = coerceBooleanProperty(value);
Expand All @@ -120,6 +121,7 @@ export class NovoAutocompleteElement

/** Whether the toggle button is disabled. */
@Input()
@BooleanInput()
get disabled(): boolean {
if (this._disabled === undefined && this._formField?._control) {
return this._formField._control.disabled;
Expand Down Expand Up @@ -170,6 +172,9 @@ export class NovoAutocompleteElement
this.options.changes.subscribe(() => {
this._watchStateChanges();
this._watchSelectionEvents();
Promise.resolve().then(() => {
this.checkSelectedOptions();
});
});
}

Expand All @@ -180,32 +185,49 @@ export class NovoAutocompleteElement

checkPanel() {
const isTriggered = this.triggerOn(this._formField._control);
console.log('checking', isTriggered);
if (isTriggered && this.element) {
this.openPanel();
}
}

private _setTriggerValue(value: any): void {
const toDisplay = this.displayWith ? this.displayWith(value) : value;
private _setTriggerValue(option: NovoOption): void {
const toDisplay = this.displayWith ? this.displayWith(option) : option?.viewValue;
// Simply falling back to an empty string if the display value is falsy does not work properly.
// The display value can also be the number zero and shouldn't fall back to an empty string.
const inputValue = toDisplay != null ? toDisplay : '';
const displayValue = toDisplay != null ? toDisplay : '';
const optionValue = option.value;
console.log('optionValue', optionValue);
// If it's used within a `NovoField`, we should set it through the property so it can go
// through change detection.
if (this._formField) {
const { controlType, lastCaretPosition = 0 } = this._formField._control;
if (controlType === 'textarea') {
const currentValue = this._formField._control.value.split('');
currentValue.splice(lastCaretPosition, 0, inputValue);
currentValue.splice(lastCaretPosition, 0, displayValue);
this._formField._control.value = currentValue.join('');
} else if (controlType === 'chip-list') {
const chipList = this._formField._control as NovoChipList;
const currentValue = this._formField._control.value;
if (currentValue.includes(optionValue)) {
chipList.removeValue(optionValue);
} else {
chipList.addValue(optionValue);
}
} else {
let valueToEmit: any = inputValue;
let valueToEmit: any = optionValue;
if (this.multiple) {
const currentValue = this._formField._control.value;
if (Array.isArray(currentValue)) {
valueToEmit = [...currentValue, inputValue];
if (currentValue.includes(optionValue)) {
valueToEmit = currentValue.filter((it) => it === optionValue);
} else {
valueToEmit = [...currentValue, optionValue];
}
} else if (currentValue === optionValue) {
valueToEmit = [];
} else {
valueToEmit = [currentValue, inputValue];
valueToEmit = [currentValue, optionValue];
}
}
this._formField._control.value = valueToEmit;
Expand All @@ -214,7 +236,7 @@ export class NovoAutocompleteElement
// this._element.nativeElement.value = inputValue;
console.warn(`AutoComplete only intended to be used within a NovoField`);
}
this._previousValue = inputValue;
this._previousValue = optionValue;
}

/**
Expand All @@ -241,16 +263,16 @@ export class NovoAutocompleteElement
*/
private _setValueAndClose(event: NovoOptionSelectionChange | null): void {
if (event && event.source) {
this._clearPreviousSelectedOption(event.source);
this._setTriggerValue(event.source.value);
if (!this.multiple) this._clearPreviousSelectedOption(event.source);
this._setTriggerValue(event.source);
// this._onChange(event.source.value);
// this._element.nativeElement.focus();
this._formField._control.focus();
// this._formField._control.focus();
this._emitSelectEvent(event.source);
this._watchSelectionEvents();
}

if (!this._multiple) this.closePanel();
if (!this.multiple) this.closePanel();
}

private _watchSelectionEvents() {
Expand All @@ -262,12 +284,16 @@ export class NovoAutocompleteElement
}

private _watchStateChanges() {
const inputStateChanged = this._formField && this._formField._control ? this._formField._control.stateChanges : of();
const inputStateChanged = this._formField.stateChanges;
this._stateChanges.unsubscribe();
this._stateChanges = merge(inputStateChanged).subscribe(() => {
this.checkPanel();
this.cdr.markForCheck();
});
this._stateChanges = merge(inputStateChanged)
.pipe(debounce(() => interval(10)))
.subscribe(() => {
console.log('StateChagn');
this.checkSelectedOptions();
this.checkPanel();
this.cdr.markForCheck();
});
}

/** The currently active option, coerced to MatOption type. */
Expand Down Expand Up @@ -310,5 +336,22 @@ export class NovoAutocompleteElement
}
}

static ngAcceptInputType_disabled: BooleanInput;
private checkSelectedOptions() {
if (this.multiple && Array.isArray(this._formField._control.value)) {
const value = this._formField._control.value;
this.options.forEach((option) => option.deselect());
value.forEach((currentValue: any) => this._selectValue(currentValue));
}
}
/**
* Finds and selects and option based on its value.
* @returns Option that has the corresponding value.
*/
private _selectValue(value: any): NovoOption | undefined {
const correspondingOption = this.options.find((option: NovoOption) => {
return option.value === value;
});
correspondingOption?.select();
return correspondingOption;
}
}
Loading