Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: added types to TreeNode & TreeView #17038

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,101 @@
* LICENSE file in the root directory of this source tree.
*/

import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { CaretDown } from '@carbon/icons-react';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, {
ComponentType,
FunctionComponent,
useEffect,
useRef,
useState,
} from 'react';
import { keys, match, matches } from '../../internal/keyboard';
import uniqueId from '../../tools/uniqueId';
import { usePrefix } from '../../internal/usePrefix';
import { useControllableState } from '../../internal/useControllableState';
import { usePrefix } from '../../internal/usePrefix';
import uniqueId from '../../tools/uniqueId';
import { useFeatureFlag } from '../FeatureFlags';

const TreeNode = React.forwardRef(
export type TreeNodeProps = {
/**
* **Note:** this is controlled by the parent TreeView component, do not set manually.
* The ID of the active node in the tree
*/
active?: string | number;
/**
* Specify the children of the TreeNode
*/
children?: React.ReactNode;
/**
* Specify an optional className to be applied to the TreeNode
*/
className?: string;
/**
* **[Experimental]** The default expansion state of the node.
* *This is only supported with the `enable-treeview-controllable` feature flag!*
*/
defaultIsExpanded?: boolean;
/**
* **Note:** this is controlled by the parent TreeView component, do not set manually.
* TreeNode depth to determine spacing
*/
depth?: number;
/**
* Specify if the TreeNode is disabled
*/
disabled?: boolean;
/**
* Specify the TreeNode's ID. Must be unique in the DOM and is used for props.active and props.selected
*/
id?: string;
/**
* Specify if the TreeNode is expanded (only applicable to parent nodes)
*/
isExpanded?: boolean;
/**
* Rendered label for the TreeNode
*/
label: React.ReactNode;
/**
* Callback function for when the node receives or loses focus
*/
onNodeFocusEvent?: (event: React.FocusEvent<HTMLLIElement>) => void;
/**
* Callback function for when the node is selected
*/
onSelect?: (event: React.MouseEvent, node?: TreeNodeProps) => void;
/**
* Callback function for when a parent node is expanded or collapsed
*/
onToggle?: (event: React.MouseEvent, node?: TreeNodeProps) => void;
/**
* Callback function for when any node in the tree is selected
*/
onTreeSelect?: (event: React.MouseEvent, node?: TreeNodeProps) => void;
/**
* Optional prop to allow each node to have an associated icon.
* Can be a React component class
*/
renderIcon?: ComponentType | FunctionComponent;
/**
* **Note:** this is controlled by the parent TreeView component, do not set manually.
* Array containing all selected node IDs in the tree
*/
selected?: Array<string | number>;
/**
* Specify the value of the TreeNode
*/
value?: string;
} & Omit<React.LiHTMLAttributes<HTMLLIElement>, 'onSelect'>;

const TreeNode = React.forwardRef<HTMLLIElement, TreeNodeProps>(
(
{
active,
children,
className,
depth,
depth: propDepth,
disabled,
id: nodeId,
isExpanded,
Expand All @@ -32,12 +110,16 @@ const TreeNode = React.forwardRef(
onToggle,
onTreeSelect,
renderIcon: Icon,
selected,
selected: propSelected,
value,
...rest
},
ref
) => {
// These are provided by the parent TreeView component
const depth = propDepth as number;
const selected = propSelected as (string | number)[];
Comment on lines +119 to +121
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have another proposal.

Since the component internals expect that these should always be defined, should we do something like this to provide a better error for consumers that try to mount TreeNode outside of TreeView?

if (depth === undefined || selected === undefined){
   throw new Error("It looks like you are trying to mount a TreeNode outside of a TreeView. To do this, ensure that the 'selected' and 'depth' props are provided.");
}

This would also mean that we wouldn't have to cast the types manually either.


const enableTreeviewControllable = useFeatureFlag(
'enable-treeview-controllable'
);
Expand All @@ -54,8 +136,8 @@ const TreeNode = React.forwardRef(
? controllableExpandedState
: uncontrollableExpandedState;

const currentNode = useRef(null);
const currentNodeLabel = useRef(null);
const currentNode = useRef<HTMLLIElement>(null);
const currentNodeLabel = useRef<HTMLDivElement>(null);
const prefix = usePrefix();
const nodesWithProps = React.Children.map(children, (node) => {
if (React.isValidElement(node)) {
Expand All @@ -66,7 +148,7 @@ const TreeNode = React.forwardRef(
onTreeSelect,
selected,
tabIndex: (!node.props.disabled && -1) || null,
});
} as TreeNodeProps);
}
});
const isActive = active === id;
Expand All @@ -85,7 +167,7 @@ const TreeNode = React.forwardRef(
[`${prefix}--tree-parent-node__toggle-icon--expanded`]: expanded,
}
);
function handleToggleClick(event) {
function handleToggleClick(event: React.MouseEvent<HTMLSpanElement>) {
if (disabled) {
return;
}
Expand All @@ -98,12 +180,12 @@ const TreeNode = React.forwardRef(
}
setExpanded(!expanded);
}
function handleClick(event) {
function handleClick(event: React.MouseEvent) {
event.stopPropagation();
if (!disabled) {
onTreeSelect?.(event, { id, label, value });
onNodeSelect?.(event, { id, label, value });
rest?.onClick?.(event);
rest?.onClick?.(event as React.MouseEvent<HTMLLIElement>);
}
}
function handleKeyDown(event) {
Expand Down Expand Up @@ -133,7 +215,7 @@ const TreeNode = React.forwardRef(
* When focus is on a leaf node or a closed parent node, move focus to
* its parent node (unless its depth is level 1)
*/
findParentTreeNode(currentNode.current.parentNode)?.focus();
findParentTreeNode(currentNode.current?.parentNode)?.focus();
}
}
if (children && match(event, keys.ArrowRight)) {
Expand All @@ -142,7 +224,7 @@ const TreeNode = React.forwardRef(
* When focus is on an expanded parent node, move focus to the first
* child node
*/
currentNode.current.lastChild.firstChild.focus();
(currentNode.current?.lastChild?.firstChild as HTMLElement).focus();
} else {
if (!enableTreeviewControllable) {
onToggle?.(event, { id, isExpanded: true, label, value });
Expand Down Expand Up @@ -213,25 +295,27 @@ const TreeNode = React.forwardRef(
setExpanded,
]);

const treeNodeProps = {
const treeNodeProps: React.LiHTMLAttributes<HTMLLIElement> = {
...rest,
['aria-current']: isActive || null,
['aria-selected']: disabled ? null : isSelected,
['aria-current']: isActive || undefined,
['aria-selected']: disabled ? undefined : isSelected,
['aria-disabled']: disabled,
className: treeNodeClasses,
id,
onBlur: handleFocusEvent,
onClick: handleClick,
onFocus: handleFocusEvent,
onKeyDown: handleKeyDown,
ref: currentNode,
role: 'treeitem',
// @ts-ignore
ref: currentNode,
};

if (!children) {
return (
<li {...treeNodeProps}>
<div className={`${prefix}--tree-node__label`} ref={currentNodeLabel}>
{/* @ts-ignore - TS cannot be sure `className` exists on Icon props */}
{Icon && <Icon className={`${prefix}--tree-node__icon`} />}
{label}
</div>
Expand All @@ -246,11 +330,13 @@ const TreeNode = React.forwardRef(
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<span
className={`${prefix}--tree-parent-node__toggle`}
// @ts-ignore
disabled={disabled}
onClick={handleToggleClick}>
<CaretDown className={toggleClasses} />
</span>
<span className={`${prefix}--tree-node__label__details`}>
{/* @ts-ignore - TS cannot be sure `className` exists on Icon props */}
{Icon && <Icon className={`${prefix}--tree-node__icon`} />}
{label}
</span>
Expand Down Expand Up @@ -338,12 +424,14 @@ TreeNode.propTypes = {
* Optional prop to allow each node to have an associated icon.
* Can be a React component class
*/
// @ts-ignore
renderIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),

/**
* **Note:** this is controlled by the parent TreeView component, do not set manually.
* Array containing all selected node IDs in the tree
*/
// @ts-ignore
selected: PropTypes.arrayOf(
PropTypes.oneOfType([PropTypes.string, PropTypes.number])
),
Expand Down
22 changes: 22 additions & 0 deletions packages/react/src/components/TreeView/TreeView-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,28 @@ describe('TreeView', () => {
expect(within(nodeChild).getByText('Node 2')).toBeInTheDocument();
});

it('should render children as expected when using dot syntax', () => {
render(
<TreeView label="Tree View">
<TreeView.TreeNode
isExpanded={true}
data-testid="Node 1"
label="Node 1">
<TreeView.TreeNode data-testid="Node 2" label="Node 2" />
</TreeView.TreeNode>
</TreeView>
);

const nodeParent = screen.getByTestId('Node 1');
const nodeChild = screen.getByTestId('Node 2');

expect(nodeParent).toHaveClass(`${prefix}--tree-parent-node`);
expect(nodeChild).toHaveClass(`${prefix}--tree-leaf-node`);

expect(within(nodeParent).getByText('Node 1')).toBeInTheDocument();
expect(within(nodeChild).getByText('Node 2')).toBeInTheDocument();
});

it('should support a custom `className` prop on the outermost element', () => {
const { container } = render(
<TreeView className="custom-class" label="Tree" />
Expand Down
Loading
Loading