Search typeahead initial design
authorRigel Kent <sendmemail@rigelk.eu>
Mon, 20 Jan 2020 14:12:51 +0000 (15:12 +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.scss
client/src/app/header/index.ts
client/src/app/header/search-typeahead.component.html [new file with mode: 0644]
client/src/app/header/search-typeahead.component.scss [new file with mode: 0644]
client/src/app/header/search-typeahead.component.ts [new file with mode: 0644]
client/src/app/shared/angular/highlight.pipe.ts [new file with mode: 0644]
client/src/app/shared/shared.module.ts
client/src/sass/bootstrap.scss
client/src/sass/include/_mixins.scss

index d11dba34d3febf4150b5bca3e1af1f3116835949..2db33d6382d069b89b0942dff8cb7409fe6c8a31 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 } from './header'
+import { HeaderComponent, SearchTypeaheadComponent } from './header'
 import { LoginModule } from './login'
 import { AvatarNotificationComponent, LanguageChooserComponent, MenuComponent } from './menu'
 import { SharedModule } from './shared'
@@ -41,6 +41,7 @@ export function metaFactory (serverService: ServerService): MetaLoader {
     LanguageChooserComponent,
     AvatarNotificationComponent,
     HeaderComponent,
+    SearchTypeaheadComponent,
 
     WelcomeModalComponent,
     InstanceConfigWarningModalComponent
index 4fd18f9bdf20b8d1f9beb64d9558aa59d59edb55..38c87c642e65838a58a804a8937ef305df8c0b66 100644 (file)
@@ -1,8 +1,10 @@
-<input
-  type="text" id="search-video" name="search-video" [attr.aria-label]="ariaLabelTextForSearch" i18n-placeholder placeholder="Search videos, channels…"
-  [(ngModel)]="searchValue" (keyup.enter)="doSearch()"
->
-<span (click)="doSearch()" class="icon icon-search"></span>
+<my-search-typeahead>
+  <input
+    type="text" id="search-video" name="search-video" [attr.aria-label]="ariaLabelTextForSearch" i18n-placeholder placeholder="Search videos, channels…"
+    [(ngModel)]="searchValue" (keyup.enter)="doSearch()"
+  >
+  <span (click)="doSearch()" class="icon icon-search"></span>
+</my-search-typeahead>
 
 <a class="upload-button" routerLink="/videos/upload">
   <my-global-icon iconName="upload"></my-global-icon>
index 2bbde74bc7d2dbe659f2e5559915726ad0b7eff4..b602cf0a884c4e02d4fcdc526153b4dffb8adb22 100644 (file)
@@ -1,31 +1,20 @@
 @import '_variables';
 @import '_mixins';
 
+my-search-typeahead {
+  margin-right: 15px;
+}
+
 #search-video {
   @include peertube-input-text($search-input-width);
   padding-left: 10px;
-  margin-right: 15px;
   padding-right: 40px; // For the search icon
   font-size: 14px;
 
-  transition: box-shadow .3s ease;
-
-  /* light border style */
-  border: 1px solid var(--mainBackgroundColor);
-  box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 20px 0px;
-
-  &:focus {
-    box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 20px 0px;
-  }
-
   &::placeholder {
     color: var(--inputPlaceholderColor);
   }
 
-  &:focus::placeholder {
-    opacity: 0 !important;
-  }
-
   @media screen and (max-width: 800px) {
     width: calc(100% - 150px);
   }
@@ -44,7 +33,7 @@
 
   // yolo
   position: absolute;
-  margin-left: -50px;
+  margin-left: -35px;
   margin-top: 5px;
 }
 
index d98d2d00a7180f10b990f748eaea89f5082829a9..bf1787103cc1929491c330b2dd69cf829f3153e1 100644 (file)
@@ -1 +1,2 @@
 export * from './header.component'
+export * from './search-typeahead.component'
diff --git a/client/src/app/header/search-typeahead.component.html b/client/src/app/header/search-typeahead.component.html
new file mode 100644 (file)
index 0000000..fe3f6ff
--- /dev/null
@@ -0,0 +1,69 @@
+<div class="d-inline-flex position-relative" id="typeahead-container" #contentWrapper>
+  <ng-content></ng-content>
+
+  <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>
+
+    <!-- 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">
+          <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>
+          </span>
+        </div>
+      </div>
+      <ul>
+        <li>
+          <em>@username@domain</em> <span class="flex-auto text-muted" i18n>account or channel</span>
+        </li>
+        <li>
+          <em>URL</em> <span class="text-muted" i18n>account or channel</span>
+        </li>
+        <li>
+          <em>URL</em> <span class="text-muted" i18n>video</span>
+        </li>
+      </ul>
+      <span class="text-muted" i18n>Any other text will return matching video, channel or account names.</span>
+    </div>
+  </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
diff --git a/client/src/app/header/search-typeahead.component.scss b/client/src/app/header/search-typeahead.component.scss
new file mode 100644 (file)
index 0000000..93f021e
--- /dev/null
@@ -0,0 +1,121 @@
+@import '_mixins';
+
+.jump-to-suggestions {
+  top: 100%;
+  left: 0;
+  z-index: 35;
+  width: 100%;
+}
+
+#typeahead-instructions,
+#jump-to-results {
+  border: 1px solid var(--mainBackgroundColor);
+  border-bottom-right-radius: 3px;
+  border-bottom-left-radius: 3px;
+  background: var(--mainBackgroundColor);
+  transition: .3s ease;
+  transition-property: box-shadow;
+}
+
+#typeahead-instructions {
+  margin-top: 10px;
+  width: 100%;
+  padding: .5rem 1rem;
+
+  ul {
+    list-style: none;
+    padding: 0;
+    margin-bottom: .5rem;
+
+    em {
+      font-weight: 600;
+      margin-right: 0.2rem;
+      font-style: normal;
+    }
+  }
+}
+
+#typeahead-container {
+  ::ng-deep input {
+    border: 1px solid var(--mainBackgroundColor) !important;
+    box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 20px 0px;
+    flex-grow: 1;
+    transition: box-shadow .3s ease, width .2s ease;
+  }
+
+  ::ng-deep span {
+    right: 10px;
+  }
+
+  & > div:last-child {
+    // we have to switch the display and not the opacity, 
+    // to avoid clashing with the rest of the interface.
+    display: none;
+  }
+
+  &:focus,
+  ::ng-deep &:focus-within {
+    & > div:last-child {
+      display: initial !important;
+      
+      #typeahead-instructions,
+      #jump-to-results {
+        box-shadow: rgba(0, 0, 0, 0.2) 0px 10px 20px -5px;
+      }
+    }
+
+    ::ng-deep input {
+      box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 20px 0px;
+      border-end-start-radius: 0;
+      border-end-end-radius: 0;
+
+      @media screen and (min-width: 900px) {
+        width: 500px;
+      }
+    }
+  }
+}
+
+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;
+}
+
+.small-title {
+  @include in-content-small-title;
+
+  margin-bottom: .5rem;
+}
+
+my-global-icon {
+  width: 17px;
+  position: relative;
+  top: -2px;
+  margin: 5px;
+
+  @include apply-svg-color(var(--mainForegroundColor))
+}
diff --git a/client/src/app/header/search-typeahead.component.ts b/client/src/app/header/search-typeahead.component.ts
new file mode 100644 (file)
index 0000000..d12a968
--- /dev/null
@@ -0,0 +1,111 @@
+import { Component, ViewChild, ElementRef, AfterViewInit, OnInit } 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'
+
+@Component({
+  selector: 'my-search-typeahead',
+  templateUrl: './search-typeahead.component.html',
+  styleUrls: [ './search-typeahead.component.scss' ]
+})
+export class SearchTypeaheadComponent implements OnInit, AfterViewInit {
+  @ViewChild('contentWrapper', { static: true }) contentWrapper: ElementRef
+  @ViewChild('optionsList', { static: true }) optionsList: ElementRef
+
+  hasChannel = false
+  inChannel = false
+  keyboardEventsManager: ListKeyManager<ListKeyManagerOption>
+
+  searchInput: HTMLInputElement
+  URIPolicy: 'only-followed' | 'any' = 'any'
+
+  URIPolicyText: string
+  inAllText: string
+  inThisChannelText: string
+
+  results: any[] = []
+
+  constructor (
+    private authService: AuthService,
+    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.inAllText = this.i18n('In all PeerTube')
+    this.inThisChannelText = this.i18n('In this channel')
+  }
+
+  ngOnInit () {
+    this.router.events
+      .pipe(filter(event => event instanceof NavigationEnd))
+      .subscribe((event: NavigationEnd) => {
+        this.hasChannel = event.url.startsWith('/videos/watch')
+        this.inChannel = event.url.startsWith('/video-channels')
+        this.computeResults()
+      })
+  }
+
+  ngAfterViewInit () {
+    this.searchInput = this.contentWrapper.nativeElement.childNodes[0]
+    this.searchInput.addEventListener('input', this.computeResults.bind(this))
+  }
+
+  get hasSearch () {
+    return !!this.searchInput && !!this.searchInput.value
+  }
+
+  computeResults () {
+    let results = [
+      {
+        text: 'Maître poney',
+        type: 'channel'
+      }
+    ]
+
+    if (this.hasSearch) {
+      results = [
+        {
+          text: this.searchInput.value,
+          type: 'search-channel'
+        },
+        {
+          text: this.searchInput.value,
+          type: 'search-global'
+        },
+        ...results
+      ]
+    }
+
+    this.results = results.filter(
+      result => {
+        // if we're not in a channel or one of its videos/playlits, show all channel-related results
+        if (!(this.hasChannel || this.inChannel)) return !result.type.includes('channel')
+        // if we're in a channel, show all channel-related results except for the channel redirection itself
+        if (this.inChannel) return !(result.type === 'channel')
+        return true
+      }
+    )
+  }
+
+  isUserLoggedIn () {
+    return this.authService.isLoggedIn()
+  }
+
+  handleKeyUp (event: KeyboardEvent) {
+    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
+        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
+        return false
+      }
+    }
+  }
+}
diff --git a/client/src/app/shared/angular/highlight.pipe.ts b/client/src/app/shared/angular/highlight.pipe.ts
new file mode 100644 (file)
index 0000000..4199d83
--- /dev/null
@@ -0,0 +1,52 @@
+import { PipeTransform, Pipe } from '@angular/core'
+import { SafeHtml } from '@angular/platform-browser'
+
+// Thanks https://gist.github.com/adamrecsko/0f28f474eca63e0279455476cc11eca7#gistcomment-2917369
+@Pipe({ name: 'highlight' })
+export class HighlightPipe implements PipeTransform {
+  /* use this for single match search */
+  static SINGLE_MATCH: string = "Single-Match"
+  /* use this for single match search with a restriction that target should start with search string */
+  static SINGLE_AND_STARTS_WITH_MATCH: string = "Single-And-StartsWith-Match"
+  /* use this for global search */
+  static MULTI_MATCH: string = "Multi-Match"
+
+  constructor() {}
+  transform(
+      contentString: string = null,
+      stringToHighlight: string = null,
+      option: string = "Single-And-StartsWith-Match",
+      caseSensitive: boolean = false,
+      highlightStyleName: string = "search-highlight"
+  ): SafeHtml {
+      if (stringToHighlight && contentString && option) {
+          let regex: any = ""
+          let caseFlag: string = !caseSensitive ? "i" : ""
+          switch (option) {
+              case "Single-Match": {
+                  regex = new RegExp(stringToHighlight, caseFlag)
+                  break
+              }
+              case "Single-And-StartsWith-Match": {
+                  regex = new RegExp("^" + stringToHighlight, caseFlag)
+                  break
+              }
+              case "Multi-Match": {
+                  regex = new RegExp(stringToHighlight, "g" + caseFlag)
+                  break
+              }
+              default: {
+                  // default will be a global case-insensitive match
+                  regex = new RegExp(stringToHighlight, "gi")
+              }
+          }
+          const replaced = contentString.replace(
+              regex,
+              (match) => `<span class="${highlightStyleName}">${match}</span>`
+          )
+          return replaced
+      } else {
+          return contentString
+      }
+  }
+}
index 98211c7279891434d13539a1a57db65042696346..090a5b7f99e88fa33162366c49d12fd8f82a2fbe 100644 (file)
@@ -89,6 +89,7 @@ import { NumberFormatterPipe } from '@app/shared/angular/number-formatter.pipe'
 import { VideoDurationPipe } from '@app/shared/angular/video-duration-formatter.pipe'
 import { ObjectLengthPipe } from '@app/shared/angular/object-length.pipe'
 import { FromNowPipe } from '@app/shared/angular/from-now.pipe'
+import { HighlightPipe }from '@app/shared/angular/highlight.pipe'
 import { PeerTubeTemplateDirective } from '@app/shared/angular/peertube-template.directive'
 import { VideoActionsDropdownComponent } from '@app/shared/video/video-actions-dropdown.component'
 import { VideoBlacklistComponent } from '@app/shared/video/modals/video-blacklist.component'
@@ -149,6 +150,7 @@ import { ClipboardModule } from '@angular/cdk/clipboard'
     NumberFormatterPipe,
     ObjectLengthPipe,
     FromNowPipe,
+    HighlightPipe,
     PeerTubeTemplateDirective,
     VideoDurationPipe,
 
@@ -254,6 +256,7 @@ import { ClipboardModule } from '@angular/cdk/clipboard'
     NumberFormatterPipe,
     ObjectLengthPipe,
     FromNowPipe,
+    HighlightPipe,
     PeerTubeTemplateDirective,
     VideoDurationPipe
   ],
index a17d9c048ece061f83898c463850ae94fd9c8266..be5879b506fda7242c0c656eb6cb3bc13fe9d009 100644 (file)
@@ -9,6 +9,10 @@ $icon-font-path: '~@neos21/bootstrap3-glyphicons/assets/fonts/';
   animation: spin .7s infinite linear;
 }
 
+.flex-auto {
+  flex: auto;
+}
+
 @keyframes spin {
   from {
     transform: scale(1) rotate(0deg);
index 317781e0e9755ad077db1f27d205a1f1a6e3cd1a..ed2cacdd1803ef5ca7ecd5d10ce3d60589816093 100644 (file)
   &:focus:not(.focus-visible) {
     outline: none;
   }
-
-  &::-moz-focus-inner {
-    border: 0;
-    padding: 0
-  }
 }