From f409f0c3b91d85c66b4841d72ea65b5fbe1483d8 Mon Sep 17 00:00:00 2001 From: Rigel Kent Date: Mon, 20 Jan 2020 15:12:51 +0100 Subject: Search typeahead initial design --- client/src/app/app.module.ts | 3 +- client/src/app/header/header.component.html | 12 +- client/src/app/header/header.component.scss | 21 +--- client/src/app/header/index.ts | 1 + .../src/app/header/search-typeahead.component.html | 69 ++++++++++++ .../src/app/header/search-typeahead.component.scss | 121 +++++++++++++++++++++ .../src/app/header/search-typeahead.component.ts | 111 +++++++++++++++++++ client/src/app/shared/angular/highlight.pipe.ts | 52 +++++++++ client/src/app/shared/shared.module.ts | 3 + client/src/sass/bootstrap.scss | 4 + client/src/sass/include/_mixins.scss | 5 - 11 files changed, 375 insertions(+), 27 deletions(-) create mode 100644 client/src/app/header/search-typeahead.component.html create mode 100644 client/src/app/header/search-typeahead.component.scss create mode 100644 client/src/app/header/search-typeahead.component.ts create mode 100644 client/src/app/shared/angular/highlight.pipe.ts (limited to 'client/src') diff --git a/client/src/app/app.module.ts b/client/src/app/app.module.ts index d11dba34d..2db33d638 100644 --- a/client/src/app/app.module.ts +++ b/client/src/app/app.module.ts @@ -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 diff --git a/client/src/app/header/header.component.html b/client/src/app/header/header.component.html index 4fd18f9bd..38c87c642 100644 --- a/client/src/app/header/header.component.html +++ b/client/src/app/header/header.component.html @@ -1,8 +1,10 @@ - - + + + + diff --git a/client/src/app/header/header.component.scss b/client/src/app/header/header.component.scss index 2bbde74bc..b602cf0a8 100644 --- a/client/src/app/header/header.component.scss +++ b/client/src/app/header/header.component.scss @@ -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; } diff --git a/client/src/app/header/index.ts b/client/src/app/header/index.ts index d98d2d00a..bf1787103 100644 --- a/client/src/app/header/index.ts +++ b/client/src/app/header/index.ts @@ -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 index 000000000..fe3f6ff4d --- /dev/null +++ b/client/src/app/header/search-typeahead.component.html @@ -0,0 +1,69 @@ +
+ + +
+ +
    +
  • + +
  • +
+ + +
+
+ +
+ + {URIPolicy, select, only-followed {only followed instances} other {any instance}} + + +
+
+
    +
  • + @username@domain account or channel +
  • +
  • + URL account or channel +
  • +
  • + URL video +
  • +
+ Any other text will return matching video, channel or account names. +
+
+ +
+ + +
+
+ + +
+ + + +
+ +
+ + {{ inThisChannelText }} + + + {{ inAllText }} + + +
+ + +
+ \ 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 index 000000000..93f021e33 --- /dev/null +++ b/client/src/app/header/search-typeahead.component.scss @@ -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 index 000000000..d12a9682e --- /dev/null +++ b/client/src/app/header/search-typeahead.component.ts @@ -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 + + 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 index 000000000..4199d833e --- /dev/null +++ b/client/src/app/shared/angular/highlight.pipe.ts @@ -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) => `${match}` + ) + return replaced + } else { + return contentString + } + } +} diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index 98211c727..090a5b7f9 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -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 ], diff --git a/client/src/sass/bootstrap.scss b/client/src/sass/bootstrap.scss index a17d9c048..be5879b50 100644 --- a/client/src/sass/bootstrap.scss +++ b/client/src/sass/bootstrap.scss @@ -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); diff --git a/client/src/sass/include/_mixins.scss b/client/src/sass/include/_mixins.scss index 317781e0e..ed2cacdd1 100644 --- a/client/src/sass/include/_mixins.scss +++ b/client/src/sass/include/_mixins.scss @@ -11,11 +11,6 @@ &:focus:not(.focus-visible) { outline: none; } - - &::-moz-focus-inner { - border: 0; - padding: 0 - } } -- cgit v1.2.3