Nested Scroll and Keyboard Behavior Conflicts in Interactive UI Components
Nested Scroll and Keyboard Behavior Conflicts in Interactive UI Components
The Challenge: Competing Event Handlers
When building interactive UI components with nested elements (like a zoomable canvas containing scrollable file nodes), we encountered conflicts where parent and child components competed for the same user input events. Specifically:
- Canvas zoom vs. file content scroll: Two-finger scroll gestures on Mac were always captured by the parent canvas for zooming, preventing scrolling within selected file nodes
- Group hover vs. file hover: When hovering over a file node inside a group, both the group and file hover states activated simultaneously, creating visual conflicts
- Event delegation hierarchy: The more specific/selected component should take precedence over parent components for user interactions
The Context: JSON Canvas UI Components
We were working with a JSON Canvas renderer built in Svelte with the following component hierarchy:
JSONCanvasRenderer.svelte- Parent canvas with zoom/pan functionalityJSONCanvasGroup.svelte- Group containers with hover effectsJSONCanvasFile.svelte- File nodes with scrollable content and hover effects
Incorrect Attempts and Why They Failed
Attempt 1: CSS pointer-events: none on Groups
css
.canvas-group.child-selected {
pointer-events: none;
} Why it failed: This disabled all interactions with the group, including the ability to select it, but didn't solve the scroll delegation issue.
Attempt 2: Always Preventing Default on Wheel Events
javascript
function handleWheel(e: WheelEvent) {
e.preventDefault(); // This always prevented scroll from reaching children
// ... zoom logic
} Why it failed: The
preventDefault() call blocked all scroll events from reaching child elements, making file content scrolling impossible.Attempt 3: CSS-only Hover State Management
css
.file-node:hover ~ .group-background {
/* Attempt to disable group hover when file is hovered */
} Why it failed: CSS sibling selectors don't work reliably with complex nested SVG structures and dynamic selection states.
The "Aha!" Moment
The breakthrough came when we realized we needed conditional event delegation based on:
- Selection state: When a child component is selected, it should have priority for relevant events
- Spatial awareness: Event handlers need to know if the mouse is over a selected child component
- Event flow control: Parent components should check if a child should handle the event before processing it themselves
The key insight was that we needed to conditionally prevent default rather than always preventing it, and use state-based CSS classes to manage hover conflicts.
Final Solution
1. Conditional Scroll Event Delegation
In
JSONCanvasRenderer.svelte: javascript
// Check if mouse is over a selected file node
function isMouseOverSelectedFile(mouseX: number, mouseY: number): boolean {
if (!selectedNodeId) return false;
const selectedNode = canvas.nodes.find(n => n.id === selectedNodeId);
if (!selectedNode || selectedNode.type !== 'file') return false;
// Convert mouse coordinates to canvas coordinates
const canvasX = (mouseX - translateX) / scale;
const canvasY = (mouseY - translateY) / scale;
// Check if mouse is within the selected file node bounds
const nodeLeft = selectedNode.x;
const nodeTop = selectedNode.y;
const nodeRight = selectedNode.x + (selectedNode.width || 200);
const nodeBottom = selectedNode.y + (selectedNode.height || 150);
return canvasX >= nodeLeft && canvasX <= nodeRight &&
canvasY >= nodeTop && canvasY <= nodeBottom;
}
// Modified wheel event handler
function handleWheel(e: WheelEvent) {
const rect = viewportElement.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// If mouse is over a selected file node, allow scroll to pass through
if (isMouseOverSelectedFile(mouseX, mouseY)) {
// Don't prevent default - let the scroll event reach the file content
return;
}
// Otherwise, handle as canvas zoom
e.preventDefault();
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
const newScale = Math.max(0.1, Math.min(3, scale * zoomFactor));
// Zoom towards mouse position
const scaleChange = newScale / scale;
translateX = mouseX - (mouseX - translateX) * scaleChange;
translateY = mouseY - (mouseY - translateY) * scaleChange;
scale = newScale;
updateTransform();
} 2. Child Selection Detection for Groups
In
JSONCanvasRenderer.svelte: javascript
// Check if any child nodes of a group are selected
function hasSelectedChild(groupNode: any): boolean {
if (!selectedNodeId || !groupNode || groupNode.type !== 'group') return false;
// Find nodes that are visually inside this group
const groupLeft = groupNode.x;
const groupTop = groupNode.y;
const groupRight = groupNode.x + (groupNode.width || 200);
const groupBottom = groupNode.y + (groupNode.height || 150);
return canvas.nodes.some(node => {
if (node.id === selectedNodeId && node.id !== groupNode.id) {
// Check if this selected node is within the group bounds
const nodeLeft = node.x;
const nodeTop = node.y;
const nodeRight = node.x + (node.width || 200);
const nodeBottom = node.y + (node.height || 150);
return nodeLeft >= groupLeft && nodeTop >= groupTop &&
nodeRight <= groupRight && nodeBottom <= groupBottom;
}
return false;
});
} Pass child selection state to group:
svelte
<JSONCanvasGroup
{node}
isSelected={selectedNodeId === node.id}
hasSelectedChild={hasSelectedChild(node)}
onClick={() => selectNode(node.id)}
onKeydown={(e) => e.key === 'Enter' || e.key === ' ' ? selectNode(node.id) : null}
/> 3. State-Based Hover Management
In
JSONCanvasGroup.svelte: javascript
export let node: GroupNode;
export let isSelected: boolean = false;
export let hasSelectedChild: boolean = false; // New prop
export let onClick: ((event: MouseEvent) => void) | undefined = undefined;
export let onKeydown: ((event: KeyboardEvent) => void) | undefined = undefined; Template with conditional classes:
svelte
<g
class="canvas-group"
class:selected={isSelected}
class:child-selected={hasSelectedChild}
role="group"
aria-label={node.label || 'Canvas group'}
tabindex="0"
on:click={handleClick}
on:keydown={handleKeydown}
transform="translate({x}, {y})"
>
<!-- Group content -->
</g> CSS for conditional hover behavior:
css
.canvas-group {
cursor: pointer;
transition: all 0.2s ease;
}
/* Only allow hover when no child is selected */
.canvas-group:hover:not(.child-selected) .group-background {
stroke: var(--clr-lossless-accent--brightest);
stroke-width: 2;
}
/* Disable hover when a child is selected */
.canvas-group.child-selected {
pointer-events: none;
}
/* Re-enable pointer events for child elements when group has child-selected */
.canvas-group.child-selected * {
pointer-events: auto;
} 4. Scrollable File Content
In
JSONCanvasFile.svelte: css
.file-content {
width: 100%;
height: 100%;
padding: 8px;
background: var(--clr-primary-bg);
border: 1px solid var(--clr-lossless-primary-glass--lighter);
border-radius: 6px;
overflow-y: auto;
overflow-x: hidden;
font-family: var(--ff-legible);
font-size: var(--fs-200);
line-height: 1.5;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
/* Custom scrollbar styling */
.file-content::-webkit-scrollbar {
width: 6px;
}
.file-content::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
.file-content::-webkit-scrollbar-thumb {
background: var(--clr-lossless-accent--brightest);
border-radius: 3px;
opacity: 0.7;
}
.file-content::-webkit-scrollbar-thumb:hover {
background: var(--clr-lossless-accent--bright);
opacity: 1;
} Key Learnings
- Event delegation hierarchy: More specific/selected components should take precedence over parent components for user interactions
- Conditional preventDefault(): Don't always prevent default on events - check if a child component should handle them first
- State-based CSS classes: Use component state to conditionally apply CSS rules rather than trying to solve everything with CSS selectors
- Spatial awareness: Event handlers need coordinate transformation logic to determine if events occur within specific component bounds
- Pointer events management: Use
pointer-events: nonestrategically withpointer-events: autoon children to create proper interaction hierarchies
Best Practices for Next Time
- Design event flow first: Before implementing interactions, map out which component should handle which events under different states
- Use coordinate transformation: Always convert mouse coordinates to the appropriate coordinate system when checking bounds
- Implement state communication: Parent components need to know about child selection states to make proper delegation decisions
- Test interaction combinations: Verify behavior when multiple interactive elements are nested and in different states
- Progressive enhancement: Start with basic functionality and layer on advanced interaction patterns
- Document event precedence: Clearly document which components have priority for different types of events
Browser Compatibility Notes
- The
pointer-eventsCSS property is well-supported in modern browsers - Webkit scrollbar styling (
-webkit-scrollbar-*) only works in Webkit-based browsers - Consider fallbacks for non-Webkit browsers if custom scrollbar styling is critical
- Touch event handling may need additional consideration for mobile devices