From f25dfcb6693329ef85f11c6fea7b062a896f1a57 Mon Sep 17 00:00:00 2001 From: Katie McFaul Date: Thu, 13 Nov 2025 16:00:36 -0500 Subject: [PATCH] feat(TreeView): add support for disabled TreeViewListItems --- .../src/components/TreeView/TreeView.tsx | 6 + .../components/TreeView/TreeViewListItem.tsx | 33 +++- .../__tests__/TreeViewListItem.test.tsx | 166 ++++++++++++++++++ 3 files changed, 196 insertions(+), 9 deletions(-) diff --git a/packages/react-core/src/components/TreeView/TreeView.tsx b/packages/react-core/src/components/TreeView/TreeView.tsx index b6cd5a0c950..3eb2b8d02f9 100644 --- a/packages/react-core/src/components/TreeView/TreeView.tsx +++ b/packages/react-core/src/components/TreeView/TreeView.tsx @@ -35,6 +35,10 @@ export interface TreeViewDataItem { name: React.ReactNode; /** Title of a tree view item. Only used in compact presentations. */ title?: React.ReactNode; + /** Flag indicating if the tree view item is disabled. */ + isDisabled?: boolean; + /** Flag indicating if the tree view item toggle is disabled. */ + isToggleDisabled?: boolean; } /** The main tree view component. */ @@ -158,6 +162,8 @@ export const TreeView: React.FunctionComponent = ({ id={item.id} isExpanded={allExpanded} isSelectable={hasSelectableNodes} + isDisabled={item.isDisabled} + isToggleDisabled={item.isToggleDisabled} defaultExpanded={item.defaultExpanded !== undefined ? item.defaultExpanded : defaultAllExpanded} onSelect={onSelect} onCheck={onCheck} diff --git a/packages/react-core/src/components/TreeView/TreeViewListItem.tsx b/packages/react-core/src/components/TreeView/TreeViewListItem.tsx index ce04ba41af0..e74c7f4e12e 100644 --- a/packages/react-core/src/components/TreeView/TreeViewListItem.tsx +++ b/packages/react-core/src/components/TreeView/TreeViewListItem.tsx @@ -46,6 +46,10 @@ export interface TreeViewListItemProps { * children. */ isSelectable?: boolean; + /** Flag indicating if the tree view item is disabled. */ + isDisabled?: boolean; + /** Flag indicating if the tree view item toggle is disabled. */ + isToggleDisabled?: boolean; /** Data structure of tree view item. */ itemData?: TreeViewDataItem; /** Internal content of a tree view item. */ @@ -81,6 +85,8 @@ const TreeViewListItemBase: React.FunctionComponent = ({ title, id, isExpanded, + isDisabled = false, + isToggleDisabled = false, defaultExpanded = false, children = null, onSelect, @@ -128,9 +134,9 @@ const TreeViewListItemBase: React.FunctionComponent = ({ const renderToggle = (randomId: string) => ( { - if (isSelectable || hasCheckbox) { + if (!isToggleDisabled && (isSelectable || hasCheckbox)) { if (internalIsExpanded) { onCollapse && onCollapse(evt, itemData, parentItem); } else { @@ -138,12 +144,12 @@ const TreeViewListItemBase: React.FunctionComponent = ({ } setIsExpanded(!internalIsExpanded); } - if (isSelectable) { + if (!isToggleDisabled && isSelectable) { evt.stopPropagation(); } }} {...((hasCheckbox || isSelectable) && { 'aria-labelledby': `label-${randomId}` })} - {...(ToggleComponent === 'button' && { type: 'button' })} + {...(ToggleComponent === 'button' && { disabled: isToggleDisabled, type: 'button' })} tabIndex={-1} > @@ -180,7 +186,12 @@ const TreeViewListItemBase: React.FunctionComponent = ({ <> {isCompact && title && {title}} {isSelectable ? ( - ) : ( @@ -234,11 +245,15 @@ const TreeViewListItemBase: React.FunctionComponent = ({ {(randomId) => ( { if (!hasCheckbox) { - onSelect && onSelect(evt, itemData, parentItem); - if (!isSelectable && children && evt.isDefaultPrevented() !== true) { + !isDisabled && onSelect && onSelect(evt, itemData, parentItem); + if (!isDisabled && !isSelectable && children && evt.isDefaultPrevented() !== true) { if (internalIsExpanded) { onCollapse && onCollapse(evt, itemData, parentItem); } else { @@ -250,7 +265,7 @@ const TreeViewListItemBase: React.FunctionComponent = ({ }} {...(hasCheckbox && { htmlFor: randomId })} {...((hasCheckbox || (isSelectable && children)) && { id: `label-${randomId}` })} - {...(Component === 'button' && { type: 'button' })} + {...(Component === 'button' && { type: 'button', disabled: isDisabled })} > {children && renderToggle(randomId)} diff --git a/packages/react-core/src/components/TreeView/__tests__/TreeViewListItem.test.tsx b/packages/react-core/src/components/TreeView/__tests__/TreeViewListItem.test.tsx index 80301d08ed2..35e69f58f7c 100644 --- a/packages/react-core/src/components/TreeView/__tests__/TreeViewListItem.test.tsx +++ b/packages/react-core/src/components/TreeView/__tests__/TreeViewListItem.test.tsx @@ -415,6 +415,172 @@ test(`Does not render ${styles.treeViewNode} element with ${styles.modifiers.cur expect(treeViewNode).not.toHaveClass(styles.modifiers.current); }); +// Assisted by Cursor AI +describe('isDisabled prop', () => { + const user = userEvent.setup(); + const onSelectMock = jest.fn(); + const onExpandMock = jest.fn(); + const onCollapseMock = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('Renders button with disabled attribute and pf-m-disabled class when isDisabled is true', () => { + render(); + + const button = screen.getByRole('button', { name: requiredProps.name }); + expect(button).toBeDisabled(); + expect(button).toHaveClass('pf-m-disabled'); + }); + + test('Does not render button with disabled attribute when isDisabled is false', () => { + render(); + + expect(screen.getByRole('button', { name: requiredProps.name })).not.toBeDisabled(); + }); + + test('Renders selectable button with disabled attribute when isDisabled is true', () => { + render(); + + const treeViewNode = screen.getByRole('treeitem').querySelector(`.${styles.treeViewNode}`); + const selectableButton = treeViewNode?.querySelector('button'); + expect(selectableButton).toBeDisabled(); + expect(selectableButton).toHaveClass('pf-m-disabled'); + }); + + test('Does not call onSelect when isDisabled is true', async () => { + render(); + + await user.click(screen.getByRole('button', { name: requiredProps.name })); + + expect(onSelectMock).not.toHaveBeenCalled(); + }); + + test('Does not call onExpand when isDisabled is true and item is collapsed', async () => { + render( + + Content + + ); + + await user.click(screen.getByRole('button', { name: requiredProps.name })); + + expect(onExpandMock).not.toHaveBeenCalled(); + }); + + test('Does not call onCollapse when isDisabled is true and item is expanded', async () => { + render( + + Content + + ); + + await user.click(screen.getByRole('button', { name: requiredProps.name })); + + expect(onCollapseMock).not.toHaveBeenCalled(); + }); +}); + +// Assisted by Cursor AI +describe('isToggleDisabled prop', () => { + const user = userEvent.setup(); + const onExpandMock = jest.fn(); + const onCollapseMock = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('Renders toggle button with disabled attribute and pf-m-disabled class when isToggleDisabled is true and hasCheckbox is passed', () => { + render( + + Content + + ); + + const toggle = screen.getByText(requiredProps.name).previousElementSibling?.previousElementSibling; + expect(toggle).toBeDisabled(); + expect(toggle).toHaveClass('pf-m-disabled'); + }); + + test('Renders toggle button with disabled attribute and pf-m-disabled class when isToggleDisabled is true and isSelectable is passed', () => { + render( + + Content + + ); + + const toggle = screen.getByText(requiredProps.name).previousElementSibling; + expect(toggle).toBeDisabled(); + expect(toggle).toHaveClass('pf-m-disabled'); + }); + + test('Does not render toggle span with disabled attribute when isToggleDisabled is true (toggle is span by default)', () => { + render( + + Content + + ); + + const toggle = screen.getByText(requiredProps.name).previousElementSibling; + expect(toggle?.tagName).toBe('SPAN'); + expect(toggle).not.toHaveAttribute('disabled'); + }); + + test('Does not call onExpand when isToggleDisabled is true and hasCheckbox is passed', async () => { + render( + + Content + + ); + + const toggle = screen.getByText(requiredProps.name).previousElementSibling?.previousElementSibling; + await user.click(toggle as Element); + + expect(onExpandMock).not.toHaveBeenCalled(); + }); + + test('Does not call onCollapse when isToggleDisabled is true and hasCheckbox is passed', async () => { + render( + + Content + + ); + + const toggle = screen.getByText(requiredProps.name).previousElementSibling?.previousElementSibling; + await user.click(toggle as Element); + + expect(onCollapseMock).not.toHaveBeenCalled(); + }); + + test('Does not call onExpand when isToggleDisabled is true and isSelectable is passed', async () => { + render( + + Content + + ); + + const toggle = screen.getByText(requiredProps.name).previousElementSibling; + await user.click(toggle as Element); + + expect(onExpandMock).not.toHaveBeenCalled(); + }); + + test('Does not call onCollapse when isToggleDisabled is true and isSelectable is passed', async () => { + render( + + Content + + ); + + const toggle = screen.getByText(requiredProps.name).previousElementSibling; + await user.click(toggle as Element); + + expect(onCollapseMock).not.toHaveBeenCalled(); + }); +}); + describe('Callback props', () => { const user = userEvent.setup(); const compareItemsMock = jest.fn();