@eclipse-scout/core
Version:
Eclipse Scout runtime
387 lines (322 loc) • 12 kB
text/typescript
/*
* Copyright (c) 2010, 2026 BSI Business Systems Integration AG
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
import {
aria, Event, InitModelOf, InputFieldKeyStrokeContext, keys, KeyStrokeContext, keyStrokeModifier, Outline, Page, PropertyChangeEvent, SearchOutlineDownKeyStroke, SearchOutlineEventMap, SearchOutlineLayout, SearchOutlineModel,
SearchOutlineUpKeyStroke, SearchState, TreeAllChildNodesDeletedEvent, TreeNodesDeletedEvent, TreeNodesInsertedEvent
} from '../../index';
import $ from 'jquery';
export class SearchOutline extends Outline implements SearchOutlineModel {
declare model: SearchOutlineModel;
declare eventMap: SearchOutlineEventMap;
declare self: SearchOutline;
searchQuery: string;
maxSearchFieldLength = 60;
minSearchTokenLength = 2;
searchFieldKeyStrokeContext: KeyStrokeContext;
searchStatus: string;
$searchPanel: JQuery;
$clearIcon: JQuery;
$searchStatus: JQuery;
$queryField: JQuery<HTMLInputElement>;
protected _searchQueryValid = true;
protected _searchStates = new Set<SearchState>();
protected _searchStateChangeHandler = this._onSearchStateChanged.bind(this);
protected _searchStateDestroyHandler = this._onSearchStateDestroy.bind(this);
protected override _init(model: InitModelOf<this>) {
super._init(model);
this.updateSearchStates();
this.on('nodesInserted nodesDeleted allChildNodesDeleted', event => {
if (!(event as unknown as TreeNodesInsertedEvent<SearchOutline> | TreeNodesDeletedEvent<SearchOutline> | TreeAllChildNodesDeletedEvent<SearchOutline>).parentNode) {
this.updateSearchStates();
}
});
this._setMaxSearchFieldLength(this.maxSearchFieldLength);
this._setMinSearchTokenLength(this.minSearchTokenLength);
this.search();
}
protected override _initKeyStrokeContext() {
super._initKeyStrokeContext();
this.searchFieldKeyStrokeContext = this._createKeyStrokeContextForSearchField();
this.searchFieldKeyStrokeContext.registerKeyStroke(new SearchOutlineDownKeyStroke(this));
this.searchFieldKeyStrokeContext.registerKeyStroke(new SearchOutlineUpKeyStroke(this));
}
protected _createKeyStrokeContextForSearchField(): KeyStrokeContext {
let keyStrokeContext = new InputFieldKeyStrokeContext();
keyStrokeContext.$scopeTarget = () => this.$searchPanel;
keyStrokeContext.$bindTarget = () => this.$queryField;
keyStrokeContext.registerStopPropagationKeys(keyStrokeModifier.NONE, [keys.ENTER, keys.BACKSPACE]);
return keyStrokeContext;
}
protected override _render() {
super._render();
// Override layout
this.htmlComp.setLayout(new SearchOutlineLayout(this));
this.$container.addClass('search-outline');
this.$searchPanel = this.$container.prependDiv('search-outline-panel');
this.$queryField = this.$searchPanel.appendElement('<input>', 'search-outline-field')
.on('input', this._createOnQueryFieldInputFunction().bind(this))
.on('keypress', this._onQueryFieldKeyPress.bind(this)) as JQuery<HTMLInputElement>;
this.$clearIcon = this.$searchPanel.appendSpan('clear-icon unfocusable action text-field-icon')
.on('mousedown', this._onClearIconMouseDown.bind(this));
this.$searchStatus = this.$searchPanel.appendDiv('search-outline-status')
.on('mousedown', this._onTitleMouseDown.bind(this));
this.session.keyStrokeManager.installKeyStrokeContext(this.searchFieldKeyStrokeContext);
}
protected override _remove() {
this.session.keyStrokeManager.uninstallKeyStrokeContext(this.searchFieldKeyStrokeContext);
this.$searchPanel.remove();
super._remove();
}
protected override _renderProperties() {
super._renderProperties();
this._renderSearchQuery();
this._renderSearchStatus();
}
protected override _renderTitle() {
super._renderTitle();
// Move before search panel
if (this.titleVisible) {
this.$title.insertBefore(this.$searchPanel);
aria.linkElementWithLabel(this.$queryField, this.$title);
}
}
setSearchQuery(searchQuery: string) {
this.setProperty('searchQuery', searchQuery);
}
protected _setSearchQuery(searchQuery: string) {
this._setProperty('searchQuery', searchQuery);
this.search();
}
protected _renderSearchQuery() {
this.$queryField.val(this.searchQuery);
this._updateHasText();
}
protected _updateHasText() {
this.$queryField.toggleClass('has-text', !!this.$queryField.val());
}
setSearchStatus(searchStatus: string) {
this.setProperty('searchStatus', searchStatus);
}
protected _updateSearchStatus() {
if (!this._searchQueryValid) {
return;
}
if (this.pending) {
this.setSearchStatus(this.session.text('ui.SearchInProgressFor', this.searchQuery));
return;
}
const resultCount = this.resultCount;
this.setSearchStatus(this.session.text('ui.NumSearchResults', this.limited ? resultCount + '+' : resultCount, this.searchQuery));
}
protected _renderSearchStatus() {
let animate = this.rendered;
if (this.searchStatus && !this.$searchStatus.isVisible()) {
if (animate) {
this.$searchStatus.slideDown({
duration: 200,
progress: this.revalidateLayout.bind(this)
});
} else {
this.$searchStatus.show();
}
} else if (!this.searchStatus && this.$searchStatus.isVisible()) {
if (animate) {
this.$searchStatus.slideUp({
duration: 200,
progress: this.revalidateLayout.bind(this)
});
} else {
this.$searchStatus.hide();
}
}
this.$searchStatus.textOrNbsp(this.searchStatus);
this.$searchPanel.toggleClass('has-status', !!this.searchStatus);
}
setMaxSearchFieldLength(maxSearchFieldLength: number) {
this.setProperty('maxSearchFieldLength', maxSearchFieldLength);
}
protected _setMaxSearchFieldLength(maxSearchFieldLength: number) {
if (maxSearchFieldLength <= 0) {
maxSearchFieldLength = 200;
}
this._setProperty('maxSearchFieldLength', maxSearchFieldLength);
}
setMinSearchTokenLength(minSearchTokenLength: number) {
this.setProperty('minSearchTokenLength', minSearchTokenLength);
}
protected _setMinSearchTokenLength(minSearchTokenLength: number) {
if (minSearchTokenLength <= 0) {
minSearchTokenLength = 2;
}
this._setProperty('minSearchTokenLength', minSearchTokenLength);
}
search() {
this._searchStates.forEach(searchState => searchState.setPending(true));
this._validateSearchQuery();
if (!this._searchQueryValid) {
this.resetSearch();
return;
}
this._updateSearchStatus();
this._triggerSearch();
}
protected _validateSearchQuery() {
if (!this.searchQuery?.length) {
this.setSearchStatus(null);
this._searchQueryValid = false;
return;
}
if (this.searchQuery.length > this.maxSearchFieldLength) {
this.setSearchStatus(this.session.text('ui.SearchTermTooLong'));
this._searchQueryValid = false;
return;
}
// remove wildcards and split into tokens (i.e. non-empty strings without spaces)
const tokens = this.searchQuery.replaceAll(/\*/g, '').split(' ').filter(Boolean);
// at least one token must have min length
for (const token of tokens) {
if (token.length >= this.minSearchTokenLength) {
this._searchQueryValid = true;
return;
}
}
this.setSearchStatus(this.session.text('ui.SearchTermTooShort'));
this._searchQueryValid = false;
}
protected _triggerSearch() {
this.trigger('search');
}
resetSearch() {
this._triggerResetSearch();
}
protected _triggerResetSearch() {
this.trigger('resetSearch');
}
protected _createOnQueryFieldInputFunction(): (event: JQuery.TriggeredEvent) => void {
let debounceFunction = $.debounce(() => this.setSearchQuery(this.$queryField.val()));
return event => {
this._updateHasText();
// debounced update search query
debounceFunction();
};
}
protected _onClearIconMouseDown(event: JQuery.MouseDownEvent) {
this.setSearchQuery('');
// focus field if x is pressed when the field does not have the focus
this.$queryField.focus();
// stay in field when x is pressed
event.preventDefault();
}
protected _onQueryFieldKeyPress(event: JQuery.KeyPressEvent) {
if (event.which === keys.ENTER) {
this._setSearchQuery(this.$queryField.val());
}
}
focusQueryField() {
this.validateFocus();
}
/**
* Focus and select content AFTER the search outline was rendered (and therefore the query field filled).
*/
override validateFocus() {
if (!this.rendered) {
return;
}
let elementToFocus = this.$queryField[0];
if (this.session.focusManager.requestFocus(elementToFocus)) {
elementToFocus.select();
}
}
updateSearchStates() {
const searchStates = new Set<SearchState>();
for (const page of this._getRelevantSearchPages()) {
if (page.searchState) {
searchStates.add(page.searchState);
}
}
this.setSearchStates(searchStates);
}
protected _getRelevantSearchPages(): SearchPage[] {
if (this.compact) {
const compactRootNode = this.compactRootNode();
if (compactRootNode) {
return compactRootNode.childNodes;
}
}
return this.nodes;
}
setSearchStates(searchStates: Set<SearchState>) {
searchStates ||= new Set();
const searchStatesToAdd = new Set<SearchState>();
for (const searchState of searchStates) {
if (this._searchStates.has(searchState)) {
continue;
}
searchStatesToAdd.add(searchState);
}
const searchStatesToRemove = new Set<SearchState>();
for (const searchState of this._searchStates) {
if (searchStates.has(searchState)) {
continue;
}
searchStatesToRemove.add(searchState);
}
if (!searchStatesToAdd.size && !searchStatesToRemove.size) {
return;
}
searchStatesToAdd.forEach(searchState => {
this._searchStates.add(searchState);
this._installSearchStateListeners(searchState);
});
searchStatesToRemove.forEach(searchState => {
this._searchStates.delete(searchState);
this._uninstallSearchStateListeners(searchState);
});
this._updateSearchStatus();
}
protected _installSearchStateListeners(searchState: SearchState) {
if (!searchState) {
return;
}
searchState.on('propertyChange:resultCount propertyChange:limited propertyChange:pending', this._searchStateChangeHandler);
searchState.one('destroy', this._searchStateDestroyHandler);
}
protected _uninstallSearchStateListeners(searchState: SearchState) {
if (!searchState) {
return;
}
searchState.off('propertyChange:resultCount propertyChange:limited propertyChange:pending', this._searchStateChangeHandler);
searchState.off('destroy', this._searchStateDestroyHandler);
}
protected _onSearchStateChanged(event: PropertyChangeEvent<number | boolean, SearchState>) {
this._updateSearchStatus();
}
protected _onSearchStateDestroy(event: Event<SearchState>) {
const searchStates = new Set(this._searchStates);
searchStates.delete(event.source);
this.setSearchStates(searchStates);
}
get resultCount(): number {
let resultCount = 0;
for (const searchState of this._searchStates) {
resultCount += searchState.resultCount;
}
return resultCount;
}
get limited(): boolean {
return Array.from(this._searchStates).some(state => state.limited);
}
get pending(): boolean {
return Array.from(this._searchStates).some(state => state.pending);
}
}
export interface SearchPage extends Page {
searchState?: SearchState;
}