@eclipse-scout/core
Version:
Eclipse Scout runtime
722 lines (624 loc) • 23.7 kB
text/typescript
/*
* Copyright (c) 2010, 2024 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 {
Accordion, arrays, Comparator, Event, EventDelegator, EventHandler, Filter, FilterOrFunction, FilterResult, FilterSupport, Group, InitModelOf, KeyStrokeContext, ObjectOrChildModel, ObjectOrModel, objects, PropertyChangeEvent, Rectangle,
scout, TextFilter, Tile, TileAccordionEventMap, TileAccordionLayout, TileAccordionModel, TileAccordionSelectionHandler, TileGrid, TileGridLayout, TileGridLayoutConfig, TileTextFilter
} from '../../index';
export class TileAccordion<TTile extends Tile = Tile> extends Accordion implements TileAccordionModel {
declare model: TileAccordionModel;
declare eventMap: TileAccordionEventMap;
declare self: TileAccordion<TTile>;
declare groups: Group<TileGrid<TTile>>[];
gridColumnCount: number;
multiSelect: boolean;
selectable: boolean;
takeTileFiltersFromGroup: boolean;
tileComparator: Comparator<TTile>;
filters: Filter<TTile>[];
tileGridLayoutConfig: TileGridLayoutConfig;
tileGridSelectionHandler: TileAccordionSelectionHandler;
withPlaceholders: boolean;
virtual: boolean;
textFilterEnabled: boolean;
filterSupport: FilterSupport<TTile>;
createTextFilter: () => TextFilter<TTile>;
updateTextFilterText: string;
$filterFieldContainer: JQuery;
protected _selectionUpdateLocked: boolean;
protected _tileGridPropertyChangeHandler: EventHandler<PropertyChangeEvent>;
protected _groupBodyHeightChangeHandler: EventHandler<Event<Group<TileGrid<TTile>>>>;
constructor() {
super();
this.exclusiveExpand = false;
this.gridColumnCount = null;
this.multiSelect = null;
this.selectable = null;
this.takeTileFiltersFromGroup = true;
this.tileComparator = null;
this.filters = [];
this.tileGridLayoutConfig = null;
this.tileGridSelectionHandler = new TileAccordionSelectionHandler(this);
this.withPlaceholders = null;
this.virtual = null;
this.$filterFieldContainer = null;
this.textFilterEnabled = false;
this.filterSupport = this._createFilterSupport();
this.createTextFilter = null;
this.updateTextFilterText = null;
this._selectionUpdateLocked = false;
this._tileGridPropertyChangeHandler = this._onTileGridPropertyChange.bind(this);
this._groupBodyHeightChangeHandler = this._onGroupBodyHeightChange.bind(this);
}
protected override _render() {
super._render();
this.$container.addClass('tile-accordion');
this.$filterFieldContainer = this.$container.prependDiv('filter-field-container');
}
protected override _createLayout(): TileAccordionLayout {
return new TileAccordionLayout(this);
}
protected override _renderProperties() {
super._renderProperties();
this._renderTextFilterEnabled();
}
protected override _remove() {
this.filterSupport.remove();
super._remove();
}
protected override _init(model: InitModelOf<this>) {
super._init(model);
this.setFilters(this.filters);
this._setTileGridLayoutConfig(this.tileGridLayoutConfig);
}
protected override _createKeyStrokeContext(): KeyStrokeContext {
return new KeyStrokeContext();
}
protected override _initGroup(group: Group<TileGrid<TTile>> & { body: TileGrid<TTile> & { __tileAccordionEventDelegator?: EventDelegator } }) {
super._initGroup(group);
group.body.setSelectionHandler(this.tileGridSelectionHandler);
// Copy properties from accordion to new group. If the properties are not set yet, copy them from the group to the accordion
// This gives the possibility to either define the properties on the accordion or on the tileGrid initially
if (this.gridColumnCount !== null) {
group.body.setGridColumnCount(this.gridColumnCount);
}
this.setProperty('gridColumnCount', group.body.gridColumnCount);
if (this.multiSelect !== null) {
group.body.setMultiSelect(this.multiSelect);
}
this.setProperty('multiSelect', group.body.multiSelect);
if (this.selectable !== null) {
group.body.setSelectable(this.selectable);
}
this.setProperty('selectable', group.body.selectable);
if (this.tileGridLayoutConfig !== null) {
group.body.setLayoutConfig(this.tileGridLayoutConfig);
}
this.setProperty('tileGridLayoutConfig', group.body.layoutConfig);
if (this.tileComparator !== null) {
group.body.setComparator(this.tileComparator);
group.body.sort();
}
this.setProperty('tileComparator', group.body.comparator);
if (this.filters.length > 0) {
group.body.addFilter(this.filters);
}
if (this.takeTileFiltersFromGroup) {
this.setFilters(group.body.filters);
}
if (this.withPlaceholders !== null) {
group.body.setWithPlaceholders(this.withPlaceholders);
}
this.setProperty('withPlaceholders', group.body.withPlaceholders);
if (this.virtual !== null) {
group.body.setVirtual(this.virtual);
}
this.setProperty('virtual', group.body.virtual);
if (group.body.selectedTiles.length > 0) {
this._handleSelectionChanged(group.body);
}
group.body.on('propertyChange', this._tileGridPropertyChangeHandler);
this._handleCollapsed(group);
// Delegate events so that consumers don't need to attach a listener to each tile grid by themselves
group.body.__tileAccordionEventDelegator = EventDelegator.create(group.body, this, {
delegateEvents: ['tileClick', 'tileAction']
});
}
protected override _deleteGroup(group: Group<TileGrid<TTile>> & { body: TileGrid<TTile> & { __tileAccordionEventDelegator?: EventDelegator } }) {
if (group.body) {
group.body.off('propertyChange', this._tileGridPropertyChangeHandler);
group.body.__tileAccordionEventDelegator.destroy();
group.body.__tileAccordionEventDelegator = null;
}
super._deleteGroup(group);
}
override setGroups(groups: Group<TileGrid<TTile>>[]) {
let oldTileCount = this.getTileCount();
let oldFilteredTileCount = this.getFilteredTileCount();
let oldSelectedTileCount = this.getSelectedTileCount();
super.setGroups(groups);
let tileCount = this.getTileCount();
let filteredTileCount = this.getFilteredTileCount();
let selectedTileCount = this.getSelectedTileCount();
// Trigger artificial property changes if necessary
// See _onTileGridPropertyChange why parameters are null
if (tileCount !== oldTileCount) {
this.triggerPropertyChange('tiles', null, null);
}
if (filteredTileCount !== oldFilteredTileCount) {
this.triggerPropertyChange('filteredTiles', null, null);
}
if (selectedTileCount !== oldSelectedTileCount) {
this.triggerPropertyChange('selectedTiles', null, null);
}
}
/** @see TileAccordionModel.gridColumnCount */
setGridColumnCount(gridColumnCount: number) {
this.groups.forEach(group => {
group.body.setGridColumnCount(gridColumnCount);
});
this.setProperty('gridColumnCount', gridColumnCount);
}
/** @see TileAccordionModel.tileGridLayoutConfig */
setTileGridLayoutConfig(layoutConfig: ObjectOrModel<TileGridLayoutConfig>) {
this.groups.forEach(group => {
group.body.setLayoutConfig(layoutConfig);
layoutConfig = group.body.layoutConfig; // May be converted from plain object to TileGridLayoutConfig
});
this.setProperty('tileGridLayoutConfig', layoutConfig);
}
protected _setTileGridLayoutConfig(layoutConfig: ObjectOrModel<TileGridLayoutConfig>) {
this._setProperty('tileGridLayoutConfig', TileGridLayoutConfig.ensure(layoutConfig));
}
/** @see TileAccordionModel.withPlaceholders */
setWithPlaceholders(withPlaceholders: boolean) {
this.groups.forEach(group => {
group.body.setWithPlaceholders(withPlaceholders);
});
this.setProperty('withPlaceholders', withPlaceholders);
}
/** @see TileAccordionModel.virtual */
setVirtual(virtual: boolean) {
this.groups.forEach(group => {
group.body.setVirtual(virtual);
});
this.setProperty('virtual', virtual);
}
/** @see TileAccordionModel.selectable */
setSelectable(selectable: boolean) {
this.groups.forEach(group => {
group.body.setSelectable(selectable);
});
this.setProperty('selectable', selectable);
}
/** @see TileAccordionModel.multiSelect */
setMultiSelect(multiSelect: boolean) {
this.groups.forEach(group => {
group.body.setMultiSelect(multiSelect);
});
this.setProperty('multiSelect', multiSelect);
}
getGroupById(id: string): Group<TileGrid<TTile>> {
return arrays.find(this.groups, group => group.id === id);
}
getGroupByTile(tile: TTile): Group<TileGrid<TTile>> {
return tile.findParent(Group<TileGrid<TTile>>);
}
/**
* Distribute the tiles to the corresponding groups and returns an object with group id as key and array of tiles as value.
* Always returns all current groups even if the given tiles may not be distributed to all groups.
*/
protected _groupTiles(tiles: TTile[]): Record<string, TTile[]> {
// Create a map of groups, key is the id, value is an array of tiles
let tilesPerGroup = {};
this.groups.forEach(group => {
tilesPerGroup[group.id] = [];
});
// Distribute the tiles to the corresponding groups
tiles.forEach(function(tile) {
let group = this.getGroupByTile(tile);
if (!group) {
throw new Error('No group found for tile ' + tile.id);
}
if (!tilesPerGroup[group.id]) {
tilesPerGroup[group.id] = [];
}
tilesPerGroup[group.id].push(tile);
}, this);
return tilesPerGroup;
}
deleteTile(tile: TTile) {
this.deleteTiles([tile]);
}
deleteTiles(tilesToDelete: TTile[] | TTile) {
tilesToDelete = arrays.ensure(tilesToDelete);
if (tilesToDelete.length === 0) {
return;
}
let tiles = this.getTiles();
arrays.removeAll(tiles, tilesToDelete);
this.setTiles(tiles);
}
deleteAllTiles() {
this.setTiles([]);
}
/**
* Distributes the given tiles to their corresponding groups.
*
* If the list contains new tiles not assigned to a group yet, an exception will be thrown.
*/
setTiles(tilesOrModels: ObjectOrChildModel<TTile> | ObjectOrChildModel<TTile>[]) {
let tilesOrModelsArr = arrays.ensure(tilesOrModels);
if (objects.equals(this.getTiles(), tilesOrModelsArr)) {
return;
}
// Ensure given tiles are real tiles (of type TTile)
let tiles = this._createChildren(tilesOrModelsArr);
// Distribute the tiles to the corresponding groups (result may contain groups without tiles)
let tilesPerGroup = this._groupTiles(tiles);
// Update the tile grids
for (let id in tilesPerGroup) { // NOSONAR
let group = this.getGroupById(id);
group.body.setTiles(tilesPerGroup[id]);
}
}
getTiles(): TTile[] {
let tiles = [];
this.groups.forEach(group => {
arrays.pushAll(tiles, group.body.tiles);
});
return tiles;
}
getTileCount(): number {
let count = 0;
this.groups.forEach(group => {
count += group.body.tiles.length;
});
return count;
}
/**
* @param filter The filters to add.
* @param applyFilter Whether to apply the filters after modifying the filter list or not. Default is true.
*/
addFilter(filter: FilterOrFunction<TTile> | FilterOrFunction<TTile>[], applyFilter = true) {
this.filterSupport.addFilter(filter, applyFilter);
}
/**
* @param filter The filters to remove.
* @param applyFilter Whether to apply the filters after modifying the filter list or not. Default is true.
*/
removeFilter(filter: FilterOrFunction<TTile> | FilterOrFunction<TTile>[], applyFilter = true) {
this.filterSupport.removeFilter(filter, applyFilter);
}
/**
* @param filter The new filters.
* @param applyFilter Whether to apply the filters after modifying the filter list or not. Default is true.
*/
setFilters(filters: FilterOrFunction<TTile> | FilterOrFunction<TTile>[], applyFilter = true) {
this.filterSupport.setFilters(filters, applyFilter);
}
protected _setFilters(filter: FilterOrFunction<TTile> | FilterOrFunction<TTile>[]) {
let filters = arrays.ensure(filter);
this.groups.forEach(group => {
group.body.setFilters(filters.slice(), false);
});
this._setProperty('filters', filters.slice());
}
filter() {
this.filterSupport.filter();
}
protected _filter(): FilterResult<TTile> {
this.groups.forEach(group => group.body.filter());
return null; // FilterSupport of the TileGrids take care of the results
}
protected _createFilterSupport(): FilterSupport<TTile> {
return new FilterSupport({
widget: this,
$container: () => this.$filterFieldContainer,
filterElements: this._filter.bind(this),
createTextFilter: this._createTextFilter.bind(this),
updateTextFilterText: this._updateTextFilterText.bind(this)
});
}
protected _createTextFilter(): TextFilter<TTile> {
if (objects.isFunction(this.createTextFilter)) {
return this.createTextFilter();
}
return new TileTextFilter();
}
protected _updateTextFilterText(filter: Filter<TTile>, text: string): boolean {
if (objects.isFunction(this.updateTextFilterText)) {
return this.updateTextFilterText(filter, text);
}
if (filter instanceof TileTextFilter) {
return filter.setText(text);
}
return false;
}
/** @see TileAccordionModel.textFilterEnabled */
setTextFilterEnabled(textFilterEnabled: boolean) {
this.setProperty('textFilterEnabled', textFilterEnabled);
}
isTextFilterFieldVisible(): boolean {
return this.textFilterEnabled;
}
protected _renderTextFilterEnabled() {
this.filterSupport.renderFilterField();
}
getFilteredTiles(): TTile[] {
let tiles = [];
this.groups.forEach(group => {
arrays.pushAll(tiles, group.body.filteredTiles);
});
return tiles;
}
getFilteredTileCount(): number {
let count = 0;
this.groups.forEach(group => {
count += group.body.filteredTiles.length;
});
return count;
}
/**
* Compared to {@link getFilteredTiles()}, this function considers the collapsed state of the group as well, meaning only filtered tiles of expanded groups are returned.
*/
getVisibleTiles(): TTile[] {
let tiles = [];
this.expandedGroups().forEach(group => {
arrays.pushAll(tiles, group.body.filteredTiles);
});
return tiles;
}
/**
* Compared to {@link getFilteredTiles()}, this function considers the collapsed state of the group as well, meaning only filtered tiles of expanded groups are counted.
*/
getVisibleTileCount(): number {
let count = 0;
this.expandedGroups().forEach(group => {
count += group.body.filteredTiles.length;
});
return count;
}
findVisibleTileIndexAt(x: number, y: number, startIndex?: number, reverse?: boolean): number {
startIndex = scout.nvl(startIndex, 0);
return arrays.findIndexFrom(this.getVisibleTiles(), startIndex, (tile, i) => {
return new Rectangle(this.getVisibleGridX(tile), this.getVisibleGridY(tile), tile.gridData.w, tile.gridData.h).contains(x, y);
}, reverse);
}
/**
* Selects the given tiles and deselects the previously selected ones.
*
* Tiles, that are currently invisible due to an active filter, are excluded and won't be selected.
*/
selectTiles(tiles: TTile[]) {
tiles = arrays.ensure(tiles);
// Split tiles into separate lists for each group (result may contain groups without tiles)
let tilesPerGroup = this._groupTiles(tiles);
// Select the tiles in the corresponding tile grids
for (let id in tilesPerGroup) { // NOSONAR
let group = this.getGroupById(id);
group.body.selectTiles(tilesPerGroup[id]);
}
}
/** @see selectTiles */
selectTile(tile: TTile) {
this.selectTiles([tile]);
}
/**
* Selects all tiles. As for every selection operation: only considers filtered tiles and tiles of expanded groups
*/
selectAllTiles() {
this.selectTiles(this.getVisibleTiles());
}
deselectTiles(tiles: TTile[]) {
tiles = arrays.ensure(tiles);
let selectedTiles = this.getSelectedTiles().slice();
if (arrays.removeAll(selectedTiles, tiles)) {
this.selectTiles(selectedTiles);
}
}
deselectTile(tile: TTile) {
this.deselectTiles([tile]);
}
deselectAllTiles() {
this.selectTiles([]);
}
addTilesToSelection(tiles: TTile[]) {
tiles = arrays.ensure(tiles);
this.selectTiles(this.getSelectedTiles().concat(tiles));
}
addTileToSelection(tile: TTile) {
this.addTilesToSelection([tile]);
}
getSelectedTiles(): TTile[] {
let selectedTiles = [];
this.groups.forEach(group => {
arrays.pushAll(selectedTiles, group.body.selectedTiles);
});
return selectedTiles;
}
getSelectedTile(): TTile {
return this.getSelectedTiles()[0];
}
getSelectedTileCount(): number {
let count = 0;
this.groups.forEach(group => {
count += group.body.selectedTiles.length;
});
return count;
}
/**
* Deselects every tile if all tiles are selected. Otherwise, selects all tiles.
*/
toggleSelection() {
if (this.getSelectedTileCount() === this.getVisibleTileCount()) {
this.deselectAllTiles();
} else {
this.selectAllTiles();
}
}
/** @see TileAccordionModel.tileComparator */
setTileComparator(comparator: Comparator<TTile>) {
this.groups.forEach(group => {
group.body.setComparator(comparator);
});
this.setProperty('tileComparator', comparator);
}
sortTiles() {
this.groups.forEach(group => {
group.body.sort();
});
}
setFocusedTile(tile: TTile) {
let groupForTile = null;
if (tile !== null) {
groupForTile = this.getGroupByTile(tile);
}
this.groups.forEach(group => {
if (group === groupForTile) {
group.body.setFocusedTile(tile);
} else {
group.body.setFocusedTile(null);
}
});
}
getFocusedTile(): TTile {
let focusedTile = null;
this.groups.some(group => {
if (group.body.focusedTile) {
focusedTile = group.body.focusedTile;
return true;
}
return false;
});
return focusedTile;
}
getVisibleGridRowCount(): number {
return this.expandedGroups().reduce((acc, group) => {
return acc + group.body.logicalGrid.gridRows;
}, 0);
}
getVisibleGridX(tile: TTile): number {
return tile.gridData.x;
}
getVisibleGridY(tile: TTile): number {
let group = this.getGroupByTile(tile);
let yCorr = this.getVisibleRowByGroup(group);
return tile.gridData.y + yCorr;
}
getGroupByVisibleRow(rowToFind: number): Group<TileGrid<TTile>> {
if (rowToFind < 0 || rowToFind >= this.getVisibleGridRowCount()) {
return null;
}
let currentIndex = 0;
return arrays.find(this.expandedGroups(), group => {
let rowCount = group.body.logicalGrid.gridRows;
if (currentIndex <= rowToFind && rowToFind < currentIndex + rowCount) {
return true;
}
currentIndex += rowCount;
});
}
/**
* @returns the index of the row where the group is located.<p>
* Example: There are 3 rows and 2 groups. The first group contains 2 rows, the second 1 row.
* The index of the first group is 0, the index of the second group is 2.
*/
getVisibleRowByGroup(groupToFind: Group<TileGrid<TTile>>): number {
let currentIndex = 0;
let found = this.expandedGroups().some(group => {
let rowCount = group.body.logicalGrid.gridRows;
if (group === groupToFind) {
return true;
}
currentIndex += rowCount;
return false;
});
if (!found) {
return -1;
}
return currentIndex;
}
expandedGroups(): Group<TileGrid<TTile>>[] {
return this.groups.filter(group => !group.collapsed);
}
protected _handleSelectionChanged(tileGrid: TileGrid<TTile>) {
if (this._selectionUpdateLocked) {
// Don't execute when deselecting other tiles to minimize the amount of property change events
return;
}
let group = tileGrid.parent as Group;
if (tileGrid.selectedTiles.length > 0 && group.collapsed) {
// Do not allow selection in a collapsed group (breaks keyboard navigation and is confusing for the user if invisible tiles are selected)
tileGrid.deselectAllTiles();
return;
}
if (!this.multiSelect && tileGrid.selectedTiles.length > 0) {
this._selectionUpdateLocked = true;
// Ensure only one grid has a selected tile if multiSelect is false
this.groups.forEach(group => {
if (group.body !== tileGrid) {
group.body.deselectAllTiles();
}
});
this._selectionUpdateLocked = false;
}
}
protected _onTileGridPropertyChange(event: PropertyChangeEvent<any, TileGrid<TTile>>) {
// Trigger artificial property changes with newValue set to null.
// Reason: these property changes are fired for each grid. Creating the compound arrays using getFilteredTiles() etc.
// costs some time (even if only some ms) but may not be necessary at all. The consumer can still call these functions by himself.
// Also: oldValue cannot be estimated either way which makes it consistent
if (event.propertyName === 'selectedTiles') {
this._handleSelectionChanged(event.source);
this.triggerPropertyChange('selectedTiles', null, null);
} else if (event.propertyName === 'filteredTiles') {
this.triggerPropertyChange('filteredTiles', null, null);
} else if (event.propertyName === 'tiles') {
this.triggerPropertyChange('tiles', null, null);
}
}
protected override _onGroupCollapsedChange(event: PropertyChangeEvent<boolean, Group<TileGrid<TTile>>>) {
super._onGroupCollapsedChange(event);
this._handleCollapsed(event.source);
}
protected _handleCollapsed(group: Group<TileGrid<TTile>>) {
if (group.collapsed) {
// Deselect tiles of a collapsed group (this will also set focusedTile to null) -> actions on invisible elements is confusing, and keystrokes only operate on visible elements, too
group.body.deselectAllTiles();
}
if (group.rendered) {
group.on('bodyHeightChange', this._groupBodyHeightChangeHandler);
group.one('bodyHeightChangeDone', this._onGroupBodyHeightChangeDone.bind(this));
}
}
protected _onGroupBodyHeightChange(event: Event<Group<TileGrid<TTile>>>) {
this.groups.forEach(group => {
if (event.source === group || group.bodyAnimating) {
// No need to layout body for the group which is already expanding / collapsing since it does it anyway
// Btw: another group may be doing it as well at the same time (e.g. because of exclusiveExpand)
return;
}
if (group.body.virtual && group.body.htmlComp) {
(group.body.htmlComp.layout as TileGridLayout).updateViewPort();
}
});
}
protected _onGroupBodyHeightChangeDone(event: Event<Group<TileGrid<TTile>>>) {
event.source.off('bodyHeightChange', this._groupBodyHeightChangeHandler);
}
/**
* @returns the first fully visible tile at the scrollTop.
*/
tileAtScrollTop(scrollTop: number): TTile {
return arrays.find(this.getTiles().filter(tile => tile.rendered), tile => {
return tile.$container.position().top >= scrollTop;
});
}
}