Commit 5ecfceb4 authored by Benjamin Kott's avatar Benjamin Kott Committed by Georg Ringer
Browse files

[TASK] Polish SvgTree component styling

This patch is cleaning up a lot of loose ends in the svg tree component.
It aims to make the component look more clean and polished.

- Unify focus styling with selected, hover and versioning
- Ensure the whole node looks focused instead of only some text
- Replace expand icon to match core icon styling
- Hide expand icon if page tree is stopped
- Move trigger to enter stopped pagetree to the front
- Remove blurry lines for background borders
- Make the edit input fields use as much space as is available
- Load common icons always on setup to avoid node refresh on initial loads
- Reposition the locked icon (another editor is editing)
- Correct keyboard navigation behaviour and focus setting
- Keep focus after leaving the edit mode without changes
- Adjust positioning to rely on calculation instead of hardcoded values
- Align drag delete target
- Make drag positions more visible to the user when moving a page
- Improve styling of tooltips when dragging elements
- Add border to select tree component to make scroll boundaries visible

Affected Areas:
- PageTree
- FileTree
- SelectTree

Releases: main
Resolves: #97344
Change-Id: I6034e0cbc1079b93b24c5c1a99e0299946874b2f
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/74203

Tested-by: Benjamin Franzke's avatarBenjamin Franzke <bfr@qbus.de>
Tested-by: core-ci's avatarcore-ci <typo3@b13.com>
Tested-by: Georg Ringer's avatarGeorg Ringer <georg.ringer@gmail.com>
Reviewed-by: Benjamin Franzke's avatarBenjamin Franzke <bfr@qbus.de>
Reviewed-by: Georg Ringer's avatarGeorg Ringer <georg.ringer@gmail.com>
parent a9cf5c75
$svgColors: (
border: #d7d7d7,
lines: #ddd,
nodeSelectedBg: #fff,
nodeHighlightText: #0078e6,
nodeOverBg: #f2f2f2,
dragOverBg: #d7e4f1,
dragOverBorder: transparent,
dragAlertBg: #f6d3cf,
dragAlertBorder: #d66c68,
dragAboveBg: transparent,
dragAboveBorder: transparent,
dragBetweenBg: transparent,
dragBetweenBorder: transparent,
dragBelowBg: transparent,
dragBelowBorder: transparent,
dragTooltipBg: #d7e4f1,
dragTooltipAlertBg: #f6d3cf,
dragTooltipAlertBorder: #d66c68
);
:root {
--svgtree-drag-info-bg: #fff;
--svgtree-drag-info-color: #{color-contrast(#fff)};
--svgtree-drag-info-icon-size: 16px;
--svgtree-drag-info-border-radius: 2px;
--svgtree-drag-info-padding-y: 0.5rem;
--svgtree-drag-info-padding-x: 0.75rem;
--svgtree-drag-dropindicator-color: #{tint-color($primary, 20%)};
--svgtree-structure-line-color: #ddd;
--svgtree-node-color: #000;
--svgtree-node-bg: transparent;
--svgtree-node-border-color: transparent;
--svgtree-node-version-bg: #{rgba(#f7c898, 0.5)};
--svgtree-node-version-border-color: #{shade-color(#f7c898, 20%)};
--svgtree-node-focus-bg: #{rgba(tint-color($primary, 90%), 0.5)};
--svgtree-node-focus-border-color: #{tint-color($primary, 20%)};
--svgtree-node-hover-bg: #{rgba(#fafafa, 0.5)};
--svgtree-node-hover-border-color: #d7d7d7;
--svgtree-node-selected-bg: #{rgba(#fff, 0.5)};
--svgtree-node-selected-border-color: #d7d7d7;
--svgtree-highlight-color: #{$primary};
--svgtree-info-bg: #{tint-color($info, 60%)};
--svgtree-info-color: #{color-contrast(tint-color($info, 60%))};
}
.svg-tree {
position: relative;
......@@ -70,9 +95,23 @@ $svgColors: (
}
}
// toggle to collapse and expand node entries
.node-toggle {
&-icon {
transform-origin: 50% 50%;
}
&--expanded {
.node-toggle-icon {
transform: rotate(90deg);
}
}
}
.svg-tree-element {
display: flex;
flex-direction: column;
border: 1px solid rgba(0, 0, 0, 0.25);
& > .svg-tree-wrapper {
flex: 1 0 0;
......@@ -83,6 +122,8 @@ $svgColors: (
background-color: #fafafa;
position: sticky;
top: 0;
padding: 0.5em;
border-bottom: 1px solid rgba(0, 0, 0, 0.25);
}
}
......@@ -98,100 +139,69 @@ $svgColors: (
path.link {
fill: none;
shape-rendering: crispEdges;
stroke: map_get($svgColors, lines);
stroke: var(--svgtree-structure-line-color);
stroke-width: 1;
pointer-events: none;
}
.node {
&-bg {
fill: transparent;
fill: var(--svgtree-node-bg);
stroke: var(--svgtree-node-border-color);
stroke-width: 1px;
&__border {
display: none;
pointer-events: none;
fill: #9eb2c5;
fill: var(--svgtree-drag-dropindicator-color);
stroke: var(--svgtree-drag-dropindicator-color);
stroke-width: 1;
shape-rendering: crispedges;
}
&.ver-element,
&.ver-versions,
&.ver-page {
fill: #f7c898 !important;
}
}
--svgtree-node-bg: var(--svgtree-node-version-bg);
&-over:not(.node-selected) {
fill: map_get($svgColors, nodeOverBg);
stroke-width: 1px;
stroke: map_get($svgColors, border);
}
&-selected {
fill: map_get($svgColors, nodeSelectedBg);
stroke-width: 1px;
stroke: map_get($svgColors, border);
--svgtree-node-border-color: var(--svgtree-node-version-border-color);
}
}
.nodes {
&-wrapper {
$b: '.nodes-wrapper';
&:focus {
outline: none;
}
cursor: pointer;
&-over {
--svgtree-node-bg: var(--svgtree-node-hover-bg);
&--dragging {
cursor: grabbing;
.node-over {
//it must be important because there is inline style in code that we must overwrite
fill: map_get($svgColors, dragOverBg) !important;
stroke-width: 1px;
stroke: map_get($svgColors, dragOverBorder);
--svgtree-node-border-color: var(--svgtree-node-hover-border-color);
}
.node-alert {
//it must be important because there is inline style in code that we must overwrite
fill: map_get($svgColors, dragAlertBg) !important;
stroke: map_get($svgColors, dragAlertBorder);
}
&-selected {
--svgtree-node-bg: var(--svgtree-node-selected-bg);
&#{$b}--nodrop {
.node-over {
//it must be important because there is inline style in code that we must overwrite
fill: map_get($svgColors, dragAlertBg) !important;
}
--svgtree-node-border-color: var(--svgtree-node-selected-border-color);
}
&#{$b}--ok-above {
.node-over {
//it must be important because there is inline style in code that we must overwrite
fill: map_get($svgColors, dragAboveBg) !important;
stroke: map_get($svgColors, dragAboveBorder);
}
}
&-focused {
--svgtree-node-bg: var(--svgtree-node-focus-bg) !important;
&#{$b}--ok-between {
.node-over {
//it must be important because there is inline style in code that we must overwrite
fill: map_get($svgColors, dragBetweenBg) !important;
stroke: map_get($svgColors, dragBetweenBorder);
--svgtree-node-border-color: var(--svgtree-node-focus-border-color) !important;
}
}
&#{$b}--ok-below {
.node-over {
//it must be important because there is inline style in code that we must overwrite
fill: map_get($svgColors, dragBelowBg) !important;
stroke: map_get($svgColors, dragBelowBorder);
}
}
.nodes-wrapper {
cursor: pointer;
&--dragging {
cursor: grabbing;
}
&--nodrop {
cursor: no-drop;
}
}
}
}
//node drag & drop tooltip
......@@ -200,27 +210,38 @@ $svgColors: (
display: none;
padding: 0;
margin: 0;
border: none;
background-color: map_get($svgColors, dragTooltipBg);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
border: 0;
color: var(--svgtree-drag-info-color);
background-color: var(--svgtree-drag-info-bg);
border-radius: var(--svgtree-drag-info-border-radius);
box-shadow: 0 2px 2px 2px rgba(0, 0, 0, 0.25);
z-index: 9999;
&--nodrop {
background-color: map_get($svgColors, dragTooltipAlertBg);
border: 1px solid map_get($svgColors, dragTooltipAlertBorder);
&__ctrl-icon {
position: absolute;
top: var(--svgtree-drag-info-padding-y);
left: var(--svgtree-drag-info-padding-x);
display: block;
width: var(--svgtree-drag-info-icon-size);
height: var(--svgtree-drag-info-icon-size);
background-color: transparent;
background-position: center;
background-repeat: no-repeat;
z-index: 1;
}
&__text {
display: flex;
padding: var(--svgtree-drag-info-padding-y) var(--svgtree-drag-info-padding-x);
gap: 0.25rem;
}
&--nodrop {
& .node-dd__ctrl-icon {
background-image: url(../Images/pagetree-drag-place-denied.png);
}
}
&__text {
display: table;
vertical-align: middle;
opacity: 0.85;
padding: 5px 5px 5px 20px;
}
&--ok-below {
&.node-dd--copy .node-dd__ctrl-icon {
background-image: url(../Images/pagetree-drag-copy-below.png);
......@@ -260,31 +281,6 @@ $svgColors: (
background-image: url(../Images/pagetree-drag-move-above.png);
}
}
&__icon {
display: table-cell;
vertical-align: top;
padding-left: 3px;
padding-right: 3px;
}
&__name {
display: table-cell;
vertical-align: top;
}
&__ctrl-icon {
position: absolute;
top: 3px;
left: 3px;
display: block;
width: 16px;
height: 16px;
background-color: transparent;
background-position: center;
background-repeat: no-repeat;
z-index: 1;
}
}
.nodes-drop-zone {
......@@ -293,18 +289,21 @@ $svgColors: (
}
rect {
fill: map_get($svgColors, dragAlertBorder);
fill: $danger;
cursor: grabbing;
}
text {
fill: color-contrast($danger);
pointer-events: none;
}
}
.node-name {
fill: var(--svgtree-node-color);
.node-highlight-text {
fill: map_get($svgColors, nodeHighlightText);
fill: var(--svgtree-highlight-color);
font-weight: 700;
}
}
......@@ -313,6 +312,9 @@ $svgColors: (
position: absolute;
top: 0;
left: 0;
padding: 0.25rem;
border: 1px solid tint-color($primary, 20%);
outline: none;
}
.scaffold-content .svg-toolbar {
......@@ -356,16 +358,18 @@ $svgColors: (
}
}
.node-stop {
fill: map_get($svgColors, dragAlertBorder);
}
//
// Info bar displayed above the tree if a page is mounted
//
.node-mount-point {
display: flex;
border: 0;
background-color: $info;
color: #fff;
padding: 1em;
background-color: var(--svgtree-info-bg);
border-bottom: 1px solid rgba(0, 0, 0, 0.25);
color: var(--svgtree-info-color);
padding: 0.75em 1.167em;
gap: 0.5em;
align-items: center;
&__icon {
flex: 0 auto;
......
......@@ -34,8 +34,12 @@ export class SelectTree extends SvgTree
readOnlyMode: false,
showIcons: true,
marginTop: 15,
nodeHeight: 20,
indentWidth: 16,
nodeHeight: 26,
icon: {
size: 16,
containerSize: 20,
},
indentWidth: 20,
width: 300,
duration: 400,
dataUrl: '',
......@@ -183,7 +187,11 @@ export class SelectTree extends SvgTree
})
.append('g')
.attr('class', 'tree-check')
.on('click', (evt: MouseEvent, node: TreeNode) => this.selectNode(node));
.on('click', (evt: MouseEvent, node: TreeNode) => {
this.selectNode(node);
this.focusNode(node);
this.updateVisibleNodes();
});
g.append('use')
.attr('x', 28)
......
......@@ -54,6 +54,7 @@ export class EditablePageTree extends PageTree {
public selectFirstNode(): void {
this.selectNode(this.nodes[0], true);
this.focusNode(this.nodes[0]);
}
public sendChangeCommand(data: any): void {
......@@ -99,10 +100,10 @@ export class EditablePageTree extends PageTree {
/**
* Make the DOM element of the node given as parameter focusable and focus it
*/
public switchFocusNode(node: TreeNode) {
public focusNode(node: TreeNode) {
// Focus node only if it's not currently in edit mode
if (!this.nodeIsEdit) {
this.switchFocus(this.getNodeElement(node));
super.focusNode(node);
}
}
......@@ -142,12 +143,14 @@ export class EditablePageTree extends PageTree {
.on('click', (event, node: TreeNode) => {
if (node.identifier === '0') {
this.selectNode(node, true);
this.focusNode(node);
return;
}
if (++clicks === 1) {
setTimeout(() => {
if (clicks === 1) {
this.selectNode(node, true);
this.focusNode(node);
} else {
this.editNodeLabel(node);
}
......@@ -189,18 +192,20 @@ export class EditablePageTree extends PageTree {
if (!node.allowEdit) {
return;
}
this.disableFocusedNodes();
node.focused = true
this.updateVisibleNodes();
this.removeEditedText();
this.nodeIsEdit = true;
d3selection.select(this.svg.node().parentNode as HTMLElement)
.append('input')
.attr('class', 'node-edit')
.style('top', () => {
const top = node.y + this.settings.marginTop;
return top + 'px';
})
.style('top', (node.y + this.settings.marginTop) + 'px')
.style('left', (node.x + this.textPosition + 5) + 'px')
.style('width', this.settings.width - (node.x + this.textPosition + 20) + 'px')
.style('width', 'calc(100% - ' + (node.x + this.textPosition + 5) + 'px)')
.style('height', this.settings.nodeHeight + 'px')
.attr('type', 'text')
.attr('value', node.name)
......@@ -222,6 +227,7 @@ export class EditablePageTree extends PageTree {
this.nodeIsEdit = false;
this.removeEditedText();
}
this.focusNode(node);
})
.on('blur', (evt: FocusEvent) => {
if (!this.nodeIsEdit) {
......@@ -235,6 +241,7 @@ export class EditablePageTree extends PageTree {
this.sendEditNodeLabelCommand(node);
}
this.removeEditedText();
this.focusNode(node);
})
.node()
.select();
......@@ -355,7 +362,7 @@ export class PageTreeNavigationComponent extends LitElement {
}
return html`
<div class="node-mount-point">
<div class="node-mount-point__icon"><typo3-backend-icon identifier="actions-document-info" size="small"></typo3-backend-icon></div>
<div class="node-mount-point__icon"><typo3-backend-icon identifier="actions-info-circle" size="small"></typo3-backend-icon></div>
<div class="node-mount-point__text">${this.mountPointPath}</div>
<div class="node-mount-point__icon mountpoint-close" @click="${() => this.unsetTemporaryMountPoint()}" title="${lll('labels.temporaryDBmount')}">
<typo3-backend-icon identifier="actions-close" size="small"></typo3-backend-icon>
......@@ -421,7 +428,7 @@ export class PageTreeNavigationComponent extends LitElement {
'tree',
'',
'',
this.tree.getNodeElement(node)
this.tree.getElementFromNode(node)
);
}
......@@ -694,6 +701,11 @@ class ToolbarDragHandler implements DragDropHandler {
const target = options.target;
let index = this.tree.nodes.indexOf(target);
const newNode = {} as TreeNode;
this.tree.disableFocusedNodes();
newNode.focused = true
this.tree.updateVisibleNodes();
newNode.command = 'new';
newNode.type = options.type;
newNode.identifier = '-1';
......@@ -742,9 +754,9 @@ class ToolbarDragHandler implements DragDropHandler {
d3selection.select(this.tree.svg.node().parentNode as HTMLElement)
.append('input')
.attr('class', 'node-edit')
.style('top', newNode.y + this.tree.settings.marginTop + 'px')
.style('left', newNode.x + this.tree.textPosition + 5 + 'px')
.style('width', this.tree.settings.width - (newNode.x + this.tree.textPosition + 20) + 'px')
.style('top', (newNode.y + this.tree.settings.marginTop) + 'px')
.style('left', (newNode.x + this.tree.textPosition + 5) + 'px')
.style('width', 'calc(100% - ' + (newNode.x + this.tree.textPosition + 5) + 'px)')
.style('height', this.tree.settings.nodeHeight + 'px')
.attr('text', 'text')
.attr('value', newNode.name)
......@@ -850,8 +862,8 @@ class PageTreeNodeDragHandler implements DragDropHandler {
this.dropZoneDelete.append('text')
.text(TYPO3.lang.deleteItem)
.attr('dx', 5)
.attr('dy', 15);
.attr('x', 5)
.attr('y', ((this.tree.settings.nodeHeight) / 2 ) + 4);
this.dropZoneDelete.node().dataset.open = 'false';
this.dropZoneDelete.node().style.transform = this.getDropZoneCloseTransform(node);
......@@ -1024,7 +1036,7 @@ class PageTreeNodeDragHandler implements DragDropHandler {
*/
private getDropZoneOpenTransform(node: TreeNode): string {
const svgWidth = parseFloat(this.tree.svg.style('width')) || 300;
return 'translate(' + (svgWidth - 58 - node.x) + 'px, -10px)';
return 'translate(' + (svgWidth - 58 - node.x) + 'px, ' + (this.tree.settings.nodeHeight / 2 * -1) + 'px)';
}
/**
......@@ -1032,7 +1044,7 @@ class PageTreeNodeDragHandler implements DragDropHandler {
*/
private getDropZoneCloseTransform(node: TreeNode): string {
const svgWidth = parseFloat(this.tree.svg.style('width')) || 300;
return 'translate(' + (svgWidth - node.x) + 'px, -10px)';
return 'translate(' + (svgWidth - node.x) + 'px, ' + (this.tree.settings.nodeHeight / 2 * -1) + 'px)';
}
/**
......
......@@ -54,19 +54,38 @@ export class PageTree extends SvgTree
public nodesUpdate(nodes: TreeNodeSelection): TreeNodeSelection {
nodes = super.nodesUpdate(nodes);
nodes
.append('text')
.text('+')
// append the stop element
let nodeStop = nodes
.append('svg')
.attr('class', 'node-stop')
.attr('dx', 30)
.attr('dy', 5)
.attr('y', (super.settings.icon.size / 2 * -1))
.attr('x', (super.settings.icon.size / 2 * -1))
.attr('height', super.settings.icon.size)
.attr('width', super.settings.icon.size)
.attr('visibility', (node: TreeNode) => node.stopPageTree && node.depth !== 0 ? 'visible' : 'hidden')
.on('click', (evt: MouseEvent, node: TreeNode) => {
document.dispatchEvent(new CustomEvent('typo3:pagetree:mountPoint', {detail: {pageId: parseInt(node.identifier, 10)}}));
});
nodeStop.append('rect')
.attr('height', super.settings.icon.size)
.attr('width', super.settings.icon.size)
.attr('fill', 'rgba(0,0,0,0)');
nodeStop.append('use')
.attr('transform-origin', '50% 50%')
.attr('href', '#icon-actions-caret-right');
return nodes;
}
protected getToggleVisibility(node: TreeNode): string {
if (node.stopPageTree && node.depth !== 0) {
return 'hidden';
}
return node.hasChildren ? 'visible' : 'hidden';
}
/**
* Loads child nodes via Ajax (used when expanding a collapsed node)
*/
......@@ -95,7 +114,7 @@ export class PageTree extends SvgTree
this.updateVisibleNodes();
this.nodesRemovePlaceholder();