From 9623f138a786bfd6eec744226e89538886dbbde5 Mon Sep 17 00:00:00 2001 From: KaiUweCZE Date: Thu, 4 Sep 2025 02:41:23 +0200 Subject: [PATCH 1/5] refactor(ui/tree-view): reorganize tree-view tokens to follow contribution standards --- libs/ui/src/molecules/tree-view.tsx | 38 +++++++------- .../components/molecules/_tree-view.css | 49 +++++++++---------- .../stories/molecules/tree-view.stories.tsx | 10 +--- 3 files changed, 44 insertions(+), 53 deletions(-) diff --git a/libs/ui/src/molecules/tree-view.tsx b/libs/ui/src/molecules/tree-view.tsx index af189a9ec9..ee908d9744 100644 --- a/libs/ui/src/molecules/tree-view.tsx +++ b/libs/ui/src/molecules/tree-view.tsx @@ -25,28 +25,31 @@ const treeVariants = tv({ label: ['text-tree-label-fg font-tree-label'], tree: [ 'outline-none bg-tree-bg', - - 'focus-visible:ring-2 focus-visible:ring-tree-node-focus focus-visible:ring-offset-2', + 'focus-visible:ring-2 focus-visible:ring-tree-node-ring focus-visible:ring-offset-2', ], branch: [ 'data-[disabled]:opacity-tree-disabled data-[disabled]:pointer-events-none', ], - branchControl: [], + branchTrigger: [ + 'flex items-center justify-between', + 'hover:bg-tree-node-bg-hover hover:text-tree-fg-hover', + 'cursor-pointer rounded-tree-node', + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-tree-node-ring', + 'data-[selected]:bg-tree-node-bg-selected data-[selected]:text-tree-fg-selected data-[selected]:font-tree-selected', + ], + branchControl: ['flex-1'], branchText: ['flex-1 text-tree-size'], branchIndicator: [ - 'data-[state=open]:token-icon-tree-indicator-open cursor-pointer hover:scale-120', + 'data-[state=open]:token-icon-tree-indicator-open cursor-pointer hover:scale-125', ], branchContent: ['relative', 'data-[state=closed]:hidden'], branchIndentGuide: [ - 'absolute top-0 bottom-0 left-1', - 'w-tree-indent bg-tree-indent', + 'absolute top-0 bottom-0 start-1', + 'w-tree-indent bg-tree-indent-bg', 'opacity-tree-indent', ], leaf: [], - nodeIcon: [ - //"flex-shrink-0", - 'text-tree-icon hover:text-tree-icon-hover', - ], + nodeIcon: ['text-tree-icon hover:text-tree-icon-hover'], }, compoundSlots: [ { @@ -55,19 +58,17 @@ const treeVariants = tv({ class: [ 'relative', // get --depth from zag-js api - 'ml-[calc(var(--depth)*theme(tree-indent-per-level))]', - 'data-[depth=1]:ml-0', + 'ms-[calc(var(--depth)*theme(tree-indent-per-level))]', + 'data-[depth=1]:ms-0', ], }, { slots: ['branchControl', 'leaf'], class: [ 'flex items-center gap-tree-icon p-tree-node', - 'cursor-pointer rounded-tree-node', - 'hover:bg-tree-node-hover hover:text-tree-fg-hover hover:font-tree-selected', - 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-tree-node-focus', - 'data-[selected]:bg-tree-node-selected data-[selected]:text-tree-fg-selected data-[selected]:font-tree-selected', - 'data-[disabled]:cursor-not-allowed data-[disabled]:hover:bg-transparent', + 'cursor-pointer', + // 'hover:bg-tree-node-bg-hover hover:text-tree-fg-hover', + 'data-[disabled]:cursor-not-allowed', ], }, ], @@ -99,6 +100,7 @@ function TreeNode({ branchIndentGuide, leaf, nodeIcon, + branchTrigger, } = treeVariants() const handleToggle = (id: string) => { @@ -112,7 +114,7 @@ function TreeNode({ if (nodeState.isBranch) { return (
-
+
{ From a1940436a8d567601dcda46bb95d842d0fb0f0e1 Mon Sep 17 00:00:00 2001 From: Petr Glaser Date: Mon, 8 Sep 2025 01:37:04 +0200 Subject: [PATCH 2/5] fix: highlight selected leaf --- libs/ui/src/molecules/tree-view.tsx | 4 +++- libs/ui/src/tokens/components/molecules/_tree-view.css | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/libs/ui/src/molecules/tree-view.tsx b/libs/ui/src/molecules/tree-view.tsx index f35f666eb5..47e0bc8ecb 100644 --- a/libs/ui/src/molecules/tree-view.tsx +++ b/libs/ui/src/molecules/tree-view.tsx @@ -48,7 +48,9 @@ const treeVariants = tv({ 'w-tree-indent bg-tree-indent-bg', 'opacity-tree-indent', ], - leaf: [], + leaf: [ + 'data-[selected]:bg-tree-node-bg-selected data-[selected]:text-tree-fg-selected', + ], nodeIcon: ['text-tree-icon hover:text-tree-icon-hover'], }, compoundSlots: [ diff --git a/libs/ui/src/tokens/components/molecules/_tree-view.css b/libs/ui/src/tokens/components/molecules/_tree-view.css index 77038a2bdc..8b5812700b 100644 --- a/libs/ui/src/tokens/components/molecules/_tree-view.css +++ b/libs/ui/src/tokens/components/molecules/_tree-view.css @@ -3,7 +3,7 @@ --color-tree-bg: transparent; --color-tree-fg: var(--color-fg-primary); --color-tree-border: var(--color-border-primary); - --color-tree-icon: current; + --color-tree-icon: currentColor; --color-tree-icon-hover: var(--color-fg-primary); /* === STATE VARIATIONS === */ From c901ea85365650786b0886c9da6e13123786c74d Mon Sep 17 00:00:00 2001 From: KaiUweCZE Date: Wed, 10 Sep 2025 12:49:05 +0200 Subject: [PATCH 3/5] refactor(ui/tree):refactor ui --- libs/ui/src/molecules/tree-view.tsx | 56 +++++++-- .../components/molecules/_tree-view.css | 25 ++-- .../stories/molecules/tree-view.stories.tsx | 119 ++++++++++++++++++ 3 files changed, 180 insertions(+), 20 deletions(-) diff --git a/libs/ui/src/molecules/tree-view.tsx b/libs/ui/src/molecules/tree-view.tsx index 47e0bc8ecb..cec1d92eb3 100644 --- a/libs/ui/src/molecules/tree-view.tsx +++ b/libs/ui/src/molecules/tree-view.tsx @@ -21,7 +21,7 @@ export interface TreeNode { // === COMPONENT VARIANTS === const treeVariants = tv({ slots: { - root: 'relative bg-tree-root-bg', + root: 'relative bg-tree-root-bg rounded-tree', label: ['text-tree-label-fg font-tree-label'], tree: [ 'outline-none bg-tree-bg', @@ -31,15 +31,16 @@ const treeVariants = tv({ 'data-[disabled]:opacity-tree-disabled data-[disabled]:pointer-events-none', ], branchTrigger: [ - 'flex items-center justify-between', - 'hover:bg-tree-node-bg-hover hover:text-tree-fg-hover', - 'cursor-pointer rounded-tree-node', - 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-tree-node-ring', - 'data-[selected]:bg-tree-node-bg-selected data-[selected]:text-tree-fg-selected data-[selected]:font-tree-selected', + 'group flex items-center justify-between', + 'hover:bg-tree-node-bg-hover', + 'cursor-pointer', + //'has-[[data-selected]]:bg-tree-node-bg-selected', + 'has-focus-visible:outline-none has-focus-visible:ring-2 has-focus-visible:ring-tree-node-ring', ], branchControl: ['flex-1'], - branchText: ['flex-1 text-tree-size'], + branchText: ['flex-1'], branchIndicator: [ + 'group-hover:text-tree-fg-hover', 'data-[state=open]:token-icon-tree-indicator-open cursor-pointer hover:scale-125', ], branchContent: ['relative', 'data-[state=closed]:hidden'], @@ -49,9 +50,12 @@ const treeVariants = tv({ 'opacity-tree-indent', ], leaf: [ - 'data-[selected]:bg-tree-node-bg-selected data-[selected]:text-tree-fg-selected', + 'hover:bg-tree-node-bg-hover hover:text-tree-fg-hover', + 'data-[selected]:hover:bg-tree-node-bg-hover', + 'data-[selected]:hover:text-tree-fg-hover', + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-tree-node-ring', ], - nodeIcon: ['text-tree-icon hover:text-tree-icon-hover'], + nodeIcon: ['hover:text-tree-icon-hover'], }, compoundSlots: [ { @@ -69,11 +73,39 @@ const treeVariants = tv({ class: [ 'flex items-center gap-tree-icon p-tree-node', 'cursor-pointer', - // 'hover:bg-tree-node-bg-hover hover:text-tree-fg-hover', 'data-[disabled]:cursor-not-allowed', + 'data-[selected]:text-tree-fg-selected', + 'group-hover:text-tree-fg-hover', + 'data-[selected]:group-hover:text-tree-fg-hover', + 'focus-visible:outline-none', ], }, ], + variants: { + size: { + sm: { + nodeIcon: 'text-tree-icon-sm', + branchText: 'text-tree-sm', + branchIndicator: 'text-tree-sm', + label: 'text-tree-sm', + }, + md: { + nodeIcon: 'text-tree-icon-md', + branchText: 'text-tree-md', + branchIndicator: 'text-lg', + label: 'text-tree-md', + }, + lg: { + nodeIcon: 'text-tree-icon-lg', + branchText: 'text-tree-lg', + branchIndicator: 'text-tree-lg', + label: 'text-tree-lg', + }, + }, + }, + defaultVariants: { + size: 'md', + }, }) // === TREE NODE COMPONENT === @@ -184,7 +216,9 @@ function TreeNode({ className={nodeIcon()} /> )} - {node.name} + + {node.name} +
) } diff --git a/libs/ui/src/tokens/components/molecules/_tree-view.css b/libs/ui/src/tokens/components/molecules/_tree-view.css index 8b5812700b..3c030b44ac 100644 --- a/libs/ui/src/tokens/components/molecules/_tree-view.css +++ b/libs/ui/src/tokens/components/molecules/_tree-view.css @@ -1,16 +1,16 @@ @theme static { /* === BASE TOKEN MAPPING === */ - --color-tree-bg: transparent; + --color-tree-bg: var(--color-base); --color-tree-fg: var(--color-fg-primary); --color-tree-border: var(--color-border-primary); - --color-tree-icon: currentColor; --color-tree-icon-hover: var(--color-fg-primary); + --color-tree-accent: var(--color-primary); /* === STATE VARIATIONS === */ --color-tree-node-bg-hover: var(--color-primary-hover); - --color-tree-node-bg-selected: var(--color-primary-active); + --color-tree-node-bg-selected: transparent; --color-tree-fg-hover: var(--color-fg-reverse); - --color-tree-fg-selected: var(--color-fg-reverse); + --color-tree-fg-selected: var(--color-tree-accent); /* === ROOT === */ --color-tree-root-bg: transparent; @@ -34,14 +34,21 @@ --padding-tree-node: var(--spacing-100); /* === TYPOGRAPHY === */ - --text-tree-size: var(--text-sm); - --text-tree-icon: var(--text-icon-sm); + /* Tree text size */ + --text-tree-sm: var(--text-sm); + --text-tree-md: var(--text-md); + --text-tree-lg: var(--text-lg); + + /* Node icon size */ + --text-tree-icon-sm: var(--text-icon-sm); + --text-tree-icon-md: var(--text-icon-md); + --text-tree-icon-lg: var(--text-icon-lg); + + /* Tree font weight */ --font-weight-tree-label: var(--font-weight-medium); - --font-weight-tree-selected: var(--font-weight-normal); /* === BORDERS & RADIUS === */ - --radius-tree-node: var(--radius-sm); - + --radius-tree: 0; /* === OPACITY === */ --opacity-tree-indent: 100%; } diff --git a/libs/ui/stories/molecules/tree-view.stories.tsx b/libs/ui/stories/molecules/tree-view.stories.tsx index 5bc6a66ee7..fd975c8863 100644 --- a/libs/ui/stories/molecules/tree-view.stories.tsx +++ b/libs/ui/stories/molecules/tree-view.stories.tsx @@ -469,3 +469,122 @@ export const TypeaheadDebugDemo: Story = { ) }, } + +// Test story pro ověření správného chování expand/collapse a selection +export const ExpandVsSelectionTest: Story = { + render: () => { + const [logs, setLogs] = useState([]) + + const addLog = (message: string) => { + const timestamp = new Date().toLocaleTimeString('cs-CZ', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + fractional: 3 + }) + setLogs(prev => [`[${timestamp}] ${message}`, ...prev].slice(0, 10)) + } + + const testData: TreeNode[] = [ + { + id: 'folder1', + name: 'Folder 1 (klikni na chevron nebo na název)', + children: [ + { id: 'file1', name: 'File 1.txt' }, + { id: 'file2', name: 'File 2.txt' }, + { + id: 'subfolder1', + name: 'Subfolder 1', + children: [ + { id: 'file3', name: 'File 3.txt' }, + ] + } + ] + }, + { + id: 'folder2', + name: 'Folder 2', + children: [ + { id: 'file4', name: 'File 4.txt' }, + { id: 'file5', name: 'File 5.txt' }, + ] + } + ] + + return ( +
+
+

Test instrukce:

+
    +
  • • Klikni na chevron (šipku) → mělo by se POUZE expandovat/collapsovat
  • +
  • • Klikni na název složky/souboru → mělo by se vybrat (+ expandovat pokud expandOnClick=true)
  • +
  • • Zkus klávesy: Space/Enter na vybraném prvku
  • +
+
+ +
+
+

expandOnClick = false

+
+ { + addLog(`🔽 EXPANDED: ${details.expandedValue.join(', ') || 'none'}`) + }} + onSelectionChange={(details) => { + addLog(`✅ SELECTED: ${details.selectedValue.join(', ') || 'none'}`) + }} + /> +
+
+ +
+

expandOnClick = true (default)

+
+ { + addLog(`🔽 EXPANDED: ${details.expandedValue.join(', ') || 'none'}`) + }} + onSelectionChange={(details) => { + addLog(`✅ SELECTED: ${details.selectedValue.join(', ') || 'none'}`) + }} + /> +
+
+
+ +
+

Event Log (posledních 10):

+
+ {logs.length === 0 ? ( +
Čekám na interakci...
+ ) : ( + logs.map((log, index) => ( +
+ {log} +
+ )) + )} +
+
+
+ ) + }, + parameters: { + docs: { + description: { + story: 'Test story pro ověření správného oddělení expand/collapse a selection logiky. Chevron by měl pouze expandovat, klik na node by měl vybírat.', + }, + }, + }, +} From 22211b25015d98c130aebf3fa4e8c7f1d59ad59a Mon Sep 17 00:00:00 2001 From: KaiUweCZE Date: Wed, 10 Sep 2025 13:04:22 +0200 Subject: [PATCH 4/5] feat(ui/tree-view): implement configurable selection behavior Addresses PR #126 review feedback requesting selection control: - Add selectionBehavior prop ('all' | 'leaf-only' | 'custom') - Support per-node selectable property in custom mode - Keep chevron click handler for expand-only action - Add comprehensive test story with all selection modes --- libs/ui/src/molecules/tree-view.tsx | 52 ++++++- .../stories/molecules/tree-view.stories.tsx | 143 +++++++++++++++++- 2 files changed, 189 insertions(+), 6 deletions(-) diff --git a/libs/ui/src/molecules/tree-view.tsx b/libs/ui/src/molecules/tree-view.tsx index cec1d92eb3..bf99f54b69 100644 --- a/libs/ui/src/molecules/tree-view.tsx +++ b/libs/ui/src/molecules/tree-view.tsx @@ -15,6 +15,7 @@ export interface TreeNode { } disabled?: boolean selected?: boolean + selectable?: boolean // For 'custom' selection behavior [key: string]: unknown } @@ -113,6 +114,7 @@ interface TreeNodeProps extends tree.NodeProps { api: tree.Api showIndentGuides?: boolean showNodeIcons?: boolean + selectionBehavior?: 'all' | 'leaf-only' | 'custom' } function TreeNode({ @@ -121,10 +123,25 @@ function TreeNode({ api, showIndentGuides = true, showNodeIcons = true, + selectionBehavior = 'all', }: TreeNodeProps) { const nodeProps = { indexPath, node } const nodeState = api.getNodeState(nodeProps) + // Determine if this node can be selected based on selectionBehavior + const isSelectable = (() => { + switch (selectionBehavior) { + case 'all': + return true + case 'leaf-only': + return !nodeState.isBranch + case 'custom': + return node.selectable !== false // Default to true if not specified + default: + return true + } + })() + const { branch, branchControl, @@ -146,13 +163,26 @@ function TreeNode({ } if (nodeState.isBranch) { + // Get the base props + const baseControlProps = api.getBranchControlProps(nodeProps) + + // Override onClick if not selectable + const branchControlProps = isSelectable + ? baseControlProps + : { + ...baseControlProps, + onClick: (event: MouseEvent) => { + // Only handle expand/collapse, not selection + if (nodeState.disabled || nodeState.loading) return + event.stopPropagation() + handleToggle(node.id) + }, + } + return (
-
+
{showNodeIcons && ( ))}
@@ -208,8 +239,16 @@ function TreeNode({ ) } + // For leaf nodes, conditionally apply selection props + const leafProps = isSelectable + ? api.getItemProps(nodeProps) + : { + ...api.getItemProps(nodeProps), + onClick: undefined, // Remove selection click handler if not selectable + } + return ( -
+
{showNodeIcons && ( , tree.Props { label?: string showIndentGuides?: boolean showNodeIcons?: boolean + selectionBehavior?: 'all' | 'leaf-only' | 'custom' } export function TreeView({ @@ -237,6 +277,7 @@ export function TreeView({ label, showIndentGuides = true, showNodeIcons = true, + selectionBehavior = 'all', dir = 'ltr', selectionMode = 'single', @@ -305,6 +346,7 @@ export function TreeView({ api={api} showIndentGuides={showIndentGuides} showNodeIcons={showNodeIcons} + selectionBehavior={selectionBehavior} /> ))}
diff --git a/libs/ui/stories/molecules/tree-view.stories.tsx b/libs/ui/stories/molecules/tree-view.stories.tsx index fd975c8863..e1deb3b3ab 100644 --- a/libs/ui/stories/molecules/tree-view.stories.tsx +++ b/libs/ui/stories/molecules/tree-view.stories.tsx @@ -248,7 +248,7 @@ export const SelectionModes: Story = { />
-

Multiple Selection

+

Multiple Selection (ctrl + click)

{ + const [selectedNodes, setSelectedNodes] = useState>({ + all: [], + leafOnly: [], + custom: [] + }) + + const testData: TreeNode[] = [ + { + id: 'folder1', + name: 'Folder 1 (Branch)', + children: [ + { id: 'file1', name: 'File 1.txt (Leaf)' }, + { id: 'file2', name: 'File 2.txt (Leaf)' }, + { + id: 'subfolder1', + name: 'Subfolder (Branch)', + children: [ + { id: 'file3', name: 'File 3.txt (Leaf)' }, + ] + } + ] + }, + { + id: 'folder2', + name: 'Folder 2 (Branch)', + children: [ + { id: 'file4', name: 'File 4.txt (Leaf)' }, + ] + } + ] + + const customData: TreeNode[] = [ + { + id: 'selectable-folder', + name: '✅ Selectable Folder', + selectable: true, + children: [ + { id: 'selectable-file', name: '✅ Selectable File', selectable: true }, + { id: 'non-selectable-file', name: '❌ Non-selectable File', selectable: false }, + ] + }, + { + id: 'non-selectable-folder', + name: '❌ Non-selectable Folder', + selectable: false, + children: [ + { id: 'file5', name: '✅ File (default selectable)', selectable: undefined }, + ] + } + ] + + return ( +
+
+

Selection Behavior Modes:

+

+ Click on folders and files to see how different selection behaviors work. +

+
+ +
+ {/* All Mode */} +
+

All (Default)

+

Both branches and leaves can be selected

+
+ { + setSelectedNodes(prev => ({ ...prev, all: details.selectedValue })) + }} + defaultExpandedValue={['folder1']} + /> +
+
+ Selected: {selectedNodes.all.join(', ') || 'None'} +
+
+ + {/* Leaf Only Mode */} +
+

Leaf Only

+

Only files (leaves) can be selected

+
+ { + setSelectedNodes(prev => ({ ...prev, leafOnly: details.selectedValue })) + }} + defaultExpandedValue={['folder1']} + /> +
+
+ Selected: {selectedNodes.leafOnly.join(', ') || 'None'} +
+
+ + {/* Custom Mode */} +
+

Custom

+

Per-node selectable property

+
+ { + setSelectedNodes(prev => ({ ...prev, custom: details.selectedValue })) + }} + defaultExpandedValue={['selectable-folder', 'non-selectable-folder']} + /> +
+
+ Selected: {selectedNodes.custom.join(', ') || 'None'} +
+
+
+
+ ) + }, + parameters: { + docs: { + description: { + story: 'Demonstrates different selection behaviors: all (default), leaf-only, and custom per-node control.', + }, + }, + }, +} + export const ExpandVsSelectionTest: Story = { render: () => { const [logs, setLogs] = useState([]) From 3a57f5e690ff0209f66c4d3dd90b9f44730b46b9 Mon Sep 17 00:00:00 2001 From: KaiUweCZE Date: Thu, 11 Sep 2025 07:05:26 +0200 Subject: [PATCH 5/5] fix(ui/tree-view): prevent keyboard selection on non-selectable nodes - Add helper function to properly block selection keys (Enter/Space) while preserving navigation - Apply consistent behavior for both branch and leaf non-selectable nodes - Allow Enter key for expand/collapse on branches even when not selectable - Fix size variants for branch indicator to use consistent token naming - Add missing indicator size tokens in CSS - Remove commented-out code Addresses CodeRabbit review: keyboard handlers must be explicitly disabled for non-selectable nodes to prevent selection via keyboard shortcuts --- libs/ui/src/molecules/tree-view.tsx | 92 +++++++++++++++---- .../components/molecules/_tree-view.css | 4 + 2 files changed, 77 insertions(+), 19 deletions(-) diff --git a/libs/ui/src/molecules/tree-view.tsx b/libs/ui/src/molecules/tree-view.tsx index bf99f54b69..7fd1804c01 100644 --- a/libs/ui/src/molecules/tree-view.tsx +++ b/libs/ui/src/molecules/tree-view.tsx @@ -35,7 +35,6 @@ const treeVariants = tv({ 'group flex items-center justify-between', 'hover:bg-tree-node-bg-hover', 'cursor-pointer', - //'has-[[data-selected]]:bg-tree-node-bg-selected', 'has-focus-visible:outline-none has-focus-visible:ring-2 has-focus-visible:ring-tree-node-ring', ], branchControl: ['flex-1'], @@ -87,19 +86,19 @@ const treeVariants = tv({ sm: { nodeIcon: 'text-tree-icon-sm', branchText: 'text-tree-sm', - branchIndicator: 'text-tree-sm', + branchIndicator: 'text-tree-indicator-sm', label: 'text-tree-sm', }, md: { nodeIcon: 'text-tree-icon-md', branchText: 'text-tree-md', - branchIndicator: 'text-lg', + branchIndicator: 'text-tree-indicator-md', label: 'text-tree-md', }, lg: { nodeIcon: 'text-tree-icon-lg', branchText: 'text-tree-lg', - branchIndicator: 'text-tree-lg', + branchIndicator: 'text-tree-indicator-lg', label: 'text-tree-lg', }, }, @@ -109,6 +108,68 @@ const treeVariants = tv({ }, }) +// === HELPER FUNCTIONS === + +// Type definitions from Zag.js API +type ItemProps = ReturnType +type BranchControlProps = ReturnType +type NonSelectableProps = { + onClick?: (e: MouseEvent) => void + onKeyDown: (e: KeyboardEvent) => void + 'aria-selected': undefined + 'data-disabled': true +} + +/** + * Creates props for non-selectable nodes that should only allow navigation + * but not selection. Optionally allows Enter key for expand/collapse on branches. + */ +function createNonSelectableProps( + baseProps: ItemProps | BranchControlProps, + nodeState: tree.NodeState, + options?: { + allowEnterForToggle?: boolean + onToggle?: () => void + } +): (ItemProps | BranchControlProps) & NonSelectableProps { + return { + ...baseProps, + onClick: options?.onToggle + ? (e: MouseEvent) => { + e.stopPropagation() + if (!nodeState.disabled && !nodeState.loading) { + options.onToggle?.() + } + } + : undefined, + onKeyDown: (e: KeyboardEvent) => { + const key = e.key + // Block selection keys (Enter/Space/Spacebar) + if (key === 'Enter' || key === ' ' || key === 'Spacebar') { + e.preventDefault() + e.stopPropagation() + + // Optionally allow Enter to toggle expand/collapse + if ( + key === 'Enter' && + options?.allowEnterForToggle && + options?.onToggle && + !nodeState.disabled && + !nodeState.loading + ) { + options.onToggle() + } + return false + } + // Allow navigation keys to pass through + baseProps.onKeyDown?.(e) + return + }, + 'aria-selected': undefined, + 'data-disabled': true, + } as (ItemProps | BranchControlProps) & NonSelectableProps +} + // === TREE NODE COMPONENT === interface TreeNodeProps extends tree.NodeProps { api: tree.Api @@ -166,18 +227,13 @@ function TreeNode({ // Get the base props const baseControlProps = api.getBranchControlProps(nodeProps) - // Override onClick if not selectable + // Use helper function for non-selectable branches const branchControlProps = isSelectable ? baseControlProps - : { - ...baseControlProps, - onClick: (event: MouseEvent) => { - // Only handle expand/collapse, not selection - if (nodeState.disabled || nodeState.loading) return - event.stopPropagation() - handleToggle(node.id) - }, - } + : createNonSelectableProps(baseControlProps, nodeState, { + allowEnterForToggle: true, + onToggle: () => handleToggle(node.id), + }) return (
@@ -240,12 +296,10 @@ function TreeNode({ } // For leaf nodes, conditionally apply selection props + const baseItemProps = api.getItemProps(nodeProps) const leafProps = isSelectable - ? api.getItemProps(nodeProps) - : { - ...api.getItemProps(nodeProps), - onClick: undefined, // Remove selection click handler if not selectable - } + ? baseItemProps + : createNonSelectableProps(baseItemProps, nodeState) return (
diff --git a/libs/ui/src/tokens/components/molecules/_tree-view.css b/libs/ui/src/tokens/components/molecules/_tree-view.css index 3c030b44ac..341ab1ff82 100644 --- a/libs/ui/src/tokens/components/molecules/_tree-view.css +++ b/libs/ui/src/tokens/components/molecules/_tree-view.css @@ -44,6 +44,10 @@ --text-tree-icon-md: var(--text-icon-md); --text-tree-icon-lg: var(--text-icon-lg); + --text-tree-indicator-sm: var(--text-icon-md); + --text-tree-indicator-md: var(--text-icon-lg); + --text-tree-indicator-lg: var(--text-icon-xl); + /* Tree font weight */ --font-weight-tree-label: var(--font-weight-medium);