Skip to content

Commit

Permalink
feat(dropdown): keyboard navigation & support
Browse files Browse the repository at this point in the history
  • Loading branch information
Joshua Godi committed Jun 21, 2017
1 parent 305550a commit 8fb2def
Show file tree
Hide file tree
Showing 4 changed files with 151 additions and 27 deletions.
39 changes: 32 additions & 7 deletions demo/pages/elements/dropdown/DropdownDemo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,38 @@ export class DropdownDemoComponent {
public CustomClassTpl: string = CustomClassTpl;
public CrazyLargeTpl: string = CrazyLargeTpl;

public items: string[] = [];

constructor() {
for (let i = 0; i < 50; i++) {
this.items.push('ITEM!');
}
}
public MOCK_WORDS: string[] = [
'lorem', 'ipsum', 'dolor', 'sit', 'amet', 'consectetur',
'adipiscing', 'elit', 'curabitur', 'vel', 'hendrerit', 'libero',
'eleifend', 'blandit', 'nunc', 'ornare', 'odio', 'ut',
'orci', 'gravida', 'imperdiet', 'nullam', 'purus', 'lacinia',
'a', 'pretium', 'quis', 'congue', 'praesent', 'sagittis',
'laoreet', 'auctor', 'mauris', 'non', 'velit', 'eros',
'dictum', 'proin', 'accumsan', 'sapien', 'nec', 'massa',
'volutpat', 'venenatis', 'sed', 'eu', 'molestie', 'lacus',
'quisque', 'porttitor', 'ligula', 'dui', 'mollis', 'tempus',
'at', 'magna', 'vestibulum', 'turpis', 'ac', 'diam',
'tincidunt', 'id', 'condimentum', 'enim', 'sodales', 'in',
'hac', 'habitasse', 'platea', 'dictumst', 'aenean', 'neque',
'fusce', 'augue', 'leo', 'eget', 'semper', 'mattis',
'tortor', 'scelerisque', 'nulla', 'interdum', 'tellus', 'malesuada',
'rhoncus', 'porta', 'sem', 'aliquet', 'et', 'nam',
'suspendisse', 'potenti', 'vivamus', 'luctus', 'fringilla', 'erat',
'donec', 'justo', 'vehicula', 'ultricies', 'varius', 'ante',
'primis', 'faucibus', 'ultrices', 'posuere', 'cubilia', 'curae',
'etiam', 'cursus', 'aliquam', 'quam', 'dapibus', 'nisl',
'feugiat', 'egestas', 'class', 'aptent', 'taciti', 'sociosqu',
'ad', 'litora', 'torquent', 'per', 'conubia', 'nostra',
'inceptos', 'himenaeos', 'phasellus', 'nibh', 'pulvinar', 'vitae',
'urna', 'iaculis', 'lobortis', 'nisi', 'viverra', 'arcu',
'morbi', 'pellentesque', 'metus', 'commodo', 'ut', 'facilisis',
'felis', 'tristique', 'ullamcorper', 'placerat', 'aenean', 'convallis',
'sollicitudin', 'integer', 'rutrum', 'duis', 'est', 'etiam',
'bibendum', 'donec', 'pharetra', 'vulputate', 'maecenas', 'mi',
'fermentum', 'consequat', 'suscipit', 'aliquam', 'habitant', 'senectus',
'netus', 'fames', 'quisque', 'euismod', 'curabitur', 'lectus',
'elementum', 'tempor', 'risus', 'cras'
];

public clickMe(data: string): void {
console.log('CLICKED!', data); // tslint:disable-line
Expand Down
2 changes: 1 addition & 1 deletion src/elements/dropdown/Dropdown.scss
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,8 @@ novo-dropdown-container {
color: darken($light, 55%);
}
&.active {
background: lighten($light, 10%);
color: darken($light, 55%);
font-weight: 500;
}
&.disabled {
color: $light;
Expand Down
135 changes: 117 additions & 18 deletions src/elements/dropdown/Dropdown.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// NG2
import { Component, ElementRef, EventEmitter, OnInit, OnDestroy, Input, Output, ViewChild, DoCheck, Renderer, HostListener } from '@angular/core';
import { Component, ElementRef, EventEmitter, OnInit, AfterContentInit, OnDestroy, Input, Output, ViewChild, DoCheck, Renderer, HostListener, ContentChildren, QueryList } from '@angular/core';
// APP
import { OutsideClick } from '../../utils/outside-click/OutsideClick';
import { KeyCodes } from '../../utils/key-codes/KeyCodes';
Expand Down Expand Up @@ -102,6 +102,11 @@ export class NovoDropdownElement extends OutsideClick implements OnInit, OnDestr
clickHandler: any;
closeHandler: any;
parentScrollElement: Element;
private _items: QueryList<NovoItemElement>;
private _textItems: string[];
private activeIndex: number = -1;
private filterTerm: string = '';
private filterTermTimeout: any;

constructor(element: ElementRef) {
super(element);
Expand All @@ -119,7 +124,15 @@ export class NovoDropdownElement extends OutsideClick implements OnInit, OnDestr
});
}

ngOnInit() {
public set items(items: QueryList<NovoItemElement>) {
this._items = items;
// Get the innertext of all the items to allow for searching
this._textItems = items.map((item: NovoItemElement) => {
return item.element.nativeElement.innerText;
});
}

public ngOnInit(): void {
// Add a click handler to the button to toggle the menu
let button = this.element.nativeElement.querySelector('button');
button.addEventListener('click', this.clickHandler);
Expand All @@ -128,7 +141,7 @@ export class NovoDropdownElement extends OutsideClick implements OnInit, OnDestr
}
}

ngOnDestroy() {
public ngOnDestroy(): void {
// Remove listener
let button = this.element.nativeElement.querySelector('button');
if (button) {
Expand Down Expand Up @@ -171,36 +184,108 @@ export class NovoDropdownElement extends OutsideClick implements OnInit, OnDestr

@HostListener('keydown', ['$event'])
public onKeyDown(event: KeyboardEvent): void {
// Close with ESC/Enter
if (this.active && (event.keyCode === KeyCodes.ESC || event.keyCode === KeyCodes.ENTER)) {
Helpers.swallowEvent(event);

if (this.active && event.keyCode === KeyCodes.ESC) {
// active & esc hit -- close
this.toggleActive();
} else if (event.keyCode === KeyCodes.ENTER) {
// enter -- perform the "click"
this._items.toArray()[this.activeIndex].onClick();
} else if (event.keyCode === KeyCodes.DOWN) {
// down - navigate through the list ignoring disabled ones
if (this.activeIndex !== -1) {
this._items.toArray()[this.activeIndex].active = false;
}
this.activeIndex++;
if (this.activeIndex === this._items.length) {
this.activeIndex = 0;
}
while (this._items.toArray()[this.activeIndex].disabled) {
this.activeIndex++;
if (this.activeIndex === this._items.length) {
this.activeIndex = 0;
}
}
this._items.toArray()[this.activeIndex].active = true;
this.scrollToActive();
} else if (event.keyCode === KeyCodes.UP) {
// up -- navigate through the list ignoring disabled ones
if (this.activeIndex !== -1) {
this._items.toArray()[this.activeIndex].active = false;
}
this.activeIndex--;
if (this.activeIndex < 0) {
this.activeIndex = this._items.length - 1;
}
while (this._items.toArray()[this.activeIndex].disabled) {
this.activeIndex--;
if (this.activeIndex < 0) {
this.activeIndex = this._items.length - 1;
}
}
this._items.toArray()[this.activeIndex].active = true;
this.scrollToActive();
} else if ((event.keyCode >= 65 && event.keyCode <= 90) || (event.keyCode >= 96 && event.keyCode <= 105) || (event.keyCode >= 48 && event.keyCode <= 57) || event.keyCode === KeyCodes.SPACE) {
// A-Z, 0-9, space -- filter the list and scroll to active filter
// filter has hard reset after 2s
clearTimeout(this.filterTermTimeout);
this.filterTermTimeout = setTimeout(() => { this.filterTerm = ''; }, 2000);
let char = String.fromCharCode(event.keyCode);
this.filterTerm = this.filterTerm.concat(char);
let index = this._textItems.findIndex((value: string) => {
return value.toLocaleLowerCase().indexOf(this.filterTerm.toLowerCase()) !== -1;
});
if (index !== -1) {
if (this.activeIndex !== -1) {
this._items.toArray()[this.activeIndex].active = false;
}
this.activeIndex = index;
this._items.toArray()[this.activeIndex].active = true;
this.scrollToActive();
}
} else if ([KeyCodes.BACKSPACE, KeyCodes.DELETE].includes(event.keyCode)) {
// backspace, delete -- remove partial filters
clearTimeout(this.filterTermTimeout);
this.filterTermTimeout = setTimeout(() => { this.filterTerm = ''; }, 2000);
this.filterTerm = this.filterTerm.slice(0, -1);
}
}
}

@Component({
selector: 'list',
template: '<ng-content></ng-content>'
})
export class NovoListElement {
private scrollToActive(): void {
let container = this.element.nativeElement.querySelector('novo-dropdown-container');
let item = this._items.toArray()[this.activeIndex];
if (container && item) {
container.scrollTop = item.element.nativeElement.offsetTop;
} else {
// Append to body
container = document.querySelector('body > novo-dropdown-container');
if (container && item) {
container.scrollTop = item.element.nativeElement.offsetTop;
}
}
}
}

@Component({
selector: 'item',
template: '<ng-content></ng-content>',
host: {
'[class.disabled]': 'disabled'
'[class.disabled]': 'disabled',
'[class.active]': 'active'
}
})
export class NovoItemElement {
@Input() disabled: boolean;
@Input() keepOpen: boolean = false;
@Output() action: EventEmitter<any> = new EventEmitter();
@Input() public disabled: boolean;
@Input() public keepOpen: boolean = false;
@Output() public action: EventEmitter<any> = new EventEmitter();

constructor(private dropdown: NovoDropdownElement) { }
public active: boolean = false;

constructor(private dropdown: NovoDropdownElement, public element: ElementRef) { }

@HostListener('click', ['$event'])
public onClick(event: MouseEvent): void {
@HostListener('click', [])
public onClick(): void {
// Poor man's disable
if (!this.disabled) {
// Close if keepOpen is false
Expand All @@ -213,6 +298,20 @@ export class NovoItemElement {
}
}

@Component({

This comment has been minimized.

Copy link
@bvkimball

bvkimball Jun 22, 2017

Contributor

probably need to prefix these soon, is now the right time?

selector: 'list',
template: '<ng-content></ng-content>'
})
export class NovoListElement implements AfterContentInit {
@ContentChildren(NovoItemElement) public items: QueryList<NovoItemElement>;

constructor(private dropdown: NovoDropdownElement) { }

public ngAfterContentInit(): void {
this.dropdown.items = this.items;
}
}

@Component({
selector: 'dropdown-item-header',
template: '<ng-content></ng-content>',
Expand Down
2 changes: 1 addition & 1 deletion src/elements/table/extras/dropdown-cell/DropdownCell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export interface INovoDropdownCellConfig {
<list>
<ng-container *ngFor="let config of meta.dropdownCellConfig; let i = index">
<dropdown-item-header *ngIf="config.category">{{ config.category }}</dropdown-item-header>
<item *ngFor="let option of config.options" (click)="onClick(config, option, option.value)" [class.active]="(option || option.value) === value">
<item *ngFor="let option of config.options" (action)="onClick(config, option, option.value)" [class.active]="(option || option.value) === value">
<span [attr.data-automation-id]="option.label || option">{{ option.label || option }}</span> <i *ngIf="(option || option.value) === value" class="bhi-check"></i>
</item>
<hr *ngIf="i < meta.dropdownCellConfig.length - 1"/>
Expand Down

0 comments on commit 8fb2def

Please sign in to comment.