From b6710e7b0aa950dbb0cba457b025912c776a5f27 Mon Sep 17 00:00:00 2001 From: Jan Hassel Date: Mon, 26 Feb 2024 14:38:04 +0100 Subject: [PATCH] feat(treeview): add experimental controllable API (#15397) Co-authored-by: Andrea N. Cardona --- packages/feature-flags/feature-flags.yml | 4 + .../__snapshots__/PublicAPI-test.js.snap | 9 + .../FeatureFlags/overview.stories.mdx | 5 +- .../react/src/components/TreeView/TreeNode.js | 54 +++- .../TreeView/TreeView.featureflag.mdx | 33 +++ .../TreeView/TreeView.featureflag.stories.js | 261 ++++++++++++++++++ .../react/src/components/TreeView/TreeView.js | 60 +++- .../components/TreeView/Treeview.stories.js | 5 +- 8 files changed, 410 insertions(+), 21 deletions(-) create mode 100644 packages/react/src/components/TreeView/TreeView.featureflag.mdx create mode 100644 packages/react/src/components/TreeView/TreeView.featureflag.stories.js diff --git a/packages/feature-flags/feature-flags.yml b/packages/feature-flags/feature-flags.yml index 5534ea27d699..9a8dee20bd6e 100644 --- a/packages/feature-flags/feature-flags.yml +++ b/packages/feature-flags/feature-flags.yml @@ -34,3 +34,7 @@ feature-flags: description: > Enable the use of the v12 OverflowMenu leveraging the Menu subcomponents enabled: false + - name: enable-treeview-controllable + description: > + Enable the new TreeView controllable API + enabled: false diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap index 1432eab99574..a7395aff7703 100644 --- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap +++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap @@ -9175,6 +9175,9 @@ Map { "className": Object { "type": "string", }, + "defaultIsExpanded": Object { + "type": "bool", + }, "depth": Object { "type": "number", }, @@ -9262,6 +9265,9 @@ Map { "className": Object { "type": "string", }, + "defaultIsExpanded": Object { + "type": "bool", + }, "depth": Object { "type": "number", }, @@ -9356,6 +9362,9 @@ Map { "multiselect": Object { "type": "bool", }, + "onActivate": Object { + "type": "func", + }, "onSelect": Object { "type": "func", }, diff --git a/packages/react/src/components/FeatureFlags/overview.stories.mdx b/packages/react/src/components/FeatureFlags/overview.stories.mdx index ba239048a98b..8a839334db0b 100644 --- a/packages/react/src/components/FeatureFlags/overview.stories.mdx +++ b/packages/react/src/components/FeatureFlags/overview.stories.mdx @@ -34,8 +34,9 @@ components with all feature flags turned on. | ----------------------------------- | ------------------------------------------------------------------------ | ------- | --------------- | --------- | | `enable-v11-release` | Flag enabling the v11 features | `true` | ✅ | ✅ | | `enable-experimental-tile-contrast` | Enable the improved styling for tiles that provides better contrast | `false` | | ✅ | -| `enable-v12-tile-default-icons` | Enable default icons for Tile components | `false` | ✅ | -| `enable-v12-overflowmenu` | Enable the use of the v12 OverflowMenu leveraging the Menu subcomponents | `false` | ✅ | +| `enable-v12-tile-default-icons` | Enable default icons for Tile components | `false` | ✅ | | +| `enable-v12-overflowmenu` | Enable the use of the v12 OverflowMenu leveraging the Menu subcomponents | `false` | ✅ | | +| `enable-treeview-controllable` | Enable the new TreeView controllable API | `false` | ✅ | | ## Turning on feature flags in Javascript/react diff --git a/packages/react/src/components/TreeView/TreeNode.js b/packages/react/src/components/TreeView/TreeNode.js index 6b4e1e76f70e..df9f36258793 100644 --- a/packages/react/src/components/TreeView/TreeNode.js +++ b/packages/react/src/components/TreeView/TreeNode.js @@ -12,6 +12,8 @@ import classNames from 'classnames'; import { keys, match, matches } from '../../internal/keyboard'; import uniqueId from '../../tools/uniqueId'; import { usePrefix } from '../../internal/usePrefix'; +import { useControllableState } from '../../internal/useControllableState'; +import { useFeatureFlag } from '../FeatureFlags'; const TreeNode = React.forwardRef( ( @@ -23,6 +25,7 @@ const TreeNode = React.forwardRef( disabled, id: nodeId, isExpanded, + defaultIsExpanded, label, onNodeFocusEvent, onSelect: onNodeSelect, @@ -35,8 +38,22 @@ const TreeNode = React.forwardRef( }, ref ) => { + const enableTreeviewControllable = useFeatureFlag( + 'enable-treeview-controllable' + ); + const { current: id } = useRef(nodeId || uniqueId()); - const [expanded, setExpanded] = useState(isExpanded); + + const controllableExpandedState = useControllableState({ + value: isExpanded, + onChange: onToggle, + defaultValue: defaultIsExpanded, + }); + const uncontrollableExpandedState = useState(isExpanded); + const [expanded, setExpanded] = enableTreeviewControllable + ? controllableExpandedState + : uncontrollableExpandedState; + const currentNode = useRef(null); const currentNodeLabel = useRef(null); const prefix = usePrefix(); @@ -76,7 +93,9 @@ const TreeNode = React.forwardRef( // Prevent the node from being selected event.stopPropagation(); - onToggle?.(event, { id, isExpanded: !expanded, label, value }); + if (!enableTreeviewControllable) { + onToggle?.(event, { id, isExpanded: !expanded, label, value }); + } setExpanded(!expanded); } function handleClick(event) { @@ -105,7 +124,9 @@ const TreeNode = React.forwardRef( return findParentTreeNode(node.parentNode); }; if (children && expanded) { - onToggle?.(event, { id, isExpanded: false, label, value }); + if (!enableTreeviewControllable) { + onToggle?.(event, { id, isExpanded: false, label, value }); + } setExpanded(false); } else { /** @@ -123,7 +144,9 @@ const TreeNode = React.forwardRef( */ currentNode.current.lastChild.firstChild.focus(); } else { - onToggle?.(event, { id, isExpanded: true, label, value }); + if (!enableTreeviewControllable) { + onToggle?.(event, { id, isExpanded: true, label, value }); + } setExpanded(true); } } @@ -177,9 +200,18 @@ const TreeNode = React.forwardRef( currentNodeLabel.current.style.paddingInlineStart = `${calcOffset()}rem`; } - // sync props and state - setExpanded(isExpanded); - }, [children, depth, Icon, isExpanded]); + if (!enableTreeviewControllable) { + // sync props and state + setExpanded(isExpanded); + } + }, [ + children, + depth, + Icon, + isExpanded, + enableTreeviewControllable, + setExpanded, + ]); const treeNodeProps = { ...rest, @@ -251,7 +283,13 @@ TreeNode.propTypes = { className: PropTypes.string, /** - * * **Note:** this is controlled by the parent TreeView component, do not set manually. + * **[Experimental]** The default expansion state of the node. + * *This is only supported with the `enable-treeview-controllable` feature flag!* + */ + defaultIsExpanded: PropTypes.bool, + + /** + * **Note:** this is controlled by the parent TreeView component, do not set manually. * TreeNode depth to determine spacing */ depth: PropTypes.number, diff --git a/packages/react/src/components/TreeView/TreeView.featureflag.mdx b/packages/react/src/components/TreeView/TreeView.featureflag.mdx new file mode 100644 index 000000000000..e8be8d3ca94c --- /dev/null +++ b/packages/react/src/components/TreeView/TreeView.featureflag.mdx @@ -0,0 +1,33 @@ +# TreeView controllable API + +The new controllable API of TreeView allows you to synchronize the state of `selected` and `active` with your application. + +You can opt-in to this by enabling the `enable-treeview-controllable` feature flag. This changes the following behaviour: + +- `TreeView` + - `props.onActivate` will be called with a node's ID whenever it is activated. + - The signature of `props.onSelect` changes from `(event, selectedIDs)` to `(selectedIDs)`. + - Whenever you update `props.selected` or `props.active`, the internal state will be updated accordingly and the component re-renders. +- `TreeNode` + - The signature of `props.onToggle` changes from `(event, { id, isExpanded, label, value })` to `(isExpanded)`. + - `props.defaultIsExpanded` is added to allow for a default state in uncontrolled mode. + +## Example + +```jsx +function SynchronizedTreeView() { + const [selected, setSelected] = useState([]); + const [active, setActive] = useState(null); + + return ( + + ... + + ); +} +``` diff --git a/packages/react/src/components/TreeView/TreeView.featureflag.stories.js b/packages/react/src/components/TreeView/TreeView.featureflag.stories.js new file mode 100644 index 000000000000..62db42ffbf63 --- /dev/null +++ b/packages/react/src/components/TreeView/TreeView.featureflag.stories.js @@ -0,0 +1,261 @@ +/** + * Copyright IBM Corp. 2023 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { useState } from 'react'; +import { action } from '@storybook/addon-actions'; +import { Document, Folder } from '@carbon/icons-react'; +import { Button, VStack } from '../../'; + +import mdx from './TreeView.featureflag.mdx'; + +import { TreeView, TreeNode } from './'; + +import { WithFeatureFlags } from '../../../.storybook/templates/WithFeatureFlags'; + +const nodes = [ + { + id: '1', + value: 'Artificial intelligence', + label: Artificial intelligence, + renderIcon: Document, + }, + { + id: '2', + value: 'Blockchain', + label: 'Blockchain', + renderIcon: Document, + }, + { + id: '3', + value: 'Business automation', + label: 'Business automation', + renderIcon: Folder, + children: [ + { + id: '3-1', + value: 'Business process automation', + label: 'Business process automation', + renderIcon: Document, + }, + { + id: '3-2', + value: 'Business process mapping', + label: 'Business process mapping', + renderIcon: Document, + }, + ], + }, + { + id: '4', + value: 'Business operations', + label: 'Business operations', + renderIcon: Document, + }, + { + id: '5', + value: 'Cloud computing', + label: 'Cloud computing', + isExpanded: true, + renderIcon: Folder, + children: [ + { + id: '5-1', + value: 'Containers', + label: 'Containers', + renderIcon: Document, + }, + { + id: '5-2', + value: 'Databases', + label: 'Databases', + renderIcon: Document, + }, + { + id: '5-3', + value: 'DevOps', + label: 'DevOps', + isExpanded: true, + renderIcon: Folder, + children: [ + { + id: '5-4', + value: 'Solutions', + label: 'Solutions', + renderIcon: Document, + }, + { + id: '5-5', + value: 'Case studies', + label: 'Case studies', + isExpanded: true, + renderIcon: Folder, + children: [ + { + id: '5-6', + value: 'Resources', + label: 'Resources', + renderIcon: Document, + }, + ], + }, + ], + }, + ], + }, + { + id: '6', + value: 'Data & Analytics', + label: 'Data & Analytics', + renderIcon: Folder, + children: [ + { + id: '6-1', + value: 'Big data', + label: 'Big data', + renderIcon: Document, + }, + { + id: '6-2', + value: 'Business intelligence', + label: 'Business intelligence', + renderIcon: Document, + }, + ], + }, + { + id: '7', + value: 'Models', + label: 'Models', + isExpanded: true, + disabled: true, + renderIcon: Folder, + children: [ + { + id: '7-1', + value: 'Audit', + label: 'Audit', + renderIcon: Document, + }, + { + id: '7-2', + value: 'Monthly data', + label: 'Monthly data', + renderIcon: Document, + }, + { + id: '8', + value: 'Data warehouse', + label: 'Data warehouse', + isExpanded: true, + renderIcon: Folder, + children: [ + { + id: '8-1', + value: 'Report samples', + label: 'Report samples', + renderIcon: Document, + }, + { + id: '8-2', + value: 'Sales performance', + label: 'Sales performance', + renderIcon: Document, + }, + ], + }, + ], + }, +]; + +function renderTree(nodes) { + if (!nodes) { + return; + } + + return nodes.map(({ children, isExpanded, ...nodeProps }) => ( + + {renderTree(children)} + + )); +} + +export default { + title: 'Experimental/Feature Flags/TreeView', + component: TreeView, + subcomponents: { + TreeNode, + }, + parameters: { + docs: { + page: mdx, + }, + }, + args: { + onSelect: action('onSelect'), + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export const Playground = (args) => { + const [selected, setSelected] = useState([]); + const [active, setActive] = useState(null); + + return ( + + + + + + +
+ + {renderTree(nodes)} + +
+
+ ); +}; + +Playground.args = { + hideLabel: false, + multiselect: false, +}; + +Playground.argTypes = { + active: { control: { disable: true } }, + selected: { control: { disable: true } }, + size: { + options: ['xs', 'sm'], + control: { type: 'select' }, + }, +}; diff --git a/packages/react/src/components/TreeView/TreeView.js b/packages/react/src/components/TreeView/TreeView.js index d49f167fd8da..43ea4c791115 100644 --- a/packages/react/src/components/TreeView/TreeView.js +++ b/packages/react/src/components/TreeView/TreeView.js @@ -11,6 +11,8 @@ import classNames from 'classnames'; import { keys, match, matches } from '../../internal/keyboard'; import uniqueId from '../../tools/uniqueId'; import { usePrefix } from '../../internal/usePrefix'; +import { useControllableState } from '../../internal/useControllableState'; +import { useFeatureFlag } from '../FeatureFlags'; export default function TreeView({ active: prespecifiedActive, @@ -19,11 +21,16 @@ export default function TreeView({ hideLabel = false, label, multiselect = false, + onActivate, onSelect, - selected: preselected = [], + selected: preselected, size = 'sm', ...rest }) { + const enableTreeviewControllable = useFeatureFlag( + 'enable-treeview-controllable' + ); + const { current: treeId } = useRef(rest.id || uniqueId()); const prefix = usePrefix(); const treeClasses = classNames(className, `${prefix}--tree`, { @@ -31,8 +38,27 @@ export default function TreeView({ }); const treeRootRef = useRef(null); const treeWalker = useRef(treeRootRef?.current); - const [selected, setSelected] = useState(preselected); - const [active, setActive] = useState(prespecifiedActive); + + const controllableSelectionState = useControllableState({ + value: preselected, + onChange: onSelect, + defaultValue: [], + }); + const uncontrollableSelectionState = useState(preselected ?? []); + const [selected, setSelected] = enableTreeviewControllable + ? controllableSelectionState + : uncontrollableSelectionState; + + const controllableActiveState = useControllableState({ + value: prespecifiedActive, + onChange: onActivate, + defaultValue: undefined, + }); + const uncontrollableActiveState = useState(prespecifiedActive); + const [active, setActive] = enableTreeviewControllable + ? controllableActiveState + : uncontrollableActiveState; + function resetNodeTabIndices() { Array.prototype.forEach.call( treeRootRef?.current?.querySelectorAll('[tabIndex="0"]') ?? [], @@ -50,11 +76,17 @@ export default function TreeView({ } else { setSelected(selected.filter((selectedId) => selectedId !== nodeId)); } - onSelect?.(event, node); + + if (!enableTreeviewControllable) { + onSelect?.(event, node); + } } else { setSelected([nodeId]); setActive(nodeId); - onSelect?.(event, { activeNodeId: nodeId, ...node }); + + if (!enableTreeviewControllable) { + onSelect?.(event, { activeNodeId: nodeId, ...node }); + } } } @@ -185,11 +217,13 @@ export default function TreeView({ const useActiveAndSelectedOnMount = () => useEffect(() => { - if (preselected.length) { - setSelected(preselected); - } - if (prespecifiedActive) { - setActive(prespecifiedActive); + if (!enableTreeviewControllable) { + if (preselected?.length) { + setSelected(preselected); + } + if (prespecifiedActive) { + setActive(prespecifiedActive); + } } }, []); @@ -253,6 +287,12 @@ TreeView.propTypes = { */ multiselect: PropTypes.bool, + /** + * **[Experimental]** Callback function that is called when any node is activated. + * *This is only supported with the `enable-treeview-controllable` feature flag!* + */ + onActivate: PropTypes.func, + /** * Callback function that is called when any node is selected */ diff --git a/packages/react/src/components/TreeView/Treeview.stories.js b/packages/react/src/components/TreeView/Treeview.stories.js index 27a4f532da1d..27908696a1d3 100644 --- a/packages/react/src/components/TreeView/Treeview.stories.js +++ b/packages/react/src/components/TreeView/Treeview.stories.js @@ -6,6 +6,7 @@ */ import React, { useState } from 'react'; +import { action } from '@storybook/addon-actions'; import { Document, Folder } from '@carbon/icons-react'; import { default as TreeView, TreeNode } from './'; import './story.scss'; @@ -186,7 +187,9 @@ export default { subcomponents: { TreeNode, }, - args: {}, + args: { + onSelect: action('onSelect'), + }, argTypes: { children: { table: {