Fix touch-enabled Chromium highlight on tree nodes
This commit resolves issues with the touch highlight behavior on tree nodes in touch-enabled Chromium browsers (such as Google Chrome). The fix addresses two issues: 1. Dual color transition issue during tapping actions on tree nodes. 2. Not highlighting full visible width of the node on keyboard focus. Other changes include: - Create `InteractableNode.vue` to centralize click styling and logic. - Remove redundant click/hover/touch styling from `LeafTreeNode.vue` and `HierarchicalTreeNode.vue`.
This commit is contained in:
@@ -1,34 +1,34 @@
|
|||||||
@mixin hover-or-touch($selector-suffix: '', $selector-prefix: '&') {
|
@mixin hover-or-touch($selector-suffix: '', $selector-prefix: '&') {
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
|
/*
|
||||||
/* We only do this if hover is truly supported; otherwise the emulator in mobile
|
Only apply hover styles if the device truly supports hover; otherwise the
|
||||||
keeps hovered style in-place even after touching, making it sticky. */
|
emulator in mobile keeps hovered style in-place even after touching, making it sticky.
|
||||||
|
*/
|
||||||
#{$selector-prefix}:hover #{$selector-suffix} {
|
#{$selector-prefix}:hover #{$selector-suffix} {
|
||||||
@content;
|
@content;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (hover: none) {
|
@media (hover: none) {
|
||||||
|
/*
|
||||||
/* We only do this if hover is not supported,otherwise the desktop behavior is not
|
Apply active styles on touch or click, ensuring interactive feedback on devices without hover capability.
|
||||||
as desired; it does not get activated on hover but only during click/touch. */
|
*/
|
||||||
#{$selector-prefix}:active #{$selector-suffix} {
|
#{$selector-prefix}:active #{$selector-suffix} {
|
||||||
@content;
|
@content;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
This mixin removes the default blue tap highlight seen in mobile WebKit browsers (e.g., Chrome, Safari, Edge).
|
||||||
|
The mixin by itself may reduce accessibility by hiding this interactive cue. Therefore, it is recommended
|
||||||
|
to use this mixin in conjunction with the `hover-or-touch` mixin to provide necessary visual feedback
|
||||||
|
for interactive elements during hover or touch interactions.
|
||||||
|
*/
|
||||||
@mixin clickable($cursor: 'pointer') {
|
@mixin clickable($cursor: 'pointer') {
|
||||||
cursor: #{$cursor};
|
cursor: #{$cursor};
|
||||||
user-select: none;
|
user-select: none;
|
||||||
/*
|
-webkit-tap-highlight-color: transparent; // Removes blue tap highlight
|
||||||
It removes (blue) background during touch as seen in mobile webkit browsers (Chrome, Safari, Edge).
|
|
||||||
The default behavior is that any element (or containing element) that has cursor:pointer
|
|
||||||
explicitly set and is clicked will flash blue momentarily.
|
|
||||||
Removing it could have accessibility issue since that hides an interactive cue. But as we still provide
|
|
||||||
response to user actions through :active by `hover-or-touch` mixin.
|
|
||||||
*/
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin fade-transition($name) {
|
@mixin fade-transition($name) {
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="wrapper">
|
<div class="wrapper">
|
||||||
<div
|
<InteractableNode
|
||||||
class="expansible-node"
|
class="expansible-node"
|
||||||
:style="{
|
:style="{
|
||||||
'padding-left': `${currentNode.hierarchy.depthInTree * 24}px`,
|
'padding-left': `${currentNode.hierarchy.depthInTree * 24}px`,
|
||||||
}"
|
}"
|
||||||
|
:node-id="nodeId"
|
||||||
|
:tree-root="treeRoot"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="expand-collapse-arrow"
|
class="expand-collapse-arrow"
|
||||||
:class="{
|
:class="{
|
||||||
expanded: expanded,
|
expanded: isExpanded,
|
||||||
'has-children': hasChildren,
|
'has-children': hasChildren,
|
||||||
}"
|
}"
|
||||||
@click.stop="toggleExpand"
|
@click.stop="toggleExpand"
|
||||||
@@ -24,10 +26,10 @@
|
|||||||
</template>
|
</template>
|
||||||
</LeafTreeNode>
|
</LeafTreeNode>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</InteractableNode>
|
||||||
<transition name="children-transition">
|
<transition name="children-transition">
|
||||||
<ul
|
<ul
|
||||||
v-if="hasChildren && expanded"
|
v-if="hasChildren && isExpanded"
|
||||||
class="children"
|
class="children"
|
||||||
>
|
>
|
||||||
<HierarchicalTreeNode
|
<HierarchicalTreeNode
|
||||||
@@ -54,12 +56,14 @@ import { NodeRenderingStrategy } from '../Rendering/Scheduling/NodeRenderingStra
|
|||||||
import { useNodeState } from './UseNodeState';
|
import { useNodeState } from './UseNodeState';
|
||||||
import { TreeNode } from './TreeNode';
|
import { TreeNode } from './TreeNode';
|
||||||
import LeafTreeNode from './LeafTreeNode.vue';
|
import LeafTreeNode from './LeafTreeNode.vue';
|
||||||
|
import InteractableNode from './InteractableNode.vue';
|
||||||
import type { PropType } from 'vue';
|
import type { PropType } from 'vue';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'HierarchicalTreeNode', // Needed due to recursion
|
name: 'HierarchicalTreeNode', // Needed due to recursion
|
||||||
components: {
|
components: {
|
||||||
LeafTreeNode,
|
LeafTreeNode,
|
||||||
|
InteractableNode,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
nodeId: {
|
nodeId: {
|
||||||
@@ -82,7 +86,7 @@ export default defineComponent({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { state } = useNodeState(currentNode);
|
const { state } = useNodeState(currentNode);
|
||||||
const expanded = computed<boolean>(() => state.value.isExpanded);
|
const isExpanded = computed<boolean>(() => state.value.isExpanded);
|
||||||
|
|
||||||
const renderedNodeIds = computed<readonly string[]>(
|
const renderedNodeIds = computed<readonly string[]>(
|
||||||
() => currentNode.value
|
() => currentNode.value
|
||||||
@@ -96,18 +100,13 @@ export default defineComponent({
|
|||||||
currentNode.value.state.toggleExpand();
|
currentNode.value.state.toggleExpand();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleCheck() {
|
|
||||||
currentNode.value.state.toggleCheck();
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasChildren = computed<boolean>(
|
const hasChildren = computed<boolean>(
|
||||||
() => currentNode.value.hierarchy.isBranchNode,
|
() => currentNode.value.hierarchy.isBranchNode,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
renderedNodeIds,
|
renderedNodeIds,
|
||||||
expanded,
|
isExpanded,
|
||||||
toggleCheck,
|
|
||||||
toggleExpand,
|
toggleExpand,
|
||||||
currentNode,
|
currentNode,
|
||||||
hasChildren,
|
hasChildren,
|
||||||
@@ -123,7 +122,6 @@ export default defineComponent({
|
|||||||
.wrapper {
|
.wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
.children {
|
.children {
|
||||||
@include reset-ul;
|
@include reset-ul;
|
||||||
@@ -140,16 +138,15 @@ export default defineComponent({
|
|||||||
|
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@include hover-or-touch {
|
|
||||||
background: $color-node-highlight-bg;
|
|
||||||
}
|
|
||||||
.expand-collapse-arrow {
|
.expand-collapse-arrow {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
cursor: pointer;
|
|
||||||
margin-left: 30px;
|
margin-left: 30px;
|
||||||
width: 0;
|
width: 0;
|
||||||
|
|
||||||
|
@include clickable;
|
||||||
|
|
||||||
&:after {
|
&:after {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="clickable-node focusable-node"
|
||||||
|
tabindex="-1"
|
||||||
|
:class="{
|
||||||
|
'keyboard-focus': hasKeyboardFocus,
|
||||||
|
}"
|
||||||
|
@click.stop="toggleCheckState"
|
||||||
|
@focus="onNodeFocus"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, computed, toRef } from 'vue';
|
||||||
|
import { TreeRoot } from '../TreeRoot/TreeRoot';
|
||||||
|
import { useCurrentTreeNodes } from '../UseCurrentTreeNodes';
|
||||||
|
import { useNodeState } from './UseNodeState';
|
||||||
|
import { useKeyboardInteractionState } from './UseKeyboardInteractionState';
|
||||||
|
import { TreeNode } from './TreeNode';
|
||||||
|
import type { PropType } from 'vue';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
nodeId: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
treeRoot: {
|
||||||
|
type: Object as PropType<TreeRoot>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const { isKeyboardBeingUsed } = useKeyboardInteractionState();
|
||||||
|
const { nodes } = useCurrentTreeNodes(toRef(props, 'treeRoot'));
|
||||||
|
const currentNode = computed<TreeNode>(() => nodes.value.getNodeById(props.nodeId));
|
||||||
|
const { state } = useNodeState(currentNode);
|
||||||
|
|
||||||
|
const hasKeyboardFocus = computed<boolean>(() => {
|
||||||
|
if (!isKeyboardBeingUsed.value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return state.value.isFocused;
|
||||||
|
});
|
||||||
|
|
||||||
|
const onNodeFocus = () => {
|
||||||
|
props.treeRoot.focus.setSingleFocus(currentNode.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
function toggleCheckState() {
|
||||||
|
currentNode.value.state.toggleCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
onNodeFocus,
|
||||||
|
toggleCheckState,
|
||||||
|
currentNode,
|
||||||
|
hasKeyboardFocus,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@use "@/presentation/assets/styles/main" as *;
|
||||||
|
@use "./../tree-colors" as *;
|
||||||
|
|
||||||
|
.clickable-node {
|
||||||
|
@include clickable;
|
||||||
|
@include hover-or-touch {
|
||||||
|
background: $color-node-highlight-bg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.focusable-node {
|
||||||
|
outline: none; // We handle keyboard focus through own styling
|
||||||
|
&.keyboard-focus {
|
||||||
|
background: $color-node-highlight-bg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,27 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<li
|
<li>
|
||||||
class="node focusable"
|
<InteractableNode
|
||||||
tabindex="-1"
|
:node-id="nodeId"
|
||||||
:class="{
|
:tree-root="treeRoot"
|
||||||
'keyboard-focus': hasKeyboardFocus,
|
class="node"
|
||||||
}"
|
>
|
||||||
@click.stop="toggleCheckState"
|
<div class="node__layout">
|
||||||
@focus="onNodeFocus"
|
<div class="node__checkbox">
|
||||||
>
|
<NodeCheckbox
|
||||||
<div class="node__layout">
|
:node-id="nodeId"
|
||||||
<div class="node__checkbox">
|
:tree-root="treeRoot"
|
||||||
<NodeCheckbox
|
/>
|
||||||
:node-id="nodeId"
|
</div>
|
||||||
:tree-root="treeRoot"
|
<div class="node__content content">
|
||||||
/>
|
<slot
|
||||||
|
name="node-content"
|
||||||
|
:node-metadata="currentNode.metadata"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="node__content content">
|
</InteractableNode>
|
||||||
<slot
|
|
||||||
name="node-content"
|
|
||||||
:node-metadata="currentNode.metadata"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -29,15 +27,15 @@
|
|||||||
import { defineComponent, computed, toRef } from 'vue';
|
import { defineComponent, computed, toRef } from 'vue';
|
||||||
import { TreeRoot } from '../TreeRoot/TreeRoot';
|
import { TreeRoot } from '../TreeRoot/TreeRoot';
|
||||||
import { useCurrentTreeNodes } from '../UseCurrentTreeNodes';
|
import { useCurrentTreeNodes } from '../UseCurrentTreeNodes';
|
||||||
import { useNodeState } from './UseNodeState';
|
|
||||||
import { useKeyboardInteractionState } from './UseKeyboardInteractionState';
|
|
||||||
import { TreeNode } from './TreeNode';
|
import { TreeNode } from './TreeNode';
|
||||||
import NodeCheckbox from './NodeCheckbox.vue';
|
import NodeCheckbox from './NodeCheckbox.vue';
|
||||||
|
import InteractableNode from './InteractableNode.vue';
|
||||||
import type { PropType } from 'vue';
|
import type { PropType } from 'vue';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
components: {
|
components: {
|
||||||
NodeCheckbox,
|
NodeCheckbox,
|
||||||
|
InteractableNode,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
nodeId: {
|
nodeId: {
|
||||||
@@ -50,31 +48,11 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const { isKeyboardBeingUsed } = useKeyboardInteractionState();
|
|
||||||
const { nodes } = useCurrentTreeNodes(toRef(props, 'treeRoot'));
|
const { nodes } = useCurrentTreeNodes(toRef(props, 'treeRoot'));
|
||||||
const currentNode = computed<TreeNode>(() => nodes.value.getNodeById(props.nodeId));
|
const currentNode = computed<TreeNode>(() => nodes.value.getNodeById(props.nodeId));
|
||||||
const { state } = useNodeState(currentNode);
|
|
||||||
|
|
||||||
const hasKeyboardFocus = computed<boolean>(() => {
|
|
||||||
if (!isKeyboardBeingUsed.value) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return state.value.isFocused;
|
|
||||||
});
|
|
||||||
|
|
||||||
const onNodeFocus = () => {
|
|
||||||
props.treeRoot.focus.setSingleFocus(currentNode.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
function toggleCheckState() {
|
|
||||||
currentNode.value.state.toggleCheck();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onNodeFocus,
|
|
||||||
toggleCheckState,
|
|
||||||
currentNode,
|
currentNode,
|
||||||
hasKeyboardFocus,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -97,27 +75,14 @@ export default defineComponent({
|
|||||||
overflow: auto; // Prevents horizontal expansion of inner content (e.g., when a code block is shown)
|
overflow: auto; // Prevents horizontal expansion of inner content (e.g., when a code block is shown)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.focusable {
|
|
||||||
outline: none; // We handle keyboard focus through own styling
|
|
||||||
}
|
|
||||||
.node {
|
.node {
|
||||||
margin-bottom: 3px;
|
margin-bottom: 3px;
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
padding-bottom: 3px;
|
padding-bottom: 3px;
|
||||||
padding-top: 3px;
|
padding-top: 3px;
|
||||||
padding-right: 6px;
|
padding-right: 6px;
|
||||||
cursor: pointer;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
&.keyboard-focus {
|
|
||||||
background: $color-node-highlight-bg;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include hover-or-touch {
|
|
||||||
background: $color-node-highlight-bg;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
display: flex; // We could provide `block`, but `flex` is more versatile.
|
display: flex; // We could provide `block`, but `flex` is more versatile.
|
||||||
color: $color-node-fg;
|
color: $color-node-fg;
|
||||||
|
|||||||
Reference in New Issue
Block a user