SvgTree.ts 42.2 KB
Newer Older
1
2
3
4
5
6
7
8
9
10
11
12
13
/*
 * This file is part of the TYPO3 CMS project.
 *
 * It is free software; you can redistribute it and/or modify it under
 * the terms of the GNU General Public License, either version 2
 * of the License, or any later version.
 *
 * For the full copyright and license information, please read the
 * LICENSE.txt file that was distributed with this source code.
 *
 * The TYPO3 project - inspiring people to share!
 */

Benjamin Franzke's avatar
Benjamin Franzke committed
14
15
import {html, LitElement, TemplateResult} from 'lit';
import {customElement, property, state} from 'lit/decorators';
16
17
18
19
20
21
22
23
24
import {TreeNode} from './Tree/TreeNode';
import * as d3selection from 'd3-selection';
import AjaxRequest from 'TYPO3/CMS/Core/Ajax/AjaxRequest';
import Notification = require('./Notification');
import {KeyTypesEnum as KeyTypes} from './Enum/KeyTypes';
import Icons = require('./Icons');
import Tooltip = require('./Tooltip');
import {AjaxResponse} from 'TYPO3/CMS/Core/Ajax/AjaxResponse';
import {MarkupIdentifiers} from './Enum/IconTypes';
25
26
27
28
import {lll} from 'TYPO3/CMS/Core/lit-helper';
import DebounceEvent from 'TYPO3/CMS/Core/Event/DebounceEvent';
import 'TYPO3/CMS/Backend/Element/IconElement';
import 'TYPO3/CMS/Backend/Input/Clearable';
29
import {Tooltip as BootstrapTooltip} from 'bootstrap';
30

31
export type TreeWrapperSelection<TBase extends d3selection.BaseType> = d3selection.Selection<TBase, any, any, any>;
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
export type TreeNodeSelection = d3selection.Selection<d3selection.BaseType, TreeNode, any, any>;

interface SvgTreeData {
  nodes: TreeNode[];
  links: SvgTreeDataLink[];
}

interface SvgTreeDataLink {
  source: TreeNode;
  target: TreeNode;
}

interface SvgTreeDataIcon {
  identifier: string;
  icon: string;
}

export interface SvgTreeSettings {
  [keys: string]: any;
  defaultProperties: {[keys: string]: any};
}

export interface SvgTreeWrapper extends HTMLElement {
  svgtree?: SvgTree
}

58
59
export class SvgTree extends LitElement {
  @property({type: Object}) setup?: {[keys: string]: any} = null;
Benjamin Franzke's avatar
Benjamin Franzke committed
60
  @state() settings: SvgTreeSettings = {
61
62
63
64
65
66
67
68
69
70
    showIcons: false,
    marginTop: 15,
    nodeHeight: 20,
    indentWidth: 16,
    width: 300,
    duration: 400,
    dataUrl: '',
    filterUrl: '',
    defaultProperties: {},
    expandUpToLevel: null as any,
71
    actions: []
72
  };
73
74
75
76
77
78
79

  /**
   * Check if cursor is over the SVG element
   */
  public isOverSvg: boolean = false;

  /**
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
   * Root <svg> element
   */
  public svg: TreeWrapperSelection<SVGSVGElement> = null;

  /**
   * SVG <g> container wrapping all .nodes, .links, .nodes-bg  elements
   */
  public container: TreeWrapperSelection<SVGGElement> = null;

  /**
   * SVG <g> container wrapping all .node elements
   */
  public nodesContainer: TreeWrapperSelection<SVGGElement> = null;

  /**
   * SVG <g> container wrapping all .nodes-bg elements
   */
  public nodesBgContainer: TreeWrapperSelection<SVGGElement> = null;

  /**
   * Is set when the input device is hovered over a node
101
102
103
   */
  public hoveredNode: TreeNode|null = null;

104
105
  public nodes: TreeNode[] = [];

106
  public textPosition: number = 10;
107

108
  protected icons: {[keys: string]: SvgTreeDataIcon} = {};
109
  protected nodesActionsContainer: TreeWrapperSelection<SVGGElement> = null;
110
111
112
113
114
115
116
117
118
119
120
121
122
123

  /**
   * SVG <defs> container wrapping all icon definitions
   */
  protected iconsContainer: TreeWrapperSelection<SVGDefsElement> = null;

  /**
   * SVG <g> container wrapping all links (lines between parent and child)
   *
   * @type {Selection}
   */
  protected linksContainer: TreeWrapperSelection<SVGGElement> = null;

  protected data: SvgTreeData = new class implements SvgTreeData {
124
125
    links: SvgTreeDataLink[] = [];
    nodes: TreeNode[] = [];
126
127
128
129
  };

  protected viewportHeight: number = 0;
  protected scrollBottom: number = 0;
130
131
  protected searchTerm: string|null = null;
  protected unfilteredNodes: string = '';
132

133
134
  protected networkErrorTitle: string = top.TYPO3.lang.tree_networkError;
  protected networkErrorMessage: string = top.TYPO3.lang.tree_networkErrorDescription;
Benni Mack's avatar
Benni Mack committed
135

136
137
  protected tooltipOptions: Partial<BootstrapTooltip.Options> = {};

138
139
  /**
   * Initializes the tree component - created basic markup, loads and renders data
140
   * @todo declare private
141
   */
142
  public doSetup(settings: any): void {
143
    Object.assign(this.settings, settings);
144
145
146
    if (this.settings.showIcons) {
      this.textPosition += 20;
    }
147

148
    this.svg = d3selection.select(this).select('svg');
149
150
    this.container = this.svg.select('.nodes-wrapper') as TreeWrapperSelection<SVGGElement>;
    this.nodesBgContainer = this.container.select('.nodes-bg') as TreeWrapperSelection<SVGGElement>;
151
    this.nodesActionsContainer = this.container.select('.nodes-actions') as TreeWrapperSelection<SVGGElement>;
152
153
154
    this.linksContainer = this.container.select('.links') as TreeWrapperSelection<SVGGElement>;
    this.nodesContainer = this.container.select('.nodes') as TreeWrapperSelection<SVGGElement>;
    this.iconsContainer = this.svg.select('defs') as TreeWrapperSelection<SVGGElement>;
155

156
157
158
159
160
161
162
    this.tooltipOptions = {
      delay: 50,
      trigger: 'hover',
      placement: 'right',
      container: '#' + this.id,
    }

163
164
    this.updateScrollPosition();
    this.loadData();
165
    this.dispatchEvent(new Event('svg-tree:initialized'));
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
  }

  /**
   * Make the DOM element given as parameter focusable and focus it
   *
   * @param {SVGElement} element
   */
  public switchFocus(element: SVGElement|HTMLElement): void {
    if (element === null) {
      return;
    }
    const visibleElements = element.parentNode.querySelectorAll('[tabindex]');
    visibleElements.forEach((visibleElement) => {
      visibleElement.setAttribute('tabindex','-1');
    });
    element.setAttribute('tabindex', '0');
    element.focus();
  }

  /**
   * Make the DOM element of the node given as parameter focusable and focus it
   */
188
  public switchFocusNode(node: TreeNode): void {
Benni Mack's avatar
Benni Mack committed
189
190
191
192
193
194
195
    this.switchFocus(this.getNodeElement(node));
  }

  /**
   * Return the DOM element of a tree node
   */
  public getNodeElement(node: TreeNode): HTMLElement|null {
196
    return this.querySelector('#identifier-' + this.getNodeStateIdentifier(node));
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
  }

  /**
   * Loads tree data (json) from configured url
   */
  public loadData() {
    this.nodesAddPlaceholder();
    (new AjaxRequest(this.settings.dataUrl))
      .get({cache: 'no-cache'})
      .then((response: AjaxResponse) => response.resolve())
      .then((json) => {
        const nodes = Array.isArray(json) ? json : [];
        this.replaceData(nodes);
        this.nodesRemovePlaceholder();
        // @todo: needed?
        this.updateScrollPosition();
213
        this.updateVisibleNodes();
214
215
      })
      .catch((error) => {
Benni Mack's avatar
Benni Mack committed
216
        this.errorNotification(error, false);
217
218
219
220
221
222
223
224
225
226
227
228
229
        this.nodesRemovePlaceholder();
        throw error;
      });
  }

  /**
   * Delete old tree and create new one
   */
  public replaceData(nodes: TreeNode[]) {
    this.setParametersNode(nodes);
    this.prepareDataForVisibleNodes();
    this.nodesContainer.selectAll('.node').remove();
    this.nodesBgContainer.selectAll('.node-bg').remove();
230
    this.nodesActionsContainer.selectAll('.node-action').remove();
231
    this.linksContainer.selectAll('.link').remove();
232
    this.updateVisibleNodes();
233
234
235
  }

  /**
236
237
   * Set parameters like node parents, parentsStateIdentifier, checked.
   * Usually called when data is loaded initially or replaced completely.
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
   *
   * @param {Node[]} nodes
   */
  public setParametersNode(nodes: TreeNode[] = null): void {
    nodes = nodes || this.nodes;
    nodes = nodes.map((node, index) => {
      if (typeof node.command === 'undefined') {
        node = Object.assign({}, this.settings.defaultProperties, node);
      }
      node.expanded = (this.settings.expandUpToLevel !== null) ? node.depth < this.settings.expandUpToLevel : Boolean(node.expanded);
      node.parents = [];
      node.parentsStateIdentifier = [];
      if (node.depth > 0) {
        let currentDepth = node.depth;
        for (let i = index; i >= 0; i--) {
          let currentNode = nodes[i];
          if (currentNode.depth < currentDepth) {
            node.parents.push(i);
            node.parentsStateIdentifier.push(nodes[i].stateIdentifier);
            currentDepth = currentNode.depth;
          }
        }
      }

      if (typeof node.checked === 'undefined') {
        node.checked = false;
      }
      return node;
    });

    // get nodes with depth 0, if there is only 1 then open it and disable toggle
269
270
    const nodesOnRootLevel = nodes.filter((node) => node.depth === 0);
    if (nodesOnRootLevel.length === 1) {
271
272
      nodes[0].expanded = true;
    }
273
274
275
    const evt = new CustomEvent('typo3:svg-tree:nodes-prepared', {detail: {nodes: nodes}, bubbles: false});
    this.dispatchEvent(evt);
    this.nodes = evt.detail.nodes;
276
277
278
  }

  public nodesRemovePlaceholder() {
279
    const nodeLoader = this.querySelector('.node-loader') as HTMLElement;
280
281
282
    if (nodeLoader) {
      nodeLoader.style.display = 'none';
    }
283
    const componentWrapper = this.closest('.svg-tree');
Benni Mack's avatar
Benni Mack committed
284
    const treeLoader = componentWrapper?.querySelector('.svg-tree-loader') as HTMLElement;
285
286
287
288
289
290
291
    if (treeLoader) {
      treeLoader.style.display = 'none';
    }
  }

  public nodesAddPlaceholder(node: TreeNode = null) {
    if (node) {
292
      const nodeLoader = this.querySelector('.node-loader') as HTMLElement;
293
294
295
296
297
      if (nodeLoader) {
        nodeLoader.style.top = '' + (node.y + this.settings.marginTop);
        nodeLoader.style.display = 'block';
      }
    } else {
298
      const componentWrapper = this.closest('.svg-tree');
Benni Mack's avatar
Benni Mack committed
299
      const treeLoader = componentWrapper?.querySelector('.svg-tree-loader') as HTMLElement;
300
301
302
303
304
305
306
307
308
309
310
311
312
313
      if (treeLoader) {
        treeLoader.style.display = 'block';
      }
    }
  }

  /**
   * Updates node's data to hide/collapse children
   *
   * @param {Node} node
   */
  public hideChildren(node: TreeNode): void {
    node.expanded = false;
    this.setExpandedState(node);
314
    this.dispatchEvent(new CustomEvent('typo3:svg-tree:expand-toggle', {detail: {node: node}}));
315
316
317
318
319
320
321
322
323
324
  }

  /**
   * Updates node's data to show/expand children
   *
   * @param {Node} node
   */
  public showChildren(node: TreeNode): void {
    node.expanded = true;
    this.setExpandedState(node);
325
    this.dispatchEvent(new CustomEvent('typo3:svg-tree:expand-toggle', {detail: {node: node}}));
326
327
328
329
330
331
332
333
334
335
  }

  /**
   * Updates the expanded state of the DOM element that belongs to the node.
   * This is required because the node is not recreated on update and thus the change in the expanded state
   * of the node data is not represented in DOM on hideChildren and showChildren.
   *
   * @param {Node} node
   */
  public setExpandedState(node: TreeNode): void {
Benni Mack's avatar
Benni Mack committed
336
    const nodeElement = this.getNodeElement(node);
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
    if (nodeElement) {
      if (node.hasChildren) {
        nodeElement.setAttribute('aria-expanded', node.expanded ? 'true' : 'false');
      } else {
        nodeElement.removeAttribute('aria-expanded');
      }
    }
  }

  /**
   * Refresh view with new data
   */
  public refreshTree(): void {
    this.loadData();
  }

353
354
355
356
357
358
359
360
  public refreshOrFilterTree(): void {
    if (this.searchTerm !== '') {
      this.filter(this.searchTerm);
    } else {
      this.refreshTree();
    }
  }

361
362
363
364
365
  /**
   * Filters out invisible nodes (collapsed) from the full dataset (this.rootNode)
   * and enriches dataset with additional properties
   * Visible dataset is stored in this.data
   */
366
  public prepareDataForVisibleNodes(): void {
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
    const blacklist: {[keys: string]: boolean} = {};
    this.nodes.forEach((node: TreeNode, index: number): void => {
      if (!node.expanded) {
        blacklist[index] = true;
      }
    });

    this.data.nodes = this.nodes.filter((node: TreeNode): boolean => {
      return node.hidden !== true && !node.parents.some((index: number) => Boolean(blacklist[index]))
    });

    this.data.links = [];
    let pathAboveMounts = 0;

    this.data.nodes.forEach((node: TreeNode, i: number) => {
      // delete n.children;
      node.x = node.depth * this.settings.indentWidth;
      if (node.readableRootline) {
        pathAboveMounts += this.settings.nodeHeight;
      }

      node.y = (i * this.settings.nodeHeight) + pathAboveMounts;
      if (node.parents[0] !== undefined) {
        this.data.links.push({
          source: this.nodes[node.parents[0]],
          target: node
        });
      }

      if (this.settings.showIcons) {
        this.fetchIcon(node.icon);
        this.fetchIcon(node.overlayIcon);
        if (node.locked) {
          this.fetchIcon('warning-in-use');
        }
      }
    });

    this.svg.attr('height', ((this.data.nodes.length * this.settings.nodeHeight) + (this.settings.nodeHeight / 2) + pathAboveMounts));
  }

  /**
   * Fetch icon from Icon API and store it in this.icons
   */
  public fetchIcon(iconName: string, update: boolean = true): void {
    if (!iconName) {
      return;
    }

    if (!(iconName in this.icons)) {
      this.icons[iconName] = {
        identifier: iconName,
        icon: ''
      };
      Icons.getIcon(iconName, Icons.sizes.small, null, null, MarkupIdentifiers.inline).then((icon: string) => {
        let result = icon.match(/<svg[\s\S]*<\/svg>/i);
        if (result) {
          this.icons[iconName].icon = result[0];
        }
        if (update) {
427
          this.updateVisibleNodes();
428
429
430
431
432
433
434
435
        }
      });
    }
  }

  /**
   * Renders the subset of the tree nodes fitting the viewport (adding, modifying and removing SVG nodes)
   */
436
  public updateVisibleNodes(): void {
437
438
439
440
    const visibleRows = Math.ceil(this.viewportHeight / this.settings.nodeHeight + 1);
    const position = Math.floor(Math.max(this.scrollTop - (this.settings.nodeHeight * 2), 0) / this.settings.nodeHeight);

    const visibleNodes = this.data.nodes.slice(position, position + visibleRows);
441
    const focusableElement = this.querySelector('[tabindex="0"]');
442
    const checkedNodeInViewport = visibleNodes.find((node: TreeNode) => node.checked);
443

444
445
446
447
    let nodes = this.nodesContainer.selectAll('.node')
      .data(visibleNodes, (node: TreeNode) => node.stateIdentifier);
    const nodesBg = this.nodesBgContainer.selectAll('.node-bg')
      .data(visibleNodes, (node: TreeNode) => node.stateIdentifier);
448
449
    const nodesActions = this.nodesActionsContainer.selectAll('.node-action')
      .data(visibleNodes, (node: TreeNode) => node.stateIdentifier);
450
451
452
453
454

    // delete nodes without corresponding data
    nodes.exit().remove();
    // delete
    nodesBg.exit().remove();
455
    nodesActions.exit().remove();
456

457
458
    // update nodes actions
    this.updateNodeActions(nodesActions);
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
    // update nodes background
    const nodeBgClass = this.updateNodeBgClass(nodesBg);

    nodeBgClass
      .attr('class', (node: TreeNode, i: number) => {
        return this.getNodeBgClass(node, i, nodeBgClass);
      })
      .attr('style', (node: TreeNode) => {
        return node.backgroundColor ? 'fill: ' + node.backgroundColor + ';' : '';
      });

    this.updateLinks();
    nodes = this.enterSvgElements(nodes);

    // update nodes
    nodes
      .attr('tabindex', (node: TreeNode, index: number) => {
        if (typeof checkedNodeInViewport !== 'undefined') {
          if (checkedNodeInViewport === node) {
            return '0';
          }
        } else {
          if (focusableElement === null) {
            if (index === 0) {
              return '0';
            }
          } else {
            if (d3selection.select(focusableElement).datum() === node) {
              return '0';
            }
          }
        }
        return '-1';
      })
      .attr('transform', this.getNodeTransform)
      .select('.node-name')
495
      .html((node: TreeNode) => this.getNodeLabel(node));
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536

    nodes
      .select('.chevron')
      .attr('transform', this.getChevronTransform)
      .style('fill', this.getChevronColor)
      .attr('class', this.getChevronClass);

    nodes
      .select('.toggle')
      .attr('visibility', this.getToggleVisibility);

    if (this.settings.showIcons) {
      nodes
        .select('use.node-icon')
        .attr('xlink:href', this.getIconId);
      nodes
        .select('use.node-icon-overlay')
        .attr('xlink:href', this.getIconOverlayId);
      nodes
        .select('use.node-icon-locked')
        .attr('xlink:href', (node: TreeNode) => {
          return '#icon-' + (node.locked ? 'warning-in-use' : '');
        });
    }
  }

  public updateNodeBgClass(nodesBg: TreeNodeSelection): TreeNodeSelection {
    return nodesBg.enter()
      .append('rect')
      .merge(nodesBg as d3selection.Selection<SVGRectElement, TreeNode, any, any>)
      .attr('width', '100%')
      .attr('height', this.settings.nodeHeight)
      .attr('data-state-id', this.getNodeStateIdentifier)
      .attr('transform', this.getNodeBgTransform)
      .on('mouseover', (evt: MouseEvent, node: TreeNode) => this.onMouseOverNode(node))
      .on('mouseout', (evt: MouseEvent, node: TreeNode) => this.onMouseOutOfNode(node))
      .on('click', (evt: MouseEvent, node: TreeNode) => {
        this.selectNode(node);
        this.switchFocusNode(node);
      })
      .on('contextmenu', (evt: MouseEvent, node: TreeNode) => {
537
        this.dispatchEvent(new CustomEvent('typo3:svg-tree:node-context', {detail: {node: node}}));
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
      });
  }

  /**
   * Returns icon's href attribute value
   */
  public getIconId(node: TreeNode): string {
    return '#icon-' + node.icon;
  }

  /**
   * Returns icon's href attribute value
   */
  public getIconOverlayId(node: TreeNode): string {
    return '#icon-' + node.overlayIcon;
  }

  /**
   * Node selection logic (triggered by different events)
557
   * This represents a dummy method and is usually overridden
558
559
560
561
562
   */
  public selectNode(node: TreeNode): void {
    if (!this.isNodeSelectable(node)) {
      return;
    }
563
564
565
566
567
    // Disable already selected nodes
    this.disableSelectedNodes();
    node.checked = true;
    this.dispatchEvent(new CustomEvent('typo3:svg-tree:node-selected', {detail: {node: node}}));
    this.updateVisibleNodes();
568
569
  }

570
571
572
573
574
  public filter(searchTerm?: string|null): void {
    if (typeof searchTerm === 'string') {
      this.searchTerm = searchTerm;
    }
    this.nodesAddPlaceholder();
575
    if (this.searchTerm && this.settings.filterUrl) {
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
      (new AjaxRequest(this.settings.filterUrl + '&q=' + this.searchTerm))
        .get({cache: 'no-cache'})
        .then((response: AjaxResponse) => response.resolve())
        .then((json) => {
          let nodes = Array.isArray(json) ? json : [];
          if (nodes.length > 0) {
            if (this.unfilteredNodes === '') {
              this.unfilteredNodes = JSON.stringify(this.nodes);
            }
            this.replaceData(nodes);
          }
          this.nodesRemovePlaceholder();
        })
        .catch((error: any) => {
          this.errorNotification(error, false)
          this.nodesRemovePlaceholder();
          throw error;
        });
    } else {
      // restore original state without filters
      this.resetFilter();
    }
  }

  public resetFilter(): void
  {
    this.searchTerm = '';
    if (this.unfilteredNodes.length > 0) {
      let currentlySelected = this.getSelectedNodes()[0];
      if (typeof currentlySelected === 'undefined') {
        this.refreshTree();
        return;
      }
      this.nodes = JSON.parse(this.unfilteredNodes);
      this.unfilteredNodes = '';
      // re-select the node from the identifier because the nodes have been updated
      const currentlySelectedNode = this.getNodeByIdentifier(currentlySelected.stateIdentifier);
      if (currentlySelectedNode) {
        this.selectNode(currentlySelectedNode);
      } else {
        this.refreshTree();
      }
    } else {
      this.refreshTree();
    }
    this.prepareDataForVisibleNodes();
622
    this.updateVisibleNodes();
623
624
  }

Benni Mack's avatar
Benni Mack committed
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
  /**
   * Displays a notification message and refresh nodes
   */
  public errorNotification(error: any = null, refresh: boolean = false): void {
    if (Array.isArray(error)) {
      error.forEach((message: any) => { Notification.error(
        message.title,
        message.message
      )});
    } else {
      let title = this.networkErrorTitle;
      if (error && error.target && (error.target.status || error.target.statusText)) {
        title += ' - ' + (error.target.status || '') + ' ' + (error.target.statusText || '');
      }
      Notification.error(title, this.networkErrorMessage);
    }
    if (refresh) {
      this.loadData();
    }
  }

646
647
648
649
650
651
652
653
654
655
656
657
  public connectedCallback(): void {
    super.connectedCallback();
    this.addEventListener('resize', () => this.updateView());
    this.addEventListener('scroll', () => this.updateView());
    this.addEventListener('svg-tree:visible', () => this.updateView());
    window.addEventListener('resize', () => {
      if (this.getClientRects().length > 0) {
        this.updateView();
      }
    });
  }

658
659
660
661
662
663
664
  /**
   * Returns an array of selected nodes
   */
  public getSelectedNodes(): TreeNode[] {
    return this.nodes.filter((node: TreeNode) => node.checked);
  }

665
666
667
668
669
670
  // disable shadow dom for now
  protected createRenderRoot(): HTMLElement | ShadowRoot {
    return this;
  }

  protected render(): TemplateResult {
671
672
673
674
    return html`
      <div class="node-loader">
        <typo3-backend-icon identifier="spinner-circle-light" size="small"></typo3-backend-icon>
      </div>
675
676
677
678
679
      <svg version="1.1"
           width="100%"
           @mouseover=${() => this.isOverSvg = true}
           @mouseout=${() => this.isOverSvg = false}
           @keydown=${(evt: KeyboardEvent) => this.handleKeyboardInteraction(evt)}>
680
681
682
683
        <g class="nodes-wrapper" transform="translate(${this.settings.indentWidth / 2},${this.settings.nodeHeight / 2})">
          <g class="nodes-bg"></g>
          <g class="links"></g>
          <g class="nodes" role="tree"></g>
684
          <g class="nodes-actions"></g>
685
686
687
688
689
690
        </g>
        <defs></defs>
      </svg>
    `;
  }

691
692
693
694
695
  protected firstUpdated(): void {
    this.svg = d3selection.select(this.querySelector('svg'))
    this.container = d3selection.select(this.querySelector('.nodes-wrapper'))
      .attr('transform', 'translate(' + (this.settings.indentWidth / 2) + ',' + (this.settings.nodeHeight / 2) + ')') as any;
    this.nodesBgContainer = d3selection.select(this.querySelector('.nodes-bg')) as any;
696
    this.nodesActionsContainer = d3selection.select(this.querySelector('.nodes-actions')) as any;
697
698
699
700
701
    this.linksContainer = d3selection.select(this.querySelector('.links')) as any;
    this.nodesContainer = d3selection.select(this.querySelector('.nodes')) as any;

    this.doSetup(this.setup || {});
    this.updateView();
702
703
704
705
706
  }

  protected updateView(): void {
    this.updateScrollPosition();
    this.updateVisibleNodes();
707
708
709
    if (this.settings.actions && this.settings.actions.length) {
      this.nodesActionsContainer.attr('transform', 'translate(' + (this.querySelector('svg').clientWidth - 16 - ((16 * this.settings.actions.length))) + ',0)');
    }
710
711
  }

712
713
714
715
716
717
718
719
720
  protected disableSelectedNodes(): void {
    // Disable already selected nodes
    this.getSelectedNodes().forEach((node: TreeNode) => {
      if (node.checked === true) {
        node.checked = false;
      }
    });
  }

721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
  /**
   * Ensure to update the actions column to stick to the very end
   */
  protected updateNodeActions(nodesActions: TreeNodeSelection): TreeNodeSelection {
    if (this.settings.actions && this.settings.actions.length) {
      return nodesActions.enter()
        .append('g')
        .merge(nodesActions as d3selection.Selection<SVGGElement, TreeNode, any, any>)
        .attr('class', 'node-action')
        .on('mouseover', (evt: MouseEvent, node: TreeNode) => this.onMouseOverNode(node))
        .on('mouseout', (evt: MouseEvent, node: TreeNode) => this.onMouseOutOfNode(node))
        .attr('data-state-id', this.getNodeStateIdentifier)
        .attr('transform', this.getNodeActionTransform)
    }
    return nodesActions.enter();
  }

738
739
740
741
742
743
  /**
   * Check whether node can be selected.
   * In some cases (e.g. selecting a parent) it should not be possible to select
   * element (as it's own parent).
   */
  protected isNodeSelectable(node: TreeNode): boolean {
744
    return true;
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
  }

  protected appendTextElement(nodes: TreeNodeSelection): TreeNodeSelection {
    return nodes
      .append('text')
      .attr('dx', (node: TreeNode) => {
        return this.textPosition + (node.locked ? 15 : 0);
      })
      .attr('dy', 5)
      .attr('class', 'node-name')
      .on('click', (evt: MouseEvent, node: TreeNode) => this.selectNode(node));
  }

  protected nodesUpdate(nodes: TreeNodeSelection): TreeNodeSelection {
    nodes = nodes
      .enter()
      .append('g')
      .attr('class', this.getNodeClass)
      .attr('id', (node: TreeNode) => {
        return 'identifier-' + node.stateIdentifier;
      })
      .attr('role', 'treeitem')
      .attr('aria-owns', (node: TreeNode) => {
        return (node.hasChildren ? 'group-identifier-' + node.stateIdentifier : null);
      })
      .attr('aria-level', this.getNodeDepth)
      .attr('aria-setsize', this.getNodeSetsize)
      .attr('aria-posinset', this.getNodePositionInSet)
      .attr('aria-expanded', (node: TreeNode) => {
        return (node.hasChildren ? node.expanded : null);
      })
      .attr('transform', this.getNodeTransform)
      .attr('data-state-id', this.getNodeStateIdentifier)
      .attr('title', this.getNodeTitle)
      .on('mouseover', (evt: MouseEvent, node: TreeNode) => this.onMouseOverNode(node))
Benni Mack's avatar
Benni Mack committed
780
781
782
      .on('mouseout', (evt: MouseEvent, node: TreeNode) => this.onMouseOutOfNode(node))
      .on('contextmenu', (evt: MouseEvent, node: TreeNode) => {
        evt.preventDefault();
783
        this.dispatchEvent(new CustomEvent('typo3:svg-tree:node-context', {detail: {node: node}}));
Benni Mack's avatar
Benni Mack committed
784
      });
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
    nodes
      .append('text')
      .text((node: TreeNode) => {
        return node.readableRootline;
      })
      .attr('class', 'node-rootline')
      .attr('dx', 0)
      .attr('dy', -15)
      .attr('visibility', (node: TreeNode) => node.readableRootline ? 'visible' : 'hidden');
    return nodes;
  }

  protected getNodeIdentifier(node: TreeNode): string {
    return node.identifier;
  }

  protected getNodeDepth(node: TreeNode): number {
    return node.depth;
  }

  protected getNodeSetsize(node: TreeNode): number {
    return node.siblingsCount;
  }

  protected getNodePositionInSet(node: TreeNode): number {
    return node.siblingsPosition;
  }

  protected getNodeStateIdentifier(node: TreeNode): string {
    return node.stateIdentifier;
  }

  protected getNodeLabel(node: TreeNode): string {
818
819
820
821
822
823
824
825
826
827
828
    let label = (node.prefix || '') + node.name + (node.suffix || '');
    // make a text node out of it, and strip out any HTML (this is because the return value uses html()
    // instead of text() which is needed to avoid XSS in a page title
    const labelNode = document.createElement('div');
    labelNode.textContent = label;
    label = labelNode.innerHTML;
    if (this.searchTerm) {
      const regexp = new RegExp(this.searchTerm, 'gi');
      label = label.replace(regexp, '<tspan class="node-highlight-text">$&</tspan>');
    }
    return label;
829
830
831
832
833
834
  }

  protected getNodeClass(node: TreeNode): string {
    return 'node identifier-' + node.stateIdentifier;
  }

835
836
837
838
839
840
841
842
843
  /**
   * Finds node by its stateIdentifier (e.g. "0_360")
   */
  protected getNodeByIdentifier(identifier: string): TreeNode|null {
    return this.nodes.find((node: TreeNode) => {
      return node.stateIdentifier === identifier;
    });
  }

844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
  /**
   * Computes the tree node-bg class
   */
  protected getNodeBgClass(node: TreeNode, i: number, nodeBgClass: TreeNodeSelection): string {
    let bgClass = 'node-bg';
    let prevNode = null;
    let nextNode = null;

    if (typeof nodeBgClass === 'object') {
      prevNode = nodeBgClass.data()[i - 1];
      nextNode = nodeBgClass.data()[i + 1];
    }

    if (node.checked) {
      bgClass += ' node-selected';
    }

    if ((prevNode && (node.depth > prevNode.depth)) || !prevNode) {
      node.firstChild = true;
863
      bgClass += ' node-first-child';
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
    }

    if ((nextNode && (node.depth > nextNode.depth)) || !nextNode) {
      node.lastChild = true;
      bgClass += ' node-last-child';
    }

    if (node.class) {
      bgClass += ' ' + node.class;
    }

    return bgClass;
  }

  protected getNodeTitle(node: TreeNode): string {
    return node.tip ? node.tip : 'uid=' + node.identifier;
  }

  protected getChevronTransform(node: TreeNode): string {
    return node.expanded ? 'translate(16,0) rotate(90)' : ' rotate(0)';
  }

886
  protected getChevronColor(node: TreeNode): string {
887
888
889
890
    return node.expanded ? '#000' : '#8e8e8e';
  }

  protected getToggleVisibility(node: TreeNode): string {
891
    return node.hasChildren ? 'visible' : 'hidden';
892
893
894
895
896
897
898
899
900
901
902
903
904
905
  }

  protected getChevronClass(node: TreeNode): string {
    return 'chevron ' + (node.expanded ? 'expanded' : 'collapsed');
  }

  /**
   * Returns a SVG path's 'd' attribute value
   *
   * @param {SvgTreeDataLink} link
   * @returns {String}
   */
  protected getLinkPath(link: SvgTreeDataLink): string {
    const target = {
Benni Mack's avatar
Benni Mack committed
906
907
      x: link.target.x,
      y: link.target.y
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
    };
    const path = [];
    path.push('M' + link.source.x + ' ' + link.source.y);
    path.push('V' + target.y);
    if (link.target.hasChildren) {
      path.push('H' + (target.x - 2));
    } else {
      path.push('H' + ((target.x + this.settings.indentWidth / 4) - 2));
    }
    return path.join(' ');
  }

  /**
   * Returns a 'transform' attribute value for the tree element (absolute positioning)
   *
   * @param {Node} node
   */
  protected getNodeTransform(node: TreeNode): string {
    return 'translate(' + (node.x || 0) + ',' + (node.y || 0) + ')';
  }

  /**
   * Returns a 'transform' attribute value for the node background element (absolute positioning)
   *
   * @param {Node} node
   */
  protected getNodeBgTransform(node: TreeNode): string {
    return 'translate(-8, ' + ((node.y || 0) - 10) + ')';
  }

938
939
940
941
942
943
944
945
946
  /**
   * Returns a 'transform' attribute value for the node background element (absolute positioning)
   *
   * @param {Node} node
   */
  protected getNodeActionTransform(node: TreeNode): string {
    return 'translate(0, ' + ((node.y || 0) - 9) + ')';
  }

947
948
949
950
  /**
   * Event handler for clicking on a node's icon
   */
  protected clickOnIcon(node: TreeNode): void {
951
    this.dispatchEvent(new CustomEvent('typo3:svg-tree:node-context', {detail: {node: node}}));
952
953
954
955
956
957
958
959
960
961
962
963
  }

  /**
   * Event handler for click on a chevron
   */
  protected chevronClick(node: TreeNode): void {
    if (node.expanded) {
      this.hideChildren(node);
    } else {
      this.showChildren(node);
    }
    this.prepareDataForVisibleNodes();
964
965
966
    this.updateVisibleNodes();
  }

967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
  /**
   * Adds missing SVG nodes
   *
   * @param {Selection} nodes
   * @returns {Selection}
   */
  protected enterSvgElements(nodes: TreeNodeSelection): TreeNodeSelection {
    if (this.settings.showIcons) {
      const iconsArray = Object.values(this.icons)
        .filter((icon: SvgTreeDataIcon): boolean => icon.icon !== '');
      const icons = this.iconsContainer
        .selectAll('.icon-def')
        .data(iconsArray, (icon: SvgTreeDataIcon) => icon.identifier);
      icons.exit().remove();

      icons
        .enter()
        .append('g')
        .attr('class', 'icon-def')
        .attr('id', (node: TreeNode) => 'icon-' + node.identifier)
        .append((node: TreeNode): SVGElement => {
          // workaround for IE11 where you can't simply call .html(content) on svg
          const parser = new DOMParser();
          const markupText = node.icon.replace('<svg', '<g').replace('/svg>', '/g>');
          const markup = '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">' + markupText + '</svg>';
          const dom = parser.parseFromString(markup, 'image/svg+xml');
          return dom.documentElement.firstChild as SVGElement;
        });
    }

    // create the node elements
    const nodeEnter = this.nodesUpdate(nodes);

    // append the chevron element
    let chevron = nodeEnter
      .append('g')
      .attr('class', 'toggle')
      .attr('visibility', this.getToggleVisibility)
      .attr('transform', 'translate(-8, -8)')
      .on('click', (evt: MouseEvent, node: TreeNode) => this.chevronClick(node));

    // improve usability by making the click area a 16px square
    chevron
      .append('path')
      .style('opacity', 0)
      .attr('d', 'M 0 0 L 16 0 L 16 16 L 0 16 Z');
    chevron
      .append('path')
      .attr('class', 'chevron')
      .attr('d', 'M 4 3 L 13 8 L 4 13 Z');

    // append the icon element
    if (this.settings.showIcons) {
      const nodeContainer = nodeEnter
        .append('g')
        .attr('class', 'node-icon-container')
        .attr('title', this.getNodeTitle)
        .attr('data-bs-toggle', 'tooltip')
        .on('click', (evt: MouseEvent, node: TreeNode) => {
1026
          evt.preventDefault();
1027
1028
1029
          this.clickOnIcon(node)
        });

1030
1031
1032
1033
1034
1035
1036
1037
1038
      // improve usability by making the click area a 20px square
      nodeContainer
        .append('rect')
        .style('opacity', 0)
        .attr('width', '20')
        .attr('height', '20')
        .attr('x', '6')
        .attr('y', '-10');

1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
      nodeContainer
        .append('use')
        .attr('class', 'node-icon')
        .attr('data-uid', this.getNodeIdentifier)
        .attr('transform', 'translate(8, -8)');

      nodeContainer
        .append('use')
        .attr('transform', 'translate(8, -3)')
        .attr('class', 'node-icon-overlay');

      nodeContainer
        .append('use')
        .attr('x', 27)
        .attr('y', -7)
        .attr('class', 'node-icon-locked');
    }

1057
    Tooltip.initialize('[data-bs-toggle="tooltip"]', this.tooltipOptions);
1058
1059
1060
1061

    this.appendTextElement(nodeEnter);
    return nodes.merge(nodeEnter);
  }
1062
1063
1064
1065
  /**
   * Updates variables used for visible nodes calculation
   */
  private updateScrollPosition(): void {
1066
    this.viewportHeight = this.getBoundingClientRect().height;
1067
    this.scrollBottom = this.scrollTop + this.viewportHeight + (this.viewportHeight / 2);
1068
1069
1070
1071
    // wait for the tooltip to appear and disable tooltips when scrolling
    setTimeout(() => {
      Tooltip.hide(this.querySelectorAll('.bs-tooltip-end'));
    }, <number>this.tooltipOptions.delay)
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
  }

  /**
   * node background events
   */
  private onMouseOverNode(node: TreeNode): void {
    node.isOver = true;
    this.hoveredNode = node;

    let elementNodeBg = this.svg.select('.nodes-bg .node-bg[data-state-id="' + node.stateIdentifier + '"]');
    if (elementNodeBg.size()) {
      elementNodeBg
        .classed('node-over', true)
        .attr('rx', '3')
        .attr('ry', '3');
    }
1088
1089
1090
1091
1092
1093
1094

    let elementNodeAction = this.nodesActionsContainer.select('.node-action[data-state-id="' + node.stateIdentifier + '"]');
    if (elementNodeAction.size()) {
      elementNodeAction.classed('node-action-over', true);
      // @todo: needs to be adapted for active nodes
      elementNodeAction.attr('fill', elementNodeBg.style('fill'));
    }
1095
  }
1096

1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
  /**
   * node background events
   */
  private onMouseOutOfNode(node: TreeNode): void {
    node.isOver = false;
    this.hoveredNode = null;

    let elementNodeBg = this.svg.select('.nodes-bg .node-bg[data-state-id="' + node.stateIdentifier + '"]');
    if (elementNodeBg.size()) {
      elementNodeBg
        .classed('node-over node-alert', false)
        .attr('rx', '0')
        .attr('ry', '0');
    }
1111
1112
1113
1114
1115
1116

    let elementNodeAction = this.nodesActionsContainer.select('.node-action[data-state-id="' + node.stateIdentifier + '"]');
    if (elementNodeAction.size()) {
      elementNodeAction.classed('node-action-over', false);
    }

1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
  }

  /**
   * Add keydown handling to allow keyboard navigation inside the tree
   */
  private handleKeyboardInteraction(evt: KeyboardEvent) {
    const evtTarget = evt.target as SVGElement;
    let currentNode = d3selection.select(evtTarget).datum() as TreeNode;
    const charCodes = [
      KeyTypes.ENTER,
      KeyTypes.SPACE,
      KeyTypes.END,
      KeyTypes.HOME,
      KeyTypes.LEFT,
      KeyTypes.UP,
      KeyTypes.RIGHT,
      KeyTypes.DOWN
    ];
    if (charCodes.indexOf(evt.keyCode) === -1) {
      return;
    }
    evt.preventDefault();
    const parentDomNode = evtTarget.parentNode as SVGElement;
    // @todo Migrate to `evt.code`, see https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code
    switch (evt.keyCode) {
      case KeyTypes.END:
        // scroll to end, select last node
1144
        this.scrollTop = this.lastElementChild.getBoundingClientRect().height + this.settings.nodeHeight - this.viewportHeight;
1145
        parentDomNode.scrollIntoView({behavior: 'smooth', block: 'end'});
1146
        this.updateVisibleNodes();
1147
1148
1149
1150
        this.switchFocus(parentDomNode.lastElementChild as SVGElement);
        break;
      case KeyTypes.HOME:
        // scroll to top, select first node
1151
        this.scrollTo({'top': this.nodes[0].y, 'behavior': 'smooth'});
1152
1153
        this.prepareDataForVisibleNodes();
        this.updateVisibleNodes();
1154
1155
1156
1157
1158
        this.switchFocus(parentDomNode.firstElementChild as SVGElement);
        break;
      case KeyTypes.LEFT:
        if (currentNode.expanded) {
          // collapse node if collapsible
1159
          if (currentNode.hasChildren) {
1160
1161
            this.hideChildren(currentNode);
            this.prepareDataForVisibleNodes();
1162
            this.updateVisibleNodes();
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
          }
        } else if (currentNode.parents.length > 0) {
          // go to parent node
          let parentNode = this.nodes[currentNode.parents[0]];
          this.scrollNodeIntoVisibleArea(parentNode, 'up');
          this.switchFocusNode(parentNode);
        }
        break;
      case KeyTypes.UP:
        // select previous visible node on any level
        this.scrollNodeIntoVisibleArea(currentNode, 'up');
        this.switchFocus(evtTarget.previousSibling as SVGElement);
        break;
      case KeyTypes.RIGHT:
        if (currentNode.expanded) {
          // the current node is expanded, goto first child (next element on the list)
          this.scrollNodeIntoVisibleArea(currentNode, 'down');
          this.switchFocus(evtTarget.nextSibling as SVGElement);
        } else {
          if (currentNode.hasChildren) {
            // expand currentNode
            this.showChildren(currentNode);
            this.prepareDataForVisibleNodes();
1186
            this.updateVisibleNodes();
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
            this.switchFocus(evtTarget as SVGElement);
          }
          //do nothing if node has no children
        }
        break;
      case KeyTypes.DOWN:
        // select next visible node on any level
        // check if node is at end of viewport and scroll down if so
        this.scrollNodeIntoVisibleArea(currentNode, 'down');
        this.switchFocus(evtTarget.nextSibling as SVGElement);
        break;
      case KeyTypes.ENTER:
      case KeyTypes.SPACE:
        this.selectNode(currentNode);
        break;
      default:
    }
  }

  /**
   * If node is at the top of the viewport and direction is up, scroll up by the height of one item
   * If node is at the bottom of the viewport and direction is down, scroll down by the height of one item
   */
  private scrollNodeIntoVisibleArea(node: TreeNode, direction: string = 'up'): void {
1211
1212
1213
1214
1215
    let scrollTop = this.scrollTop;
    if (direction === 'up' && scrollTop > node.y - this.settings.nodeHeight) {
      scrollTop = node.y - this.settings.nodeHeight;
    } else if (direction === 'down' && scrollTop + this.viewportHeight <= node.y + (3 * this.settings.nodeHeight)) {
      scrollTop = scrollTop + this.settings.nodeHeight;
1216
1217
1218
    } else {
      return;
    }
1219
    this.scrollTo({'top': scrollTop, 'behavior': 'smooth'});
1220
    this.updateVisibleNodes();
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
  }

  /**
   * Renders links(lines) between parent and child nodes and is also used for grouping the children
   * The line element of the first child is used as role=group node to group the children programmatically
   */
  private updateLinks() {
    const visibleLinks = this.data.links
      .filter((link: SvgTreeDataLink) => {
        return link.source.y <= this.scrollBottom && link.target.y >= this.scrollTop - this.settings.nodeHeight;
      })
      .map((link: SvgTreeDataLink) => {
        link.source.owns = link.source.owns || [];
        link.source.owns.push('identifier-' + link.target.stateIdentifier);
        return link;
      });
1237
    const links = this.linksContainer.selectAll('.link').data(visibleLinks);
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
    // delete
    links.exit().remove();
    // create
    links.enter()
      .append('path')
      .attr('class', 'link')
      .attr('id', this.getGroupIdentifier)
      .attr('role', (link: SvgTreeDataLink): null|string => {
        return link.target.siblingsPosition === 1 && link.source.owns.length > 0 ? 'group' : null
      })
      .attr('aria-owns', (link: SvgTreeDataLink): null|string => {
        return link.target.siblingsPosition === 1 && link.source.owns.length > 0 ? link.source.owns.join(' ') : null
      })
      // create + update
      .merge(links as d3selection.Selection<any, any, any, any>)
      .attr('d', (link: SvgTreeDataLink) => this.getLinkPath(link));
  }

  /**
   * If the link target is the first child, set the group identifier.
   * The group with this id is used for grouping the siblings, thus the identifier uses the stateIdentifier of
   * the link source item.
   */
  private getGroupIdentifier(link: any): string|null {
    return link.target.siblingsPosition === 1 ? 'group-identifier-' + link.source.stateIdentifier : null;
  }

}
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319


/**
 * A basic toolbar allowing to search / filter
 */
@customElement('typo3-backend-tree-toolbar')
export class Toolbar extends LitElement {
  @property({type: SvgTree}) tree: SvgTree = null;
  protected settings = {
    searchInput: '.search-input',
    filterTimeout: 450
  };

  protected createRenderRoot(): HTMLElement | ShadowRoot {
    return this;
  }

  protected firstUpdated(): void
  {
    const inputEl = this.querySelector(this.settings.searchInput) as HTMLInputElement;
    if (inputEl) {
      new DebounceEvent('input', (evt: InputEvent) => {
        const el = evt.target as HTMLInputElement;
        this.tree.filter(el.value.trim());
      }, this.settings.filterTimeout).bindTo(inputEl);
      inputEl.focus();
      inputEl.clearable({
        onClear: () => {
          this.tree.resetFilter();
        }
      });
    }
  }

  protected render(): TemplateResult {
    /* eslint-disable @typescript-eslint/indent */
    return html`
      <div class="tree-toolbar">
        <div class="svg-toolbar__menu">
          <div class="svg-toolbar__search">
              <input type="text" class="form-control form-control-sm search-input" placeholder="${lll('tree.searchTermInfo')}">
          </div>
          <button class="btn btn-default btn-borderless btn-sm" @click="${() => this.refreshTree()}" data-tree-icon="actions-refresh" title="${lll('labels.refresh')}">
            <typo3-backend-icon identifier="actions-refresh" size="small"></typo3-backend-icon>
          </button>
        </div>
      </div>
    `;
  }

  protected refreshTree(): void {
    this.tree.refreshOrFilterTree();
  }
}