Add keyboard navigation and hepler to typeahead
authorRigel Kent <sendmemail@rigelk.eu>
Sat, 25 Jan 2020 15:32:06 +0000 (16:32 +0100)
committerRigel Kent <sendmemail@rigelk.eu>
Thu, 13 Feb 2020 15:32:21 +0000 (16:32 +0100)
client/src/app/app.module.ts
client/src/app/header/header.component.html
client/src/app/header/header.component.ts
client/src/app/header/index.ts
client/src/app/header/search-typeahead.component.html
client/src/app/header/search-typeahead.component.scss
client/src/app/header/search-typeahead.component.ts
client/src/app/header/suggestion.component.html [new file with mode: 0644]
client/src/app/header/suggestion.component.scss [new file with mode: 0644]
client/src/app/header/suggestion.component.ts [new file with mode: 0644]
client/src/app/header/suggestions.component.ts [new file with mode: 0644]

index 2db33d6382d069b89b0942dff8cb7409fe6c8a31..9e220a3836d83d516ef4cc0c974e260e5af9e070 100644 (file)
@@ -9,7 +9,7 @@ import 'focus-visible'
 import { AppRoutingModule } from './app-routing.module'
 import { AppComponent } from './app.component'
 import { CoreModule } from './core'
-import { HeaderComponent, SearchTypeaheadComponent } from './header'
+import { HeaderComponent, SearchTypeaheadComponent, SuggestionsComponent, SuggestionComponent } from './header'
 import { LoginModule } from './login'
 import { AvatarNotificationComponent, LanguageChooserComponent, MenuComponent } from './menu'
 import { SharedModule } from './shared'
@@ -42,6 +42,8 @@ export function metaFactory (serverService: ServerService): MetaLoader {
     AvatarNotificationComponent,
     HeaderComponent,
     SearchTypeaheadComponent,
+    SuggestionsComponent,
+    SuggestionComponent,
 
     WelcomeModalComponent,
     InstanceConfigWarningModalComponent
index 38c87c642e65838a58a804a8937ef305df8c0b66..074bebf21585e782157139f0f0c86c6a33972011 100644 (file)
@@ -1,6 +1,6 @@
 <my-search-typeahead>
   <input
-    type="text" id="search-video" name="search-video" [attr.aria-label]="ariaLabelTextForSearch" i18n-placeholder placeholder="Search videos, channels…"
+    type="text" id="search-video" name="search-video" [attr.aria-label]="ariaLabelTextForSearch" i18n-placeholder placeholder="Search locally videos, channels…"
     [(ngModel)]="searchValue" (keyup.enter)="doSearch()"
   >
   <span (click)="doSearch()" class="icon icon-search"></span>
index 92a7eded651eea650993ca6243ec7930cf433f34..ca4a32cbc7732ca5fe1107472445d2891a26b60a 100644 (file)
@@ -2,7 +2,7 @@ import { filter, first, map, tap } from 'rxjs/operators'
 import { Component, OnInit } from '@angular/core'
 import { ActivatedRoute, NavigationEnd, Params, Router } from '@angular/router'
 import { getParameterByName } from '../shared/misc/utils'
-import { AuthService, Notifier, ServerService } from '@app/core'
+import { AuthService } from '@app/core'
 import { of } from 'rxjs'
 import { I18n } from '@ngx-translate/i18n-polyfill'
 
@@ -20,9 +20,6 @@ export class HeaderComponent implements OnInit {
     private router: Router,
     private route: ActivatedRoute,
     private auth: AuthService,
-    private serverService: ServerService,
-    private authService: AuthService,
-    private notifier: Notifier,
     private i18n: I18n
   ) {}
 
index bf1787103cc1929491c330b2dd69cf829f3153e1..a882d4d1faaf82877afdaa1facc0ff1cf17f7d2e 100644 (file)
@@ -1,2 +1,4 @@
 export * from './header.component'
 export * from './search-typeahead.component'
+export * from './suggestions.component'
+export * from './suggestion.component'
index fe3f6ff4d5bb35d2886ec5bd400f337b4e6ae0a1..2623ba33734968e80252ce6897c5703a8fae4121 100644 (file)
@@ -3,17 +3,27 @@
 
   <div class="position-absolute jump-to-suggestions">
     <!-- suggestions -->
-    <ul id="jump-to-results" role="listbox" class="p-0 m-0" #optionsList>
-      <li *ngFor="let res of results" class="d-flex flex-justify-start flex-items-center p-0 f5" role="option" aria-selected="true">
-        <ng-container *ngTemplateOutlet="result; context: {$implicit: res}"></ng-container>
-      </li>
-    </ul>
+    <my-suggestions *ngIf="hasSearch && newSearch" [results]="results" [highlight]="searchInput.value" (init)="initKeyboardEventsManager($event)" #suggestions></my-suggestions>
+
+    <!-- suggestion help, not shown until one of the suggestions is selected and specific to that suggestion -->
+    <div *ngIf="showHelp" id="typeahead-help" class="overflow-hidden">
+      <ng-container *ngIf="activeResult.type === 'search-global'">
+        <div class="d-flex justify-content-between">
+          <label class="small-title" i18n>Global search</label>
+          <div class="advanced-search-status text-muted">
+            <span class="mr-1" i18n>using {{ globalSearchIndex }}</span>
+            <i class="glyphicon glyphicon-globe"></i>
+          </div>
+        </div>
+        <div class="text-muted" i18n>Results will be augmented with those of a third-party index. Only data necessary to make the query will be sent.</div>
+      </ng-container>
+    </div>
 
     <!-- search instructions, when search input is empty -->
     <div *ngIf="!hasSearch" id="typeahead-instructions" class="overflow-hidden">
       <div class="d-flex justify-content-between">
         <label class="small-title" i18n>Advanced search</label>
-        <div class="advanced-search-status">
+        <div class="advanced-search-status c-help">
           <span [ngClass]="URIPolicy === 'any' ? 'text-success' : 'text-muted'" [title]="URIPolicyText">
             <span class="mr-1" i18n>{URIPolicy, select, only-followed {only followed instances} other {any instance}} </span>
             <i [ngClass]="URIPolicy === 'any' ? 'glyphicon glyphicon-ok-sign' : 'glyphicon glyphicon-exclamation-sign'"></i>
   </div>
 
 </div>
-
-<ng-template #result let-result>
-  <a tabindex="0" class="d-flex flex-auto flex-items-center p-2"
-    data-target-type="Repository"
-    [routerLink]="result.routerLink"
-  >
-    <div class="flex-shrink-0 mr-2 text-center">
-      <my-global-icon *ngIf="result.type !== 'channel'" iconName="search"></my-global-icon>
-      <my-global-icon *ngIf="result.type === 'channel'" iconName="folder"></my-global-icon>
-    </div>
-
-    <img class="avatar mr-2 flex-shrink-0 d-none" alt="" aria-label="Team" src="" width="28" height="28">
-
-    <div class="flex-auto overflow-hidden text-left no-wrap css-truncate css-truncate-target" [attr.aria-label]="result.text" [innerHTML]="result.text | highlight : searchInput.value"></div>
-
-    <div *ngIf="result.type !== 'channel' && result.type !== 'suggestion'" class="border rounded flex-shrink-0 px-1 bg-gray text-gray-light ml-1 f6">
-      <span *ngIf="result.type === 'search-channel'" [attr.aria-label]="inThisChannelText">
-        {{ inThisChannelText }}
-      </span>
-      <span *ngIf="result.type === 'search-global'" [attr.aria-label]="inAllText">
-        {{ inAllText }}
-      </span>
-      <span *ngIf="result.type === 'search-any'" aria-hidden="true" class="d-inline-block ml-1 v-align-middle">↵</span>
-    </div>
-
-    <div *ngIf="result.type === 'channel'" aria-hidden="true" class="border rounded flex-shrink-0 px-1 bg-gray text-gray-light ml-1 f6 d-on-nav-focus" i18n>
-      Jump to channel
-      <span class="d-inline-block ml-1 v-align-middle">↵</span>
-    </div>
-  </a>
-</ng-template>
\ No newline at end of file
index 93f021e332dd09c1184e0c02f2c745d868230d8b..c410d47345b3e35ce43d50a12a703863e9f8a372 100644 (file)
@@ -7,8 +7,9 @@
   width: 100%;
 }
 
+#typeahead-help,
 #typeahead-instructions,
-#jump-to-results {
+my-suggestions ::ng-deep ul {
   border: 1px solid var(--mainBackgroundColor);
   border-bottom-right-radius: 3px;
   border-bottom-left-radius: 3px;
   transition-property: box-shadow;
 }
 
+#typeahead-help,
 #typeahead-instructions {
   margin-top: 10px;
   width: 100%;
   padding: .5rem 1rem;
+  white-space: normal;
 
   ul {
     list-style: none;
@@ -58,8 +61,9 @@
     & > div:last-child {
       display: initial !important;
       
+      #typeahead-help,
       #typeahead-instructions,
-      #jump-to-results {
+      my-suggestions ::ng-deep ul {
         box-shadow: rgba(0, 0, 0, 0.2) 0px 10px 20px -5px;
       }
     }
   }
 }
 
-a.focus-visible {
-  background-color: var(--mainHoverColor);
-}
-
-a {
-  @include disable-default-a-behaviour;
-  width: 100%;
-
-  &, &:hover {
-    color: var(--mainForegroundColor);
-  }
-}
-
-.bg-gray {
-  background-color: var(--mainBackgroundColor);
-}
-
-.text-gray-light {
-  color: var(--mainForegroundColor);
-}
-
 .glyphicon {
   top: 3px;
 }
 
 .advanced-search-status {
-  cursor: help;
+  height: max-content;
+  cursor: default;
+
+  &.c-help {
+    cursor: help;
+  }
 }
 
 .small-title {
@@ -111,11 +99,6 @@ a {
   margin-bottom: .5rem;
 }
 
-my-global-icon {
-  width: 17px;
-  position: relative;
-  top: -2px;
-  margin: 5px;
-
-  @include apply-svg-color(var(--mainForegroundColor))
+::ng-deep my-suggestion {
+  width: 100%;
 }
index d12a9682e0dcb11aa276c5a87dd09a8b7a5a4bd6..084bdd58be24a3b25b72a9f9ce06eb790a48c1ce 100644 (file)
@@ -1,23 +1,31 @@
-import { Component, ViewChild, ElementRef, AfterViewInit, OnInit } from '@angular/core'
+import {
+  Component,
+  ViewChild,
+  ElementRef,
+  AfterViewInit,
+  OnInit,
+  OnDestroy,
+  QueryList
+} from '@angular/core'
 import { Router, NavigationEnd } from '@angular/router'
 import { AuthService } from '@app/core'
 import { I18n } from '@ngx-translate/i18n-polyfill'
 import { filter } from 'rxjs/operators'
-import { ListKeyManager, ListKeyManagerOption } from '@angular/cdk/a11y'
-import { UP_ARROW, DOWN_ARROW, ENTER } from '@angular/cdk/keycodes'
+import { ListKeyManager } from '@angular/cdk/a11y'
+import { UP_ARROW, DOWN_ARROW, ENTER, TAB } from '@angular/cdk/keycodes'
+import { SuggestionComponent } from './suggestion.component'
 
 @Component({
   selector: 'my-search-typeahead',
   templateUrl: './search-typeahead.component.html',
   styleUrls: [ './search-typeahead.component.scss' ]
 })
-export class SearchTypeaheadComponent implements OnInit, AfterViewInit {
+export class SearchTypeaheadComponent implements OnInit, OnDestroy, AfterViewInit {
   @ViewChild('contentWrapper', { static: true }) contentWrapper: ElementRef
-  @ViewChild('optionsList', { static: true }) optionsList: ElementRef
 
   hasChannel = false
   inChannel = false
-  keyboardEventsManager: ListKeyManager<ListKeyManagerOption>
+  newSearch = true
 
   searchInput: HTMLInputElement
   URIPolicy: 'only-followed' | 'any' = 'any'
@@ -25,7 +33,9 @@ export class SearchTypeaheadComponent implements OnInit, AfterViewInit {
   URIPolicyText: string
   inAllText: string
   inThisChannelText: string
+  globalSearchIndex = 'https://index.joinpeertube.org'
 
+  keyboardEventsManager: ListKeyManager<SuggestionComponent>
   results: any[] = []
 
   constructor (
@@ -33,7 +43,7 @@ export class SearchTypeaheadComponent implements OnInit, AfterViewInit {
     private router: Router,
     private i18n: I18n
   ) {
-    this.URIPolicyText = this.i18n('Determines whether you can resolve any distant content from its URL, or if your instance only allows doing so for instances it follows.')
+    this.URIPolicyText = this.i18n('Determines whether you can resolve any distant content, or if your instance only allows doing so for instances it follows.')
     this.inAllText = this.i18n('In all PeerTube')
     this.inThisChannelText = this.i18n('In this channel')
   }
@@ -48,16 +58,30 @@ export class SearchTypeaheadComponent implements OnInit, AfterViewInit {
       })
   }
 
+  ngOnDestroy () {
+    if (this.keyboardEventsManager) this.keyboardEventsManager.change.unsubscribe()
+  }
+
   ngAfterViewInit () {
     this.searchInput = this.contentWrapper.nativeElement.childNodes[0]
     this.searchInput.addEventListener('input', this.computeResults.bind(this))
+    this.searchInput.addEventListener('keyup', this.handleKeyUp.bind(this))
   }
 
   get hasSearch () {
     return !!this.searchInput && !!this.searchInput.value
   }
 
+  get activeResult () {
+    return this.keyboardEventsManager && this.keyboardEventsManager.activeItem && this.keyboardEventsManager.activeItem.result
+  }
+
+  get showHelp () {
+    return this.hasSearch && this.newSearch && this.activeResult && this.activeResult.type === 'search-global' || false
+  }
+
   computeResults () {
+    this.newSearch = true
     let results = [
       {
         text: 'Maître poney',
@@ -71,6 +95,10 @@ export class SearchTypeaheadComponent implements OnInit, AfterViewInit {
           text: this.searchInput.value,
           type: 'search-channel'
         },
+        {
+          text: this.searchInput.value,
+          type: 'search-instance'
+        },
         {
           text: this.searchInput.value,
           type: 'search-global'
@@ -90,20 +118,38 @@ export class SearchTypeaheadComponent implements OnInit, AfterViewInit {
     )
   }
 
+  initKeyboardEventsManager (event: { items: QueryList<SuggestionComponent>, index?: number }) {
+    if (this.keyboardEventsManager) this.keyboardEventsManager.change.unsubscribe()
+    this.keyboardEventsManager = new ListKeyManager(event.items)
+    if (event.index !== undefined) {
+      this.keyboardEventsManager.setActiveItem(event.index)
+      event.items.forEach(e => e.active = false)
+      this.keyboardEventsManager.activeItem.active = true
+    }
+    this.keyboardEventsManager.change.subscribe(
+      val => {
+        event.items.forEach(e => e.active = false)
+        this.keyboardEventsManager.activeItem.active = true
+      }
+    )
+  }
+
   isUserLoggedIn () {
     return this.authService.isLoggedIn()
   }
 
-  handleKeyUp (event: KeyboardEvent) {
+  handleKeyUp (event: KeyboardEvent, indexSelected?: number) {
     event.stopImmediatePropagation()
     if (this.keyboardEventsManager) {
-      if (event.keyCode === DOWN_ARROW || event.keyCode === UP_ARROW) {
-        // passing the event to key manager so we get a change fired
+      if (event.keyCode === TAB) {
+        this.keyboardEventsManager.setNextItemActive()
+        return false
+      } else if (event.keyCode === DOWN_ARROW || event.keyCode === UP_ARROW) {
         this.keyboardEventsManager.onKeydown(event)
         return false
       } else if (event.keyCode === ENTER) {
-        // when we hit enter, the keyboardManager should call the selectItem method of the `ListItemComponent`
-        // this.keyboardEventsManager.activeItem
+        this.newSearch = false
+        // this.router.navigate(this.keyboardEventsManager.activeItem.result)
         return false
       }
     }
diff --git a/client/src/app/header/suggestion.component.html b/client/src/app/header/suggestion.component.html
new file mode 100644 (file)
index 0000000..894cacb
--- /dev/null
@@ -0,0 +1,28 @@
+<a tabindex="-1" class="d-flex flex-auto flex-items-center p-2" [class.focus-visible]="active" [routerLink]="result.routerLink">
+  <div class="flex-shrink-0 mr-2 text-center">
+    <my-global-icon *ngIf="result.type !== 'channel'" iconName="search"></my-global-icon>
+    <my-global-icon *ngIf="result.type === 'channel'" iconName="folder"></my-global-icon>
+  </div>
+
+  <img class="avatar mr-2 flex-shrink-0 d-none" alt="" aria-label="Team" src="" width="28" height="28">
+
+  <div class="flex-auto overflow-hidden text-left no-wrap css-truncate css-truncate-target" [attr.aria-label]="result.text" [innerHTML]="result.text | highlight : highlight"></div>
+
+  <div *ngIf="result.type !== 'channel' && result.type !== 'suggestion'" class="border rounded flex-shrink-0 px-1 bg-gray text-gray-light ml-1 f6">
+    <span *ngIf="result.type === 'search-channel'" [attr.aria-label]="inThisChannelText">
+      {{ inThisChannelText }}
+    </span>
+    <span *ngIf="result.type === 'search-instance'" [attr.aria-label]="inThisInstanceText">
+      {{ inThisInstanceText }}
+    </span>
+    <span *ngIf="result.type === 'search-global'" [attr.aria-label]="inAllText">
+      {{ inAllText }}
+    </span>
+    <span *ngIf="result.type === 'search-any'" aria-hidden="true" class="d-inline-block ml-1 v-align-middle">↵</span>
+  </div>
+
+  <div *ngIf="result.type === 'channel'" aria-hidden="true" class="border rounded flex-shrink-0 px-1 bg-gray text-gray-light ml-1 f6 d-on-nav-focus" i18n>
+    Jump to channel
+    <span class="d-inline-block ml-1 v-align-middle">↵</span>
+  </div>
+</a>
\ No newline at end of file
diff --git a/client/src/app/header/suggestion.component.scss b/client/src/app/header/suggestion.component.scss
new file mode 100644 (file)
index 0000000..1de2f43
--- /dev/null
@@ -0,0 +1,32 @@
+@import '_mixins';
+
+a {
+  @include disable-default-a-behaviour;
+  width: 100%;
+
+  &, &:hover {
+    color: var(--mainForegroundColor);
+
+    &.focus-visible {
+      background-color: var(--mainHoverColor);
+      color: var(--mainBackgroundColor);
+    }
+  }
+}
+
+.bg-gray {
+  background-color: var(--mainBackgroundColor);
+}
+
+.text-gray-light {
+  color: var(--mainForegroundColor);
+}
+
+my-global-icon {
+  width: 17px;
+  position: relative;
+  top: -2px;
+  margin: 5px;
+
+  @include apply-svg-color(var(--mainForegroundColor));
+}
diff --git a/client/src/app/header/suggestion.component.ts b/client/src/app/header/suggestion.component.ts
new file mode 100644 (file)
index 0000000..75c44a5
--- /dev/null
@@ -0,0 +1,48 @@
+import { Input, Component, Output, EventEmitter, OnInit } from '@angular/core'
+import { RouterLink } from '@angular/router'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { ListKeyManagerOption } from '@angular/cdk/a11y'
+
+type Result = {
+  text: string
+  type: 'channel' | 'suggestion' | 'search-channel' | 'search-instance' | 'search-global' | 'search-any'
+  routerLink?: RouterLink
+}
+
+@Component({
+  selector: 'my-suggestion',
+  templateUrl: './suggestion.component.html',
+  styleUrls: [ './suggestion.component.scss' ]
+})
+export class SuggestionComponent implements OnInit, ListKeyManagerOption {
+  @Input() result: Result
+  @Input() highlight: string
+  @Output() selected = new EventEmitter()
+
+  inAllText: string
+  inThisChannelText: string
+  inThisInstanceText: string
+
+  disabled = false
+  active = false
+
+  constructor (
+    private i18n: I18n
+  ) {
+    this.inAllText = this.i18n('In the vidiverse')
+    this.inThisChannelText = this.i18n('In this channel')
+    this.inThisInstanceText = this.i18n('In this instance')
+  }
+
+  getLabel () {
+    return this.result.text
+  }
+
+  ngOnInit () {
+    this.active = false
+  }
+
+  selectItem () {
+    this.selected.emit(this.result)
+  }
+}
diff --git a/client/src/app/header/suggestions.component.ts b/client/src/app/header/suggestions.component.ts
new file mode 100644 (file)
index 0000000..122c093
--- /dev/null
@@ -0,0 +1,31 @@
+import { Input, QueryList, Component, Output, AfterViewInit, EventEmitter, ViewChildren } from '@angular/core'
+import { SuggestionComponent } from './suggestion.component'
+
+@Component({
+  selector: 'my-suggestions',
+  template: `
+    <ul role="listbox" class="p-0 m-0">
+      <li *ngFor="let result of results; let i = index" class="d-flex flex-justify-start flex-items-center p-0 f5"
+          role="option" aria-selected="true" (mouseenter)="hoverItem(i)">
+        <my-suggestion [result]="result" [highlight]="highlight"></my-suggestion>
+      </li>
+    </ul>
+  `
+})
+export class SuggestionsComponent implements AfterViewInit {
+  @Input() results: any[]
+  @Input() highlight: string
+  @ViewChildren(SuggestionComponent) listItems: QueryList<SuggestionComponent>
+  @Output() init = new EventEmitter()
+
+  ngAfterViewInit () {
+    this.init.emit({ items: this.listItems })
+    this.listItems.changes.subscribe(
+      val => this.init.emit({ items: this.listItems })
+    )
+  }
+
+  hoverItem (index: number) {
+    this.init.emit({ items: this.listItems, index: index })
+  }
+}