diff options
author | Rigel Kent <sendmemail@rigelk.eu> | 2020-01-25 16:32:06 +0100 |
---|---|---|
committer | Rigel Kent <sendmemail@rigelk.eu> | 2020-02-13 16:32:21 +0100 |
commit | 6af662a5961b48ac12682df2b8b971060a2cc67d (patch) | |
tree | de1efc71cbe8543b5b832e5de99a407a54c37220 /client/src/app/header | |
parent | f409f0c3b91d85c66b4841d72ea65b5fbe1483d8 (diff) | |
download | PeerTube-6af662a5961b48ac12682df2b8b971060a2cc67d.tar.gz PeerTube-6af662a5961b48ac12682df2b8b971060a2cc67d.tar.zst PeerTube-6af662a5961b48ac12682df2b8b971060a2cc67d.zip |
Add keyboard navigation and hepler to typeahead
Diffstat (limited to 'client/src/app/header')
-rw-r--r-- | client/src/app/header/header.component.html | 2 | ||||
-rw-r--r-- | client/src/app/header/header.component.ts | 5 | ||||
-rw-r--r-- | client/src/app/header/index.ts | 2 | ||||
-rw-r--r-- | client/src/app/header/search-typeahead.component.html | 53 | ||||
-rw-r--r-- | client/src/app/header/search-typeahead.component.scss | 45 | ||||
-rw-r--r-- | client/src/app/header/search-typeahead.component.ts | 70 | ||||
-rw-r--r-- | client/src/app/header/suggestion.component.html | 28 | ||||
-rw-r--r-- | client/src/app/header/suggestion.component.scss | 32 | ||||
-rw-r--r-- | client/src/app/header/suggestion.component.ts | 48 | ||||
-rw-r--r-- | client/src/app/header/suggestions.component.ts | 31 |
10 files changed, 231 insertions, 85 deletions
diff --git a/client/src/app/header/header.component.html b/client/src/app/header/header.component.html index 38c87c642..074bebf21 100644 --- a/client/src/app/header/header.component.html +++ b/client/src/app/header/header.component.html | |||
@@ -1,6 +1,6 @@ | |||
1 | <my-search-typeahead> | 1 | <my-search-typeahead> |
2 | <input | 2 | <input |
3 | type="text" id="search-video" name="search-video" [attr.aria-label]="ariaLabelTextForSearch" i18n-placeholder placeholder="Search videos, channels…" | 3 | type="text" id="search-video" name="search-video" [attr.aria-label]="ariaLabelTextForSearch" i18n-placeholder placeholder="Search locally videos, channels…" |
4 | [(ngModel)]="searchValue" (keyup.enter)="doSearch()" | 4 | [(ngModel)]="searchValue" (keyup.enter)="doSearch()" |
5 | > | 5 | > |
6 | <span (click)="doSearch()" class="icon icon-search"></span> | 6 | <span (click)="doSearch()" class="icon icon-search"></span> |
diff --git a/client/src/app/header/header.component.ts b/client/src/app/header/header.component.ts index 92a7eded6..ca4a32cbc 100644 --- a/client/src/app/header/header.component.ts +++ b/client/src/app/header/header.component.ts | |||
@@ -2,7 +2,7 @@ import { filter, first, map, tap } from 'rxjs/operators' | |||
2 | import { Component, OnInit } from '@angular/core' | 2 | import { Component, OnInit } from '@angular/core' |
3 | import { ActivatedRoute, NavigationEnd, Params, Router } from '@angular/router' | 3 | import { ActivatedRoute, NavigationEnd, Params, Router } from '@angular/router' |
4 | import { getParameterByName } from '../shared/misc/utils' | 4 | import { getParameterByName } from '../shared/misc/utils' |
5 | import { AuthService, Notifier, ServerService } from '@app/core' | 5 | import { AuthService } from '@app/core' |
6 | import { of } from 'rxjs' | 6 | import { of } from 'rxjs' |
7 | import { I18n } from '@ngx-translate/i18n-polyfill' | 7 | import { I18n } from '@ngx-translate/i18n-polyfill' |
8 | 8 | ||
@@ -20,9 +20,6 @@ export class HeaderComponent implements OnInit { | |||
20 | private router: Router, | 20 | private router: Router, |
21 | private route: ActivatedRoute, | 21 | private route: ActivatedRoute, |
22 | private auth: AuthService, | 22 | private auth: AuthService, |
23 | private serverService: ServerService, | ||
24 | private authService: AuthService, | ||
25 | private notifier: Notifier, | ||
26 | private i18n: I18n | 23 | private i18n: I18n |
27 | ) {} | 24 | ) {} |
28 | 25 | ||
diff --git a/client/src/app/header/index.ts b/client/src/app/header/index.ts index bf1787103..a882d4d1f 100644 --- a/client/src/app/header/index.ts +++ b/client/src/app/header/index.ts | |||
@@ -1,2 +1,4 @@ | |||
1 | export * from './header.component' | 1 | export * from './header.component' |
2 | export * from './search-typeahead.component' | 2 | export * from './search-typeahead.component' |
3 | export * from './suggestions.component' | ||
4 | export * from './suggestion.component' | ||
diff --git a/client/src/app/header/search-typeahead.component.html b/client/src/app/header/search-typeahead.component.html index fe3f6ff4d..2623ba337 100644 --- a/client/src/app/header/search-typeahead.component.html +++ b/client/src/app/header/search-typeahead.component.html | |||
@@ -3,17 +3,27 @@ | |||
3 | 3 | ||
4 | <div class="position-absolute jump-to-suggestions"> | 4 | <div class="position-absolute jump-to-suggestions"> |
5 | <!-- suggestions --> | 5 | <!-- suggestions --> |
6 | <ul id="jump-to-results" role="listbox" class="p-0 m-0" #optionsList> | 6 | <my-suggestions *ngIf="hasSearch && newSearch" [results]="results" [highlight]="searchInput.value" (init)="initKeyboardEventsManager($event)" #suggestions></my-suggestions> |
7 | <li *ngFor="let res of results" class="d-flex flex-justify-start flex-items-center p-0 f5" role="option" aria-selected="true"> | 7 | |
8 | <ng-container *ngTemplateOutlet="result; context: {$implicit: res}"></ng-container> | 8 | <!-- suggestion help, not shown until one of the suggestions is selected and specific to that suggestion --> |
9 | </li> | 9 | <div *ngIf="showHelp" id="typeahead-help" class="overflow-hidden"> |
10 | </ul> | 10 | <ng-container *ngIf="activeResult.type === 'search-global'"> |
11 | <div class="d-flex justify-content-between"> | ||
12 | <label class="small-title" i18n>Global search</label> | ||
13 | <div class="advanced-search-status text-muted"> | ||
14 | <span class="mr-1" i18n>using {{ globalSearchIndex }}</span> | ||
15 | <i class="glyphicon glyphicon-globe"></i> | ||
16 | </div> | ||
17 | </div> | ||
18 | <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> | ||
19 | </ng-container> | ||
20 | </div> | ||
11 | 21 | ||
12 | <!-- search instructions, when search input is empty --> | 22 | <!-- search instructions, when search input is empty --> |
13 | <div *ngIf="!hasSearch" id="typeahead-instructions" class="overflow-hidden"> | 23 | <div *ngIf="!hasSearch" id="typeahead-instructions" class="overflow-hidden"> |
14 | <div class="d-flex justify-content-between"> | 24 | <div class="d-flex justify-content-between"> |
15 | <label class="small-title" i18n>Advanced search</label> | 25 | <label class="small-title" i18n>Advanced search</label> |
16 | <div class="advanced-search-status"> | 26 | <div class="advanced-search-status c-help"> |
17 | <span [ngClass]="URIPolicy === 'any' ? 'text-success' : 'text-muted'" [title]="URIPolicyText"> | 27 | <span [ngClass]="URIPolicy === 'any' ? 'text-success' : 'text-muted'" [title]="URIPolicyText"> |
18 | <span class="mr-1" i18n>{URIPolicy, select, only-followed {only followed instances} other {any instance}} </span> | 28 | <span class="mr-1" i18n>{URIPolicy, select, only-followed {only followed instances} other {any instance}} </span> |
19 | <i [ngClass]="URIPolicy === 'any' ? 'glyphicon glyphicon-ok-sign' : 'glyphicon glyphicon-exclamation-sign'"></i> | 29 | <i [ngClass]="URIPolicy === 'any' ? 'glyphicon glyphicon-ok-sign' : 'glyphicon glyphicon-exclamation-sign'"></i> |
@@ -36,34 +46,3 @@ | |||
36 | </div> | 46 | </div> |
37 | 47 | ||
38 | </div> | 48 | </div> |
39 | |||
40 | <ng-template #result let-result> | ||
41 | <a tabindex="0" class="d-flex flex-auto flex-items-center p-2" | ||
42 | data-target-type="Repository" | ||
43 | [routerLink]="result.routerLink" | ||
44 | > | ||
45 | <div class="flex-shrink-0 mr-2 text-center"> | ||
46 | <my-global-icon *ngIf="result.type !== 'channel'" iconName="search"></my-global-icon> | ||
47 | <my-global-icon *ngIf="result.type === 'channel'" iconName="folder"></my-global-icon> | ||
48 | </div> | ||
49 | |||
50 | <img class="avatar mr-2 flex-shrink-0 d-none" alt="" aria-label="Team" src="" width="28" height="28"> | ||
51 | |||
52 | <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> | ||
53 | |||
54 | <div *ngIf="result.type !== 'channel' && result.type !== 'suggestion'" class="border rounded flex-shrink-0 px-1 bg-gray text-gray-light ml-1 f6"> | ||
55 | <span *ngIf="result.type === 'search-channel'" [attr.aria-label]="inThisChannelText"> | ||
56 | {{ inThisChannelText }} | ||
57 | </span> | ||
58 | <span *ngIf="result.type === 'search-global'" [attr.aria-label]="inAllText"> | ||
59 | {{ inAllText }} | ||
60 | </span> | ||
61 | <span *ngIf="result.type === 'search-any'" aria-hidden="true" class="d-inline-block ml-1 v-align-middle">↵</span> | ||
62 | </div> | ||
63 | |||
64 | <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> | ||
65 | Jump to channel | ||
66 | <span class="d-inline-block ml-1 v-align-middle">↵</span> | ||
67 | </div> | ||
68 | </a> | ||
69 | </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 index 93f021e33..c410d4734 100644 --- a/client/src/app/header/search-typeahead.component.scss +++ b/client/src/app/header/search-typeahead.component.scss | |||
@@ -7,8 +7,9 @@ | |||
7 | width: 100%; | 7 | width: 100%; |
8 | } | 8 | } |
9 | 9 | ||
10 | #typeahead-help, | ||
10 | #typeahead-instructions, | 11 | #typeahead-instructions, |
11 | #jump-to-results { | 12 | my-suggestions ::ng-deep ul { |
12 | border: 1px solid var(--mainBackgroundColor); | 13 | border: 1px solid var(--mainBackgroundColor); |
13 | border-bottom-right-radius: 3px; | 14 | border-bottom-right-radius: 3px; |
14 | border-bottom-left-radius: 3px; | 15 | border-bottom-left-radius: 3px; |
@@ -17,10 +18,12 @@ | |||
17 | transition-property: box-shadow; | 18 | transition-property: box-shadow; |
18 | } | 19 | } |
19 | 20 | ||
21 | #typeahead-help, | ||
20 | #typeahead-instructions { | 22 | #typeahead-instructions { |
21 | margin-top: 10px; | 23 | margin-top: 10px; |
22 | width: 100%; | 24 | width: 100%; |
23 | padding: .5rem 1rem; | 25 | padding: .5rem 1rem; |
26 | white-space: normal; | ||
24 | 27 | ||
25 | ul { | 28 | ul { |
26 | list-style: none; | 29 | list-style: none; |
@@ -58,8 +61,9 @@ | |||
58 | & > div:last-child { | 61 | & > div:last-child { |
59 | display: initial !important; | 62 | display: initial !important; |
60 | 63 | ||
64 | #typeahead-help, | ||
61 | #typeahead-instructions, | 65 | #typeahead-instructions, |
62 | #jump-to-results { | 66 | my-suggestions ::ng-deep ul { |
63 | box-shadow: rgba(0, 0, 0, 0.2) 0px 10px 20px -5px; | 67 | box-shadow: rgba(0, 0, 0, 0.2) 0px 10px 20px -5px; |
64 | } | 68 | } |
65 | } | 69 | } |
@@ -76,33 +80,17 @@ | |||
76 | } | 80 | } |
77 | } | 81 | } |
78 | 82 | ||
79 | a.focus-visible { | ||
80 | background-color: var(--mainHoverColor); | ||
81 | } | ||
82 | |||
83 | a { | ||
84 | @include disable-default-a-behaviour; | ||
85 | width: 100%; | ||
86 | |||
87 | &, &:hover { | ||
88 | color: var(--mainForegroundColor); | ||
89 | } | ||
90 | } | ||
91 | |||
92 | .bg-gray { | ||
93 | background-color: var(--mainBackgroundColor); | ||
94 | } | ||
95 | |||
96 | .text-gray-light { | ||
97 | color: var(--mainForegroundColor); | ||
98 | } | ||
99 | |||
100 | .glyphicon { | 83 | .glyphicon { |
101 | top: 3px; | 84 | top: 3px; |
102 | } | 85 | } |
103 | 86 | ||
104 | .advanced-search-status { | 87 | .advanced-search-status { |
105 | cursor: help; | 88 | height: max-content; |
89 | cursor: default; | ||
90 | |||
91 | &.c-help { | ||
92 | cursor: help; | ||
93 | } | ||
106 | } | 94 | } |
107 | 95 | ||
108 | .small-title { | 96 | .small-title { |
@@ -111,11 +99,6 @@ a { | |||
111 | margin-bottom: .5rem; | 99 | margin-bottom: .5rem; |
112 | } | 100 | } |
113 | 101 | ||
114 | my-global-icon { | 102 | ::ng-deep my-suggestion { |
115 | width: 17px; | 103 | width: 100%; |
116 | position: relative; | ||
117 | top: -2px; | ||
118 | margin: 5px; | ||
119 | |||
120 | @include apply-svg-color(var(--mainForegroundColor)) | ||
121 | } | 104 | } |
diff --git a/client/src/app/header/search-typeahead.component.ts b/client/src/app/header/search-typeahead.component.ts index d12a9682e..084bdd58b 100644 --- a/client/src/app/header/search-typeahead.component.ts +++ b/client/src/app/header/search-typeahead.component.ts | |||
@@ -1,23 +1,31 @@ | |||
1 | import { Component, ViewChild, ElementRef, AfterViewInit, OnInit } from '@angular/core' | 1 | import { |
2 | Component, | ||
3 | ViewChild, | ||
4 | ElementRef, | ||
5 | AfterViewInit, | ||
6 | OnInit, | ||
7 | OnDestroy, | ||
8 | QueryList | ||
9 | } from '@angular/core' | ||
2 | import { Router, NavigationEnd } from '@angular/router' | 10 | import { Router, NavigationEnd } from '@angular/router' |
3 | import { AuthService } from '@app/core' | 11 | import { AuthService } from '@app/core' |
4 | import { I18n } from '@ngx-translate/i18n-polyfill' | 12 | import { I18n } from '@ngx-translate/i18n-polyfill' |
5 | import { filter } from 'rxjs/operators' | 13 | import { filter } from 'rxjs/operators' |
6 | import { ListKeyManager, ListKeyManagerOption } from '@angular/cdk/a11y' | 14 | import { ListKeyManager } from '@angular/cdk/a11y' |
7 | import { UP_ARROW, DOWN_ARROW, ENTER } from '@angular/cdk/keycodes' | 15 | import { UP_ARROW, DOWN_ARROW, ENTER, TAB } from '@angular/cdk/keycodes' |
16 | import { SuggestionComponent } from './suggestion.component' | ||
8 | 17 | ||
9 | @Component({ | 18 | @Component({ |
10 | selector: 'my-search-typeahead', | 19 | selector: 'my-search-typeahead', |
11 | templateUrl: './search-typeahead.component.html', | 20 | templateUrl: './search-typeahead.component.html', |
12 | styleUrls: [ './search-typeahead.component.scss' ] | 21 | styleUrls: [ './search-typeahead.component.scss' ] |
13 | }) | 22 | }) |
14 | export class SearchTypeaheadComponent implements OnInit, AfterViewInit { | 23 | export class SearchTypeaheadComponent implements OnInit, OnDestroy, AfterViewInit { |
15 | @ViewChild('contentWrapper', { static: true }) contentWrapper: ElementRef | 24 | @ViewChild('contentWrapper', { static: true }) contentWrapper: ElementRef |
16 | @ViewChild('optionsList', { static: true }) optionsList: ElementRef | ||
17 | 25 | ||
18 | hasChannel = false | 26 | hasChannel = false |
19 | inChannel = false | 27 | inChannel = false |
20 | keyboardEventsManager: ListKeyManager<ListKeyManagerOption> | 28 | newSearch = true |
21 | 29 | ||
22 | searchInput: HTMLInputElement | 30 | searchInput: HTMLInputElement |
23 | URIPolicy: 'only-followed' | 'any' = 'any' | 31 | URIPolicy: 'only-followed' | 'any' = 'any' |
@@ -25,7 +33,9 @@ export class SearchTypeaheadComponent implements OnInit, AfterViewInit { | |||
25 | URIPolicyText: string | 33 | URIPolicyText: string |
26 | inAllText: string | 34 | inAllText: string |
27 | inThisChannelText: string | 35 | inThisChannelText: string |
36 | globalSearchIndex = 'https://index.joinpeertube.org' | ||
28 | 37 | ||
38 | keyboardEventsManager: ListKeyManager<SuggestionComponent> | ||
29 | results: any[] = [] | 39 | results: any[] = [] |
30 | 40 | ||
31 | constructor ( | 41 | constructor ( |
@@ -33,7 +43,7 @@ export class SearchTypeaheadComponent implements OnInit, AfterViewInit { | |||
33 | private router: Router, | 43 | private router: Router, |
34 | private i18n: I18n | 44 | private i18n: I18n |
35 | ) { | 45 | ) { |
36 | 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.') | 46 | this.URIPolicyText = this.i18n('Determines whether you can resolve any distant content, or if your instance only allows doing so for instances it follows.') |
37 | this.inAllText = this.i18n('In all PeerTube') | 47 | this.inAllText = this.i18n('In all PeerTube') |
38 | this.inThisChannelText = this.i18n('In this channel') | 48 | this.inThisChannelText = this.i18n('In this channel') |
39 | } | 49 | } |
@@ -48,16 +58,30 @@ export class SearchTypeaheadComponent implements OnInit, AfterViewInit { | |||
48 | }) | 58 | }) |
49 | } | 59 | } |
50 | 60 | ||
61 | ngOnDestroy () { | ||
62 | if (this.keyboardEventsManager) this.keyboardEventsManager.change.unsubscribe() | ||
63 | } | ||
64 | |||
51 | ngAfterViewInit () { | 65 | ngAfterViewInit () { |
52 | this.searchInput = this.contentWrapper.nativeElement.childNodes[0] | 66 | this.searchInput = this.contentWrapper.nativeElement.childNodes[0] |
53 | this.searchInput.addEventListener('input', this.computeResults.bind(this)) | 67 | this.searchInput.addEventListener('input', this.computeResults.bind(this)) |
68 | this.searchInput.addEventListener('keyup', this.handleKeyUp.bind(this)) | ||
54 | } | 69 | } |
55 | 70 | ||
56 | get hasSearch () { | 71 | get hasSearch () { |
57 | return !!this.searchInput && !!this.searchInput.value | 72 | return !!this.searchInput && !!this.searchInput.value |
58 | } | 73 | } |
59 | 74 | ||
75 | get activeResult () { | ||
76 | return this.keyboardEventsManager && this.keyboardEventsManager.activeItem && this.keyboardEventsManager.activeItem.result | ||
77 | } | ||
78 | |||
79 | get showHelp () { | ||
80 | return this.hasSearch && this.newSearch && this.activeResult && this.activeResult.type === 'search-global' || false | ||
81 | } | ||
82 | |||
60 | computeResults () { | 83 | computeResults () { |
84 | this.newSearch = true | ||
61 | let results = [ | 85 | let results = [ |
62 | { | 86 | { |
63 | text: 'Maître poney', | 87 | text: 'Maître poney', |
@@ -73,6 +97,10 @@ export class SearchTypeaheadComponent implements OnInit, AfterViewInit { | |||
73 | }, | 97 | }, |
74 | { | 98 | { |
75 | text: this.searchInput.value, | 99 | text: this.searchInput.value, |
100 | type: 'search-instance' | ||
101 | }, | ||
102 | { | ||
103 | text: this.searchInput.value, | ||
76 | type: 'search-global' | 104 | type: 'search-global' |
77 | }, | 105 | }, |
78 | ...results | 106 | ...results |
@@ -90,20 +118,38 @@ export class SearchTypeaheadComponent implements OnInit, AfterViewInit { | |||
90 | ) | 118 | ) |
91 | } | 119 | } |
92 | 120 | ||
121 | initKeyboardEventsManager (event: { items: QueryList<SuggestionComponent>, index?: number }) { | ||
122 | if (this.keyboardEventsManager) this.keyboardEventsManager.change.unsubscribe() | ||
123 | this.keyboardEventsManager = new ListKeyManager(event.items) | ||
124 | if (event.index !== undefined) { | ||
125 | this.keyboardEventsManager.setActiveItem(event.index) | ||
126 | event.items.forEach(e => e.active = false) | ||
127 | this.keyboardEventsManager.activeItem.active = true | ||
128 | } | ||
129 | this.keyboardEventsManager.change.subscribe( | ||
130 | val => { | ||
131 | event.items.forEach(e => e.active = false) | ||
132 | this.keyboardEventsManager.activeItem.active = true | ||
133 | } | ||
134 | ) | ||
135 | } | ||
136 | |||
93 | isUserLoggedIn () { | 137 | isUserLoggedIn () { |
94 | return this.authService.isLoggedIn() | 138 | return this.authService.isLoggedIn() |
95 | } | 139 | } |
96 | 140 | ||
97 | handleKeyUp (event: KeyboardEvent) { | 141 | handleKeyUp (event: KeyboardEvent, indexSelected?: number) { |
98 | event.stopImmediatePropagation() | 142 | event.stopImmediatePropagation() |
99 | if (this.keyboardEventsManager) { | 143 | if (this.keyboardEventsManager) { |
100 | if (event.keyCode === DOWN_ARROW || event.keyCode === UP_ARROW) { | 144 | if (event.keyCode === TAB) { |
101 | // passing the event to key manager so we get a change fired | 145 | this.keyboardEventsManager.setNextItemActive() |
146 | return false | ||
147 | } else if (event.keyCode === DOWN_ARROW || event.keyCode === UP_ARROW) { | ||
102 | this.keyboardEventsManager.onKeydown(event) | 148 | this.keyboardEventsManager.onKeydown(event) |
103 | return false | 149 | return false |
104 | } else if (event.keyCode === ENTER) { | 150 | } else if (event.keyCode === ENTER) { |
105 | // when we hit enter, the keyboardManager should call the selectItem method of the `ListItemComponent` | 151 | this.newSearch = false |
106 | // this.keyboardEventsManager.activeItem | 152 | // this.router.navigate(this.keyboardEventsManager.activeItem.result) |
107 | return false | 153 | return false |
108 | } | 154 | } |
109 | } | 155 | } |
diff --git a/client/src/app/header/suggestion.component.html b/client/src/app/header/suggestion.component.html new file mode 100644 index 000000000..894cacb95 --- /dev/null +++ b/client/src/app/header/suggestion.component.html | |||
@@ -0,0 +1,28 @@ | |||
1 | <a tabindex="-1" class="d-flex flex-auto flex-items-center p-2" [class.focus-visible]="active" [routerLink]="result.routerLink"> | ||
2 | <div class="flex-shrink-0 mr-2 text-center"> | ||
3 | <my-global-icon *ngIf="result.type !== 'channel'" iconName="search"></my-global-icon> | ||
4 | <my-global-icon *ngIf="result.type === 'channel'" iconName="folder"></my-global-icon> | ||
5 | </div> | ||
6 | |||
7 | <img class="avatar mr-2 flex-shrink-0 d-none" alt="" aria-label="Team" src="" width="28" height="28"> | ||
8 | |||
9 | <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> | ||
10 | |||
11 | <div *ngIf="result.type !== 'channel' && result.type !== 'suggestion'" class="border rounded flex-shrink-0 px-1 bg-gray text-gray-light ml-1 f6"> | ||
12 | <span *ngIf="result.type === 'search-channel'" [attr.aria-label]="inThisChannelText"> | ||
13 | {{ inThisChannelText }} | ||
14 | </span> | ||
15 | <span *ngIf="result.type === 'search-instance'" [attr.aria-label]="inThisInstanceText"> | ||
16 | {{ inThisInstanceText }} | ||
17 | </span> | ||
18 | <span *ngIf="result.type === 'search-global'" [attr.aria-label]="inAllText"> | ||
19 | {{ inAllText }} | ||
20 | </span> | ||
21 | <span *ngIf="result.type === 'search-any'" aria-hidden="true" class="d-inline-block ml-1 v-align-middle">↵</span> | ||
22 | </div> | ||
23 | |||
24 | <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> | ||
25 | Jump to channel | ||
26 | <span class="d-inline-block ml-1 v-align-middle">↵</span> | ||
27 | </div> | ||
28 | </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 index 000000000..1de2f43bd --- /dev/null +++ b/client/src/app/header/suggestion.component.scss | |||
@@ -0,0 +1,32 @@ | |||
1 | @import '_mixins'; | ||
2 | |||
3 | a { | ||
4 | @include disable-default-a-behaviour; | ||
5 | width: 100%; | ||
6 | |||
7 | &, &:hover { | ||
8 | color: var(--mainForegroundColor); | ||
9 | |||
10 | &.focus-visible { | ||
11 | background-color: var(--mainHoverColor); | ||
12 | color: var(--mainBackgroundColor); | ||
13 | } | ||
14 | } | ||
15 | } | ||
16 | |||
17 | .bg-gray { | ||
18 | background-color: var(--mainBackgroundColor); | ||
19 | } | ||
20 | |||
21 | .text-gray-light { | ||
22 | color: var(--mainForegroundColor); | ||
23 | } | ||
24 | |||
25 | my-global-icon { | ||
26 | width: 17px; | ||
27 | position: relative; | ||
28 | top: -2px; | ||
29 | margin: 5px; | ||
30 | |||
31 | @include apply-svg-color(var(--mainForegroundColor)); | ||
32 | } | ||
diff --git a/client/src/app/header/suggestion.component.ts b/client/src/app/header/suggestion.component.ts new file mode 100644 index 000000000..75c44a583 --- /dev/null +++ b/client/src/app/header/suggestion.component.ts | |||
@@ -0,0 +1,48 @@ | |||
1 | import { Input, Component, Output, EventEmitter, OnInit } from '@angular/core' | ||
2 | import { RouterLink } from '@angular/router' | ||
3 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
4 | import { ListKeyManagerOption } from '@angular/cdk/a11y' | ||
5 | |||
6 | type Result = { | ||
7 | text: string | ||
8 | type: 'channel' | 'suggestion' | 'search-channel' | 'search-instance' | 'search-global' | 'search-any' | ||
9 | routerLink?: RouterLink | ||
10 | } | ||
11 | |||
12 | @Component({ | ||
13 | selector: 'my-suggestion', | ||
14 | templateUrl: './suggestion.component.html', | ||
15 | styleUrls: [ './suggestion.component.scss' ] | ||
16 | }) | ||
17 | export class SuggestionComponent implements OnInit, ListKeyManagerOption { | ||
18 | @Input() result: Result | ||
19 | @Input() highlight: string | ||
20 | @Output() selected = new EventEmitter() | ||
21 | |||
22 | inAllText: string | ||
23 | inThisChannelText: string | ||
24 | inThisInstanceText: string | ||
25 | |||
26 | disabled = false | ||
27 | active = false | ||
28 | |||
29 | constructor ( | ||
30 | private i18n: I18n | ||
31 | ) { | ||
32 | this.inAllText = this.i18n('In the vidiverse') | ||
33 | this.inThisChannelText = this.i18n('In this channel') | ||
34 | this.inThisInstanceText = this.i18n('In this instance') | ||
35 | } | ||
36 | |||
37 | getLabel () { | ||
38 | return this.result.text | ||
39 | } | ||
40 | |||
41 | ngOnInit () { | ||
42 | this.active = false | ||
43 | } | ||
44 | |||
45 | selectItem () { | ||
46 | this.selected.emit(this.result) | ||
47 | } | ||
48 | } | ||
diff --git a/client/src/app/header/suggestions.component.ts b/client/src/app/header/suggestions.component.ts new file mode 100644 index 000000000..122c09388 --- /dev/null +++ b/client/src/app/header/suggestions.component.ts | |||
@@ -0,0 +1,31 @@ | |||
1 | import { Input, QueryList, Component, Output, AfterViewInit, EventEmitter, ViewChildren } from '@angular/core' | ||
2 | import { SuggestionComponent } from './suggestion.component' | ||
3 | |||
4 | @Component({ | ||
5 | selector: 'my-suggestions', | ||
6 | template: ` | ||
7 | <ul role="listbox" class="p-0 m-0"> | ||
8 | <li *ngFor="let result of results; let i = index" class="d-flex flex-justify-start flex-items-center p-0 f5" | ||
9 | role="option" aria-selected="true" (mouseenter)="hoverItem(i)"> | ||
10 | <my-suggestion [result]="result" [highlight]="highlight"></my-suggestion> | ||
11 | </li> | ||
12 | </ul> | ||
13 | ` | ||
14 | }) | ||
15 | export class SuggestionsComponent implements AfterViewInit { | ||
16 | @Input() results: any[] | ||
17 | @Input() highlight: string | ||
18 | @ViewChildren(SuggestionComponent) listItems: QueryList<SuggestionComponent> | ||
19 | @Output() init = new EventEmitter() | ||
20 | |||
21 | ngAfterViewInit () { | ||
22 | this.init.emit({ items: this.listItems }) | ||
23 | this.listItems.changes.subscribe( | ||
24 | val => this.init.emit({ items: this.listItems }) | ||
25 | ) | ||
26 | } | ||
27 | |||
28 | hoverItem (index: number) { | ||
29 | this.init.emit({ items: this.listItems, index: index }) | ||
30 | } | ||
31 | } | ||