diff --git a/packages/js/components/changelog/add-35851-tree-control b/packages/js/components/changelog/add-35851-tree-control new file mode 100644 index 00000000000..24395c6622b --- /dev/null +++ b/packages/js/components/changelog/add-35851-tree-control @@ -0,0 +1,4 @@ +Significance: minor +Type: dev + +Create tree-control component diff --git a/packages/js/components/src/experimental-tree-control/hooks/use-linked-tree.ts b/packages/js/components/src/experimental-tree-control/hooks/use-linked-tree.ts new file mode 100644 index 00000000000..94ff95706b8 --- /dev/null +++ b/packages/js/components/src/experimental-tree-control/hooks/use-linked-tree.ts @@ -0,0 +1,50 @@ +/** + * External dependencies + */ +import { useMemo } from 'react'; + +/** + * Internal dependencies + */ +import { Item, LinkedTree } from '../types'; + +type MemoItems = { + [ value: Item[ 'value' ] ]: LinkedTree; +}; + +function findChildren( + items: Item[], + parent?: Item[ 'parent' ], + memo: MemoItems = {} +): LinkedTree[] { + const children: Item[] = []; + const others: Item[] = []; + + items.forEach( ( item ) => { + if ( item.parent === parent ) { + children.push( item ); + } else { + others.push( item ); + } + memo[ item.value ] = { + parent: undefined, + data: item, + children: [], + }; + } ); + + return children.map( ( child ) => { + const linkedTree = memo[ child.value ]; + linkedTree.parent = child.parent ? memo[ child.parent ] : undefined; + linkedTree.children = findChildren( others, child.value, memo ); + return linkedTree; + } ); +} + +export function useLinkedTree( items: Item[] ): LinkedTree[] { + const linkedTree = useMemo( () => { + return findChildren( items, undefined, {} ); + }, [ items ] ); + + return linkedTree; +} diff --git a/packages/js/components/src/experimental-tree-control/hooks/use-tree-item.ts b/packages/js/components/src/experimental-tree-control/hooks/use-tree-item.ts new file mode 100644 index 00000000000..9043fe00f69 --- /dev/null +++ b/packages/js/components/src/experimental-tree-control/hooks/use-tree-item.ts @@ -0,0 +1,30 @@ +/** + * External dependencies + */ + +/** + * Internal dependencies + */ +import { TreeItemProps } from '../types'; + +export function useTreeItem( { item, level, ...props }: TreeItemProps ) { + const nextLevel = level + 1; + const nextHeadingPaddingLeft = ( level - 1 ) * 28 + 12; + + return { + item, + level: nextLevel, + treeItemProps: { + ...props, + }, + headingProps: { + style: { + paddingLeft: nextHeadingPaddingLeft, + }, + }, + treeProps: { + items: item.children, + level: nextLevel, + }, + }; +} diff --git a/packages/js/components/src/experimental-tree-control/hooks/use-tree.ts b/packages/js/components/src/experimental-tree-control/hooks/use-tree.ts new file mode 100644 index 00000000000..2ab6b889c58 --- /dev/null +++ b/packages/js/components/src/experimental-tree-control/hooks/use-tree.ts @@ -0,0 +1,21 @@ +/** + * External dependencies + */ + +/** + * Internal dependencies + */ +import { TreeProps } from '../types'; + +export function useTree( { ref, items, level = 1, ...props }: TreeProps ) { + return { + level, + items, + treeProps: { + ...props, + }, + treeItemProps: { + level, + }, + }; +} diff --git a/packages/js/components/src/experimental-tree-control/index.ts b/packages/js/components/src/experimental-tree-control/index.ts new file mode 100644 index 00000000000..bf4fdd7d970 --- /dev/null +++ b/packages/js/components/src/experimental-tree-control/index.ts @@ -0,0 +1,4 @@ +export * from './tree'; +export * from './tree-control'; +export * from './tree-item'; +export * from './types'; diff --git a/packages/js/components/src/experimental-tree-control/stories/index.tsx b/packages/js/components/src/experimental-tree-control/stories/index.tsx new file mode 100644 index 00000000000..d62a8ed1964 --- /dev/null +++ b/packages/js/components/src/experimental-tree-control/stories/index.tsx @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import { BaseControl } from '@wordpress/components'; +import React, { createElement } from 'react'; + +/** + * Internal dependencies + */ +import { TreeControl } from '../tree-control'; +import { Item } from '../types'; + +const listItems: Item[] = [ + { value: '1', label: 'Technology' }, + { value: '1.1', label: 'Notebooks', parent: '1' }, + { value: '1.2', label: 'Phones', parent: '1' }, + { value: '1.2.1', label: 'iPhone', parent: '1.2' }, + { value: '1.2.1.1', label: 'iPhone 14 Pro', parent: '1.2.1' }, + { value: '1.2.1.2', label: 'iPhone 14 Pro Max', parent: '1.2.1' }, + { value: '1.2.2', label: 'Samsung', parent: '1.2' }, + { value: '1.2.2.1', label: 'Samsung Galaxy 22 Plus', parent: '1.2.2' }, + { value: '1.2.2.2', label: 'Samsung Galaxy 22 Ultra', parent: '1.2.2' }, + { value: '1.3', label: 'Wearables', parent: '1' }, + { value: '2', label: 'Hardware' }, + { value: '2.1', label: 'CPU', parent: '2' }, + { value: '2.2', label: 'GPU', parent: '2' }, + { value: '2.3', label: 'Memory RAM', parent: '2' }, + { value: '3', label: 'Other' }, +]; + +export const SimpleTree: React.FC = () => { + return ( + + + + ); +}; + +export default { + title: 'WooCommerce Admin/experimental/TreeControl', + component: TreeControl, +}; diff --git a/packages/js/components/src/experimental-tree-control/tree-control.tsx b/packages/js/components/src/experimental-tree-control/tree-control.tsx new file mode 100644 index 00000000000..24a484a2995 --- /dev/null +++ b/packages/js/components/src/experimental-tree-control/tree-control.tsx @@ -0,0 +1,20 @@ +/** + * External dependencies + */ +import { createElement, forwardRef } from 'react'; + +/** + * Internal dependencies + */ +import { useLinkedTree } from './hooks/use-linked-tree'; +import { Tree } from './tree'; +import { TreeControlProps } from './types'; + +export const TreeControl = forwardRef( function ForwardedTree( + { items, ...props }: TreeControlProps, + ref: React.ForwardedRef< HTMLOListElement > +) { + const linkedTree = useLinkedTree( items ); + + return ; +} ); diff --git a/packages/js/components/src/experimental-tree-control/tree-item.scss b/packages/js/components/src/experimental-tree-control/tree-item.scss new file mode 100644 index 00000000000..a62dbde0122 --- /dev/null +++ b/packages/js/components/src/experimental-tree-control/tree-item.scss @@ -0,0 +1,34 @@ +.experimental-woocommerce-tree-item { + margin: 0; + + &__heading { + display: flex; + flex-grow: 1; + gap: $gap-smaller; + min-height: $gap-largest; + padding: 0 $gap-small; + border-radius: 2px; + + &:hover, + &:focus-within { + outline: 1.5px solid var( --wp-admin-theme-color ); + outline-offset: -1.5px; + } + + &:hover, + &:focus-within { + background-color: $gray-0; + } + } + &__label { + display: flex; + flex-grow: 1; + align-items: center; + padding: $gap-smaller $gap-small $gap-smaller 0; + position: relative; + + > span { + display: block; + } + } +} diff --git a/packages/js/components/src/experimental-tree-control/tree-item.tsx b/packages/js/components/src/experimental-tree-control/tree-item.tsx new file mode 100644 index 00000000000..e8e7c407932 --- /dev/null +++ b/packages/js/components/src/experimental-tree-control/tree-item.tsx @@ -0,0 +1,44 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import classNames from 'classnames'; +import { createElement, forwardRef } from 'react'; + +/** + * Internal dependencies + */ +import { useTreeItem } from './hooks/use-tree-item'; +import { Tree } from './tree'; +import { TreeItemProps } from './types'; + +export const TreeItem = forwardRef( function ForwardedTreeItem( + props: TreeItemProps, + ref: React.ForwardedRef< HTMLLIElement > +) { + const { item, treeItemProps, headingProps, treeProps } = useTreeItem( { + ...props, + ref, + } ); + + return ( +
  • +
    +
    + { item.data.label } +
    +
    + + { Boolean( item.children.length ) && } +
  • + ); +} ); diff --git a/packages/js/components/src/experimental-tree-control/tree.scss b/packages/js/components/src/experimental-tree-control/tree.scss new file mode 100644 index 00000000000..221208658d5 --- /dev/null +++ b/packages/js/components/src/experimental-tree-control/tree.scss @@ -0,0 +1,15 @@ +@import './tree-item.scss'; + +.experimental-woocommerce-tree { + list-style: none; + padding: 0; + margin: 0; + + &--level-1 { + max-height: 280px; + overflow-y: auto; + background-color: $white; + border: 1px solid $gray-400; + border-radius: 2px; + } +} diff --git a/packages/js/components/src/experimental-tree-control/tree.tsx b/packages/js/components/src/experimental-tree-control/tree.tsx new file mode 100644 index 00000000000..da3a2200839 --- /dev/null +++ b/packages/js/components/src/experimental-tree-control/tree.tsx @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import classNames from 'classnames'; +import { createElement, forwardRef } from 'react'; + +/** + * Internal dependencies + */ +import { useTree } from './hooks/use-tree'; +import { TreeItem } from './tree-item'; +import { TreeProps } from './types'; + +export const Tree = forwardRef( function ForwardedTree( + props: TreeProps, + ref: React.ForwardedRef< HTMLOListElement > +) { + const { level, items, treeProps, treeItemProps } = useTree( { + ...props, + ref, + } ); + + if ( ! items.length ) return null; + return ( +
      + { items.map( ( child ) => ( + + ) ) } +
    + ); +} ); diff --git a/packages/js/components/src/experimental-tree-control/types.ts b/packages/js/components/src/experimental-tree-control/types.ts new file mode 100644 index 00000000000..c925bbb8574 --- /dev/null +++ b/packages/js/components/src/experimental-tree-control/types.ts @@ -0,0 +1,31 @@ +export interface Item { + parent?: string; + value: string; + label: string; +} + +export interface LinkedTree { + parent?: LinkedTree; + data: Item; + children: LinkedTree[]; +} + +export type TreeProps = React.DetailedHTMLProps< + React.OlHTMLAttributes< HTMLOListElement >, + HTMLOListElement +> & { + level?: number; + items: LinkedTree[]; +}; + +export type TreeItemProps = React.DetailedHTMLProps< + React.LiHTMLAttributes< HTMLLIElement >, + HTMLLIElement +> & { + level: number; + item: LinkedTree; +}; + +export type TreeControlProps = Omit< TreeProps, 'items' | 'level' > & { + items: Item[]; +};