UNPKG

11.2 kBPlain TextView Raw
1// *****************************************************************************
2// Copyright (C) 2018 TypeFox and others.
3//
4// This program and the accompanying materials are made available under the
5// terms of the Eclipse Public License v. 2.0 which is available at
6// http://www.eclipse.org/legal/epl-2.0.
7//
8// This Source Code may also be made available under the following Secondary
9// Licenses when the conditions for such availability set forth in the Eclipse
10// Public License v. 2.0 are satisfied: GNU General Public License, version 2
11// with the GNU Classpath Exception which is available at
12// https://www.gnu.org/software/classpath/license.html.
13//
14// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
15// *****************************************************************************
16
17import { SearchBoxDebounce, SearchBoxDebounceOptions } from '../tree/search-box-debounce';
18import { BaseWidget, Message } from '../widgets/widget';
19import { Emitter, Event } from '../../common/event';
20import { KeyCode, Key } from '../keyboard/keys';
21import { nls } from '../../common/nls';
22
23/**
24 * Initializer properties for the search box widget.
25 */
26export interface SearchBoxProps extends SearchBoxDebounceOptions {
27
28 /**
29 * If `true`, the `Previous`, `Next`, and `Close` buttons will be visible. Otherwise, `false`. Defaults to `false`.
30 */
31 readonly showButtons?: boolean;
32
33 /**
34 * If `true`, `Filter` and `Close` buttons will be visible, and clicking the `Filter` button will triggers filter on the search term. Defaults to `false`.
35 */
36 readonly showFilter?: boolean;
37
38}
39
40export namespace SearchBoxProps {
41
42 /**
43 * The default search box widget option.
44 */
45 export const DEFAULT: SearchBoxProps = SearchBoxDebounceOptions.DEFAULT;
46
47}
48
49/**
50 * The search box widget.
51 */
52export class SearchBox extends BaseWidget {
53
54 protected static SPECIAL_KEYS = [
55 Key.ESCAPE,
56 Key.BACKSPACE
57 ];
58
59 protected static MAX_CONTENT_LENGTH = 15;
60
61 protected readonly nextEmitter = new Emitter<void>();
62 protected readonly previousEmitter = new Emitter<void>();
63 protected readonly closeEmitter = new Emitter<void>();
64 protected readonly textChangeEmitter = new Emitter<string | undefined>();
65 protected readonly filterToggleEmitter = new Emitter<boolean>();
66 protected readonly input: HTMLSpanElement;
67 protected readonly filter: HTMLElement | undefined;
68 protected _isFiltering: boolean = false;
69
70 constructor(protected readonly props: SearchBoxProps,
71 protected readonly debounce: SearchBoxDebounce) {
72
73 super();
74 this.toDispose.pushAll([
75 this.nextEmitter,
76 this.previousEmitter,
77 this.closeEmitter,
78 this.textChangeEmitter,
79 this.filterToggleEmitter,
80 this.debounce,
81 this.debounce.onChanged(data => this.fireTextChange(data))
82 ]);
83 this.hide();
84 this.update();
85 const { input, filter } = this.createContent();
86 this.input = input;
87 this.filter = filter;
88 }
89
90 get onPrevious(): Event<void> {
91 return this.previousEmitter.event;
92 }
93
94 get onNext(): Event<void> {
95 return this.nextEmitter.event;
96 }
97
98 get onClose(): Event<void> {
99 return this.closeEmitter.event;
100 }
101
102 get onTextChange(): Event<string | undefined> {
103 return this.textChangeEmitter.event;
104 }
105
106 get onFilterToggled(): Event<boolean> {
107 return this.filterToggleEmitter.event;
108 }
109
110 get isFiltering(): boolean {
111 return this._isFiltering;
112 }
113
114 get keyCodePredicate(): KeyCode.Predicate {
115 return this.canHandle.bind(this);
116 }
117
118 protected firePrevious(): void {
119 this.previousEmitter.fire(undefined);
120 }
121
122 protected fireNext(): void {
123 this.nextEmitter.fire(undefined);
124 }
125
126 protected fireClose(): void {
127 this.closeEmitter.fire(undefined);
128 }
129
130 protected fireTextChange(input: string | undefined): void {
131 this.textChangeEmitter.fire(input);
132 }
133
134 protected fireFilterToggle(): void {
135 this.doFireFilterToggle();
136 }
137
138 protected doFireFilterToggle(toggleTo: boolean = !this._isFiltering): void {
139 if (this.filter) {
140 if (toggleTo) {
141 this.filter.classList.add(SearchBox.Styles.FILTER_ON);
142 } else {
143 this.filter.classList.remove(SearchBox.Styles.FILTER_ON);
144 }
145 this._isFiltering = toggleTo;
146 this.filterToggleEmitter.fire(toggleTo);
147 this.update();
148 }
149 }
150
151 handle(event: KeyboardEvent): void {
152 event.preventDefault();
153 const keyCode = KeyCode.createKeyCode(event);
154 if (this.canHandle(keyCode)) {
155 if (Key.equals(Key.ESCAPE, keyCode) || this.isCtrlBackspace(keyCode)) {
156 this.hide();
157 } else {
158 this.show();
159 this.handleKey(keyCode);
160 }
161 }
162 }
163
164 protected handleArrowUp(): void {
165 this.firePrevious();
166 }
167
168 protected handleArrowDown(): void {
169 this.fireNext();
170 }
171
172 override onBeforeHide(): void {
173 this.removeClass(SearchBox.Styles.NO_MATCH);
174 this.doFireFilterToggle(false);
175 this.debounce.append(undefined);
176 this.fireClose();
177 }
178
179 protected handleKey(keyCode: KeyCode): void {
180 const character = Key.equals(Key.BACKSPACE, keyCode) ? '\b' : keyCode.character;
181 const data = this.debounce.append(character);
182 if (data) {
183 this.input.textContent = this.getTrimmedContent(data);
184 this.update();
185 } else {
186 this.hide();
187 }
188 }
189
190 protected getTrimmedContent(data: string): string {
191 if (data.length > SearchBox.MAX_CONTENT_LENGTH) {
192 return '...' + data.substring(data.length - SearchBox.MAX_CONTENT_LENGTH);
193 }
194 return data;
195 }
196
197 protected canHandle(keyCode: KeyCode | undefined): boolean {
198 if (keyCode === undefined) {
199 return false;
200 }
201 const { ctrl, alt, meta } = keyCode;
202 if (this.isCtrlBackspace(keyCode)) {
203 return true;
204 }
205 if (ctrl || alt || meta || keyCode.key === Key.SPACE) {
206 return false;
207 }
208 if (keyCode.character || (this.isVisible && SearchBox.SPECIAL_KEYS.some(key => Key.equals(key, keyCode)))) {
209 return true;
210 }
211 return false;
212 }
213
214 protected isCtrlBackspace(keyCode: KeyCode): boolean {
215 if (keyCode.ctrl && Key.equals(Key.BACKSPACE, keyCode)) {
216 return true;
217 }
218 return false;
219 }
220
221 updateHighlightInfo(info: SearchBox.HighlightInfo): void {
222 if (info.filterText && info.filterText.length > 0) {
223 if (info.matched === 0) {
224 this.addClass(SearchBox.Styles.NO_MATCH);
225 } else {
226 this.removeClass(SearchBox.Styles.NO_MATCH);
227 }
228 }
229 }
230
231 protected createContent(): {
232 container: HTMLElement,
233 input: HTMLSpanElement,
234 filter: HTMLElement | undefined,
235 previous: HTMLElement | undefined,
236 next: HTMLElement | undefined,
237 close: HTMLElement | undefined
238 } {
239 this.node.setAttribute('tabIndex', '0');
240 this.addClass(SearchBox.Styles.SEARCH_BOX);
241
242 const input = document.createElement('span');
243 input.classList.add(SearchBox.Styles.SEARCH_INPUT);
244 this.node.appendChild(input);
245
246 const buttons = document.createElement('div');
247 buttons.classList.add(SearchBox.Styles.SEARCH_BUTTONS_WRAPPER);
248 this.node.appendChild(buttons);
249
250 let filter: HTMLElement | undefined;
251 if (this.props.showFilter) {
252 filter = document.createElement('div');
253 filter.classList.add(
254 SearchBox.Styles.BUTTON,
255 ...SearchBox.Styles.FILTER,
256 );
257 filter.title = nls.localizeByDefault('Filter on Type');
258 buttons.appendChild(filter);
259 filter.onclick = this.fireFilterToggle.bind(this);
260 }
261
262 let previous: HTMLElement | undefined;
263 let next: HTMLElement | undefined;
264 let close: HTMLElement | undefined;
265
266 if (!!this.props.showButtons) {
267 previous = document.createElement('div');
268 previous.classList.add(
269 SearchBox.Styles.BUTTON,
270 SearchBox.Styles.BUTTON_PREVIOUS
271 );
272 previous.title = nls.localize('theia/core/searchbox/previous', 'Previous (Up)');
273 buttons.appendChild(previous);
274 previous.onclick = () => this.firePrevious.bind(this)();
275
276 next = document.createElement('div');
277 next.classList.add(
278 SearchBox.Styles.BUTTON,
279 SearchBox.Styles.BUTTON_NEXT
280 );
281 next.title = nls.localize('theia/core/searchbox/next', 'Next (Down)');
282 buttons.appendChild(next);
283 next.onclick = () => this.fireNext.bind(this)();
284 }
285
286 if (this.props.showButtons || this.props.showFilter) {
287 close = document.createElement('div');
288 close.classList.add(
289 SearchBox.Styles.BUTTON,
290 SearchBox.Styles.BUTTON_CLOSE
291 );
292 close.title = nls.localize('theia/core/searchbox/close', 'Close (Escape)');
293 buttons.appendChild(close);
294 close.onclick = () => this.hide.bind(this)();
295 }
296
297 return {
298 container: this.node,
299 input,
300 filter,
301 previous,
302 next,
303 close
304 };
305
306 }
307
308 protected override onAfterAttach(msg: Message): void {
309 super.onAfterAttach(msg);
310 // eslint-disable-next-line @typescript-eslint/no-explicit-any
311 this.addEventListener(this.input, 'selectstart' as any, () => false);
312 }
313
314}
315
316export namespace SearchBox {
317
318 /**
319 * CSS classes for the search box widget.
320 */
321 export namespace Styles {
322
323 export const SEARCH_BOX = 'theia-search-box';
324 export const SEARCH_INPUT = 'theia-search-input';
325 export const SEARCH_BUTTONS_WRAPPER = 'theia-search-buttons-wrapper';
326 export const BUTTON = 'theia-search-button';
327 export const FILTER = ['codicon', 'codicon-filter'];
328 export const FILTER_ON = 'filter-active';
329 export const BUTTON_PREVIOUS = 'theia-search-button-previous';
330 export const BUTTON_NEXT = 'theia-search-button-next';
331 export const BUTTON_CLOSE = 'theia-search-button-close';
332 export const NON_SELECTABLE = 'theia-non-selectable';
333 export const NO_MATCH = 'no-match';
334 }
335
336 export interface HighlightInfo {
337 filterText: string | undefined,
338 matched: number,
339 total: number
340 }
341
342}
343
344/**
345 * Search box factory.
346 */
347export const SearchBoxFactory = Symbol('SearchBoxFactory');
348export interface SearchBoxFactory {
349
350 /**
351 * Creates a new search box with the given initializer properties.
352 */
353 (props: SearchBoxProps): SearchBox;
354
355}