Skip to content

Commit

Permalink
chore(playground): add search for component list (#2939)
Browse files Browse the repository at this point in the history
  • Loading branch information
Uladzislau Sakalou authored Nov 22, 2021
1 parent fca5cd1 commit 1d8e814
Show file tree
Hide file tree
Showing 12 changed files with 269 additions and 126 deletions.
27 changes: 15 additions & 12 deletions src/app/app.component.scss
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
.framework-options-bar {
.toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
}

::ng-deep {
.options-bar {
display: flex;
align-items: center;
}
.options-show {
margin-left: auto;
}
.options-show.fixed {
.toolbar-toggle {
margin: 0 1.25rem;

&-fixed {
position: fixed;
right: 0;
top: 0;
left: 0;
top: 0.5rem;
}
}

.component-list-wrapper {
padding: 2rem;
flex: 0 0 100vw;
min-height: 100vh;
}
124 changes: 82 additions & 42 deletions src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,90 +4,130 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*/

import { AfterViewInit, Component, Inject, OnDestroy } from '@angular/core';
import { AfterViewInit, Component, ElementRef, Inject, OnDestroy, ViewChild } from '@angular/core';
import { NavigationStart, Router } from '@angular/router';
import { NB_DOCUMENT } from '@nebular/theme';
import { fromEvent, Subject } from 'rxjs';
import { filter, takeUntil } from 'rxjs/operators';
import { ComponentLink, PLAYGROUND_COMPONENTS } from './playground-components';
import { fromEvent, Observable, Subject } from 'rxjs';
import { filter, take, takeUntil } from 'rxjs/operators';
import { ComponentsListService } from './components-list.service';
import { ComponentLink } from './playground-components';

@Component({
selector: 'npg-app-root',
styleUrls: ['./app.component.scss'],
template: `
<div class="options-bar" dir="ltr">
<ng-container *ngIf="optionsVisible">
<nb-layout-direction-toggle></nb-layout-direction-toggle>
<div class="toolbar" dir="ltr">
<button (click)="toggleToolbar()" [class.toolbar-toggle-fixed]="!showToolbar" class="toolbar-toggle">
{{ showToolbar ? 'hide' : 'show' }} toolbar
</button>
<ng-container *ngIf="showToolbar">
<npg-layout-direction-toggle></npg-layout-direction-toggle>
<npg-layout-theme-toggle></npg-layout-theme-toggle>
<button (click)="showComponentsOverlay()">Components (c)</button>
<nb-components-overlay *ngIf="optionsVisible && componentsListVisible" (closeClicked)="hideComponentsOverlay()">
<nb-components-list [components]="components"></nb-components-list>
</nb-components-overlay>
<input
#componentSearch
type="text"
placeholder="Components search (/)"
(focus)="showComponentList()"
(click)="showComponentList()"
(input)="onSearchChange($event)"
(keyup.enter)="navigateToComponent()"
/>
<div class="component-list-wrapper" *ngIf="showComponentsList">
<button (click)="hideComponentsList()" tabindex="-1">hide list</button>
<npg-components-list [components]="components$ | async"></npg-components-list>
</div>
</ng-container>
<button (click)="toggleOptions()" [class.fixed]="!optionsVisible" class="options-show">
<ng-container *ngIf="optionsVisible">hide</ng-container>
<ng-container *ngIf="!optionsVisible">show</ng-container>
</button>
</div>
<router-outlet></router-outlet>
`,
})
export class AppComponent implements AfterViewInit, OnDestroy {
private destroy$ = new Subject<void>();
document: Document;
optionsVisible: boolean = true;
componentsListVisible: boolean = false;
components: ComponentLink[] = PLAYGROUND_COMPONENTS;
private readonly destroy$ = new Subject<void>();
private readonly document: Document;
private lastFocusedElement: HTMLElement;
showToolbar: boolean = true;
showComponentsList: boolean = false;
components$: Observable<ComponentLink[]> = this.componentsListService.components$;

@ViewChild('componentSearch') componentSearch: ElementRef;

constructor(@Inject(NB_DOCUMENT) document, private router: Router) {
constructor(
@Inject(NB_DOCUMENT) document,
private router: Router,
private componentsListService: ComponentsListService,
) {
this.document = document;
this.lastFocusedElement = this.document.body;
}

ngAfterViewInit() {
if (!this.document) {
return;
}

fromEvent<KeyboardEvent>(this.document, 'keypress')
.pipe(
filter((e: KeyboardEvent) => e.key === 'c'),
takeUntil(this.destroy$),
)
.subscribe(this.toggleComponentsOverlay.bind(this));

fromEvent<KeyboardEvent>(this.document, 'keyup')
.pipe(
filter((e: KeyboardEvent) => e.key === 'Escape' || e.key === 'Esc'),
takeUntil(this.destroy$),
)
.subscribe(() => this.hideComponentsOverlay());
.pipe(takeUntil(this.destroy$))
.subscribe((e: KeyboardEvent) => this.handleButtonPressUp(e));

this.router.events
.pipe(
filter((event) => event instanceof NavigationStart),
takeUntil(this.destroy$),
)
.subscribe(() => this.hideComponentsOverlay());
.subscribe(() => this.hideComponentsList());
}

ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}

toggleOptions() {
this.optionsVisible = !this.optionsVisible;
navigateToComponent(): void {
this.componentSearch.nativeElement.blur(); // remove focus from search input
this.componentsListService.selectedLink$.pipe(take(1), takeUntil(this.destroy$)).subscribe((selectedLink) => {
this.router.navigate([selectedLink]);
});
}

toggleComponentsOverlay() {
this.componentsListVisible = !this.componentsListVisible;
toggleToolbar() {
this.showToolbar = !this.showToolbar;
if (!this.showToolbar) {
this.hideComponentsList();
}
}

showComponentList(): void {
this.showComponentsList = true;
}

hideComponentsOverlay() {
this.componentsListVisible = false;
hideComponentsList(): void {
this.showComponentsList = false;
}

showComponentsOverlay() {
this.componentsListVisible = true;
onSearchChange(event): void {
this.showComponentList();
this.componentsListService.updateSearch(event.target.value);
}

private handleButtonPressUp(e: KeyboardEvent): void {
if (e.key === 'ArrowDown') {
this.componentsListService.selectNextComponent();
}

if (e.key === 'ArrowUp') {
this.componentsListService.selectPreviousComponent();
}

if (e.key === 'Escape') {
this.hideComponentsList();
this.lastFocusedElement.focus();
}

if (e.key === '/') {
this.lastFocusedElement = this.document.activeElement as HTMLElement;
this.componentSearch.nativeElement.focus();
}
}
}
4 changes: 2 additions & 2 deletions src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ import { NbThemeModule } from '@nebular/theme';
import { AppComponent } from './app.component';
import { LayoutDirectionToggleComponent } from './layout-direction-toggle/layout-direction-toggle.component';
import { LayoutThemeToggleComponent } from './layout-theme-toggle/layout-theme-toggle.component';
import { ComponentsOverlayComponent } from './components-list/components-overlay.component';
import { ComponentsListComponent } from './components-list/components-list.component';
import { NbEvaIconsModule } from '@nebular/eva-icons';
import { ComponentLinkDirective } from './components-link.directive';

@NgModule({
imports: [
Expand All @@ -43,8 +43,8 @@ import { NbEvaIconsModule } from '@nebular/eva-icons';
AppComponent,
LayoutDirectionToggleComponent,
LayoutThemeToggleComponent,
ComponentsOverlayComponent,
ComponentsListComponent,
ComponentLinkDirective,
],
bootstrap: [AppComponent],
})
Expand Down
47 changes: 47 additions & 0 deletions src/app/components-link.directive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { OnInit, ChangeDetectorRef, Directive, ElementRef, HostBinding, Input, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs';
import { distinctUntilChanged, map, takeUntil } from 'rxjs/operators';
import { ComponentsListService } from './components-list.service';

@Directive({
selector: '[npgComponentLink]',
})
export class ComponentLinkDirective implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();

@Input() npgComponentLink: string = '';

@HostBinding('class.selected')
selected = false;

constructor(
private componentsListService: ComponentsListService,
private cd: ChangeDetectorRef,
private elementRef: ElementRef<Element>,
) {}

ngOnInit() {
let isFirstEmission = true;
this.componentsListService.selectedLink$
.pipe(
map((selectedLink: string) => this.npgComponentLink === selectedLink),
distinctUntilChanged(),
takeUntil(this.destroy$),
)
.subscribe((isSelected) => {
this.selected = isSelected;
this.cd.markForCheck();

if (isFirstEmission) {
isFirstEmission = false;
} else {
this.elementRef.nativeElement.scrollIntoView({ block: 'nearest' });
}
});
}

ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}
96 changes: 96 additions & 0 deletions src/app/components-list.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
import { map, shareReplay, take } from 'rxjs/operators';
import { ComponentLink, PLAYGROUND_COMPONENTS } from './playground-components';

@Injectable({
providedIn: 'root',
})
export class ComponentsListService {
private readonly searchString$ = new BehaviorSubject<string>('');
private readonly selectedLinkIndex$ = new BehaviorSubject<number>(0);

readonly components$: Observable<ComponentLink[]> = this.searchString$.pipe(
map((searchString: string) => this.filter(searchString)),
shareReplay(1),
);

private readonly componentLinks$ = this.components$.pipe(
map((components: ComponentLink[]) => this.extractLinks(components)),
shareReplay(1),
);

readonly selectedLink$: Observable<string> = combineLatest([this.componentLinks$, this.selectedLinkIndex$]).pipe(
map(([filteredComponents, activeElementIndex]) => filteredComponents[activeElementIndex]),
shareReplay(1),
);

updateSearch(searchString: string): void {
this.selectedLinkIndex$.next(0);
this.searchString$.next(searchString);
}

selectNextComponent(): void {
this.moveSelection(1);
}

selectPreviousComponent(): void {
this.moveSelection(-1);
}

private moveSelection(offset: 1 | -1): void {
combineLatest([this.selectedLinkIndex$, this.componentLinks$])
.pipe(
map(([selectedIndex, components]: [number, string[]]) => [selectedIndex, components.length]),
take(1),
)
.subscribe(([selectedIndex, length]: number[]) => {
let indexToSelect = selectedIndex + offset;
const isOutOfBounds = indexToSelect < 0 || indexToSelect >= length;
// If we went out of bounds when moving forward (offset === 1), we should select the first element.
// Otherwise, we're moving backward, and after we pass the first element,
// we move the selection to the last one (length - 1).
if (isOutOfBounds && offset === 1) {
indexToSelect = 0;
}
if (isOutOfBounds && offset === -1) {
indexToSelect = length - 1;
}
this.selectedLinkIndex$.next(indexToSelect);
});
}

private extractLinks(componentLink: ComponentLink[]): string[] {
return componentLink.reduce((acc: string[], item) => {
if (item.link) {
acc.push(item.link);
}
if (item.children) {
acc = [...acc, ...this.extractLinks(item.children)];
}
return acc;
}, []);
}

private filter(searchString: string): ComponentLink[] {
if (searchString === '') {
return PLAYGROUND_COMPONENTS;
}

const filterBySearchString = (components: ComponentLink[], componentLink: ComponentLink) => {
if (componentLink.name?.toLowerCase().includes(searchString)) {
components.push(componentLink);
return components;
}
if (componentLink.children) {
const children = componentLink.children.reduce(filterBySearchString, []);
if (children.length) {
components.push({ ...componentLink, children });
}
}
return components;
};

return PLAYGROUND_COMPONENTS.reduce(filterBySearchString, []);
}
}
4 changes: 4 additions & 0 deletions src/app/components-list/components-list.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
.component-link {
display: block;

&.selected {
background: #ccc;
}

&:focus {
transform: scale(1.02);
}
Expand Down
Loading

0 comments on commit 1d8e814

Please sign in to comment.