Expand / collapse list items component (https://github.com/woocommerce/woocommerce-admin/pull/6869)
* Prototype the basic functionality of the list collapse component. * Further work on the component. * Add icons and some basic styling. Fix exports to include 'Experimental' * Add CSSTransition to collapsible list item * Add tests for collapsible component * Add changelog * Add collapse/expand callbacks * Add tests for callbacks * Replaced collapsible list item with a collapsible list instead * Updated to use calculated height versus absolute height * Removed fallback and removed animation from collapse footer Co-authored-by: Lourens Schep <lourensschep@gmail.com>
This commit is contained in:
parent
1edb849c67
commit
b84799d470
|
@ -1,5 +1,6 @@
|
||||||
# Unreleased
|
# Unreleased
|
||||||
|
|
||||||
|
- Add new (experimental) collapsible list item to collapse list items. #6869
|
||||||
- SelectControl: fix display of multiple selections without inline tags. #6862
|
- SelectControl: fix display of multiple selections without inline tags. #6862
|
||||||
- Add new (experimental) list, and add depreciation notice for the current list. #6787
|
- Add new (experimental) list, and add depreciation notice for the current list. #6787
|
||||||
- Force `<SearchListItem>` form elements id to be unique. #6871
|
- Force `<SearchListItem>` form elements id to be unique. #6871
|
||||||
|
|
|
@ -29,6 +29,8 @@ export {
|
||||||
default as List,
|
default as List,
|
||||||
ExperimentalList as __experimentalList,
|
ExperimentalList as __experimentalList,
|
||||||
ExperimentalListItem as __experimentalListItem,
|
ExperimentalListItem as __experimentalListItem,
|
||||||
|
ExperimentalListItemCollapse as __experimentalListItemCollapse,
|
||||||
|
ExperimentalCollapsibleList as __experimentalCollapsibleList,
|
||||||
} from './list';
|
} from './list';
|
||||||
export { default as MenuItem } from './ellipsis-menu/menu-item';
|
export { default as MenuItem } from './ellipsis-menu/menu-item';
|
||||||
export { default as MenuTitle } from './ellipsis-menu/menu-title';
|
export { default as MenuTitle } from './ellipsis-menu/menu-title';
|
||||||
|
|
|
@ -0,0 +1,127 @@
|
||||||
|
/**
|
||||||
|
* External dependencies
|
||||||
|
*/
|
||||||
|
import { Dashicon } from '@wordpress/components';
|
||||||
|
import { useState, useCallback, Children } from '@wordpress/element';
|
||||||
|
import { Transition } from 'react-transition-group';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal dependencies
|
||||||
|
*/
|
||||||
|
import { ExperimentalListItem } from '../experimental-list-item';
|
||||||
|
import { ListProps, ExperimentalList } from '../experimental-list';
|
||||||
|
|
||||||
|
type CollapsibleListProps = {
|
||||||
|
collapseLabel: string;
|
||||||
|
expandLabel: string;
|
||||||
|
collapsed?: boolean;
|
||||||
|
show?: number;
|
||||||
|
onCollapse?: () => void;
|
||||||
|
onExpand?: () => void;
|
||||||
|
} & ListProps;
|
||||||
|
|
||||||
|
const defaultStyle = {
|
||||||
|
transition: `max-height 500ms ease-in-out`,
|
||||||
|
maxHeight: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getContainerHeight( collapseContainer: HTMLDivElement | null ) {
|
||||||
|
let containerHeight = 0;
|
||||||
|
if ( collapseContainer ) {
|
||||||
|
for ( const child of collapseContainer.children ) {
|
||||||
|
containerHeight += child.clientHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return containerHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ExperimentalCollapsibleList: React.FC< CollapsibleListProps > = ( {
|
||||||
|
children,
|
||||||
|
collapsed = true,
|
||||||
|
collapseLabel,
|
||||||
|
expandLabel,
|
||||||
|
show = 0,
|
||||||
|
onCollapse,
|
||||||
|
onExpand,
|
||||||
|
...listProps
|
||||||
|
} ): JSX.Element => {
|
||||||
|
const [ isCollapsed, setCollapsed ] = useState( collapsed );
|
||||||
|
const [ containerHeight, setContainerHeight ] = useState( 0 );
|
||||||
|
const collapseContainerRef = useCallback(
|
||||||
|
( containerElement: HTMLDivElement ) => {
|
||||||
|
if ( containerElement ) {
|
||||||
|
setContainerHeight( getContainerHeight( containerElement ) );
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[ children ]
|
||||||
|
);
|
||||||
|
|
||||||
|
const triggerCallbacks = ( newCollapseValue: boolean ) => {
|
||||||
|
if ( onCollapse && newCollapseValue ) {
|
||||||
|
onCollapse();
|
||||||
|
}
|
||||||
|
if ( onExpand && ! newCollapseValue ) {
|
||||||
|
onExpand();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clickHandler = useCallback( () => {
|
||||||
|
setCollapsed( ! isCollapsed );
|
||||||
|
triggerCallbacks( ! isCollapsed );
|
||||||
|
}, [ isCollapsed ] );
|
||||||
|
|
||||||
|
let shownChildren: React.ReactNode[] = [];
|
||||||
|
let hiddenChildren = Children.toArray( children );
|
||||||
|
if ( show > 0 ) {
|
||||||
|
shownChildren = hiddenChildren.slice( 0, show );
|
||||||
|
hiddenChildren = hiddenChildren.slice( show );
|
||||||
|
}
|
||||||
|
|
||||||
|
const transitionStyles = {
|
||||||
|
entered: { maxHeight: containerHeight },
|
||||||
|
entering: { maxHeight: containerHeight },
|
||||||
|
exiting: { maxHeight: 0 },
|
||||||
|
exited: { maxHeight: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ExperimentalList { ...listProps }>
|
||||||
|
{ shownChildren }
|
||||||
|
<Transition
|
||||||
|
timeout={ 500 }
|
||||||
|
in={ ! isCollapsed }
|
||||||
|
mountOnEnter
|
||||||
|
unmountOnExit
|
||||||
|
>
|
||||||
|
{ ( state: 'entering' | 'entered' | 'exiting' | 'exited' ) => (
|
||||||
|
<div
|
||||||
|
ref={ collapseContainerRef }
|
||||||
|
style={ {
|
||||||
|
...defaultStyle,
|
||||||
|
...transitionStyles[ state ],
|
||||||
|
} }
|
||||||
|
>
|
||||||
|
{ hiddenChildren }
|
||||||
|
</div>
|
||||||
|
) }
|
||||||
|
</Transition>
|
||||||
|
{ hiddenChildren.length > 0 ? (
|
||||||
|
<ExperimentalListItem
|
||||||
|
className="list-item-collapse"
|
||||||
|
onClick={ clickHandler }
|
||||||
|
animation="none"
|
||||||
|
>
|
||||||
|
<p>{ isCollapsed ? expandLabel : collapseLabel }</p>
|
||||||
|
|
||||||
|
<Dashicon
|
||||||
|
className="list-item-collapse__icon"
|
||||||
|
icon={
|
||||||
|
isCollapsed ? 'arrow-down-alt2' : 'arrow-up-alt2'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ExperimentalListItem>
|
||||||
|
) : null }
|
||||||
|
</ExperimentalList>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,11 @@
|
||||||
|
.woocommerce-list__item.list-item-collapse {
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item-collapse__icon-container {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
|
@ -11,7 +11,7 @@ import type { ListAnimation } from './experimental-list-item';
|
||||||
|
|
||||||
type ListType = 'ol' | 'ul';
|
type ListType = 'ol' | 'ul';
|
||||||
|
|
||||||
type ListProps = {
|
export type ListProps = {
|
||||||
listType?: ListType;
|
listType?: ListType;
|
||||||
animation?: ListAnimation;
|
animation?: ListAnimation;
|
||||||
} & React.HTMLAttributes< HTMLElement >;
|
} & React.HTMLAttributes< HTMLElement >;
|
||||||
|
|
|
@ -115,3 +115,4 @@ export default List;
|
||||||
|
|
||||||
export { ExperimentalListItem } from './experimental-list-item';
|
export { ExperimentalListItem } from './experimental-list-item';
|
||||||
export { ExperimentalList } from './experimental-list';
|
export { ExperimentalList } from './experimental-list';
|
||||||
|
export { ExperimentalCollapsibleList } from './collapsible-list';
|
||||||
|
|
|
@ -7,7 +7,11 @@ import { withConsole } from '@storybook/addon-console';
|
||||||
/**
|
/**
|
||||||
* Internal dependencies
|
* Internal dependencies
|
||||||
*/
|
*/
|
||||||
import List, { ExperimentalList, ExperimentalListItem } from '../';
|
import List, {
|
||||||
|
ExperimentalList,
|
||||||
|
ExperimentalListItem,
|
||||||
|
ExperimentalCollapsibleList,
|
||||||
|
} from '../';
|
||||||
import './style.scss';
|
import './style.scss';
|
||||||
|
|
||||||
function logItemClick( event ) {
|
function logItemClick( event ) {
|
||||||
|
@ -161,4 +165,47 @@ export const ExperimentalListExample = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
ExperimentalList.storyName = 'ExperimentalList / ExperimentalListItem.';
|
ExperimentalListExample.storyName = 'ExperimentalList / ExperimentalListItem.';
|
||||||
|
|
||||||
|
export const ExperimentalCollapsibleListExample = () => {
|
||||||
|
return (
|
||||||
|
<ExperimentalCollapsibleList
|
||||||
|
collapseLabel="Show less"
|
||||||
|
expandLabel="Show more items"
|
||||||
|
show={ 2 }
|
||||||
|
onCollapse={ () => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log( 'collapsed' );
|
||||||
|
} }
|
||||||
|
onExpand={ () => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log( 'expanded' );
|
||||||
|
} }
|
||||||
|
>
|
||||||
|
<ExperimentalListItem onClick={ () => {} }>
|
||||||
|
<div>Any markup can go here.</div>
|
||||||
|
</ExperimentalListItem>
|
||||||
|
<ExperimentalListItem onClick={ () => {} }>
|
||||||
|
<div>Any markup can go here.</div>
|
||||||
|
</ExperimentalListItem>
|
||||||
|
<ExperimentalListItem onClick={ () => {} }>
|
||||||
|
<div>
|
||||||
|
Any markup can go here.
|
||||||
|
<br />
|
||||||
|
Bigger task item
|
||||||
|
<br />
|
||||||
|
Another line
|
||||||
|
</div>
|
||||||
|
</ExperimentalListItem>
|
||||||
|
<ExperimentalListItem onClick={ () => {} }>
|
||||||
|
<div>Any markup can go here.</div>
|
||||||
|
</ExperimentalListItem>
|
||||||
|
<ExperimentalListItem onClick={ () => {} }>
|
||||||
|
<div>Any markup can go here.</div>
|
||||||
|
</ExperimentalListItem>
|
||||||
|
</ExperimentalCollapsibleList>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ExperimentalCollapsibleListExample.storyName =
|
||||||
|
'ExperimentalList with ExperimentalCollapsibleListItem.';
|
||||||
|
|
|
@ -7,13 +7,21 @@ jest.mock( '../list-item', () => ( {
|
||||||
/**
|
/**
|
||||||
* External dependencies
|
* External dependencies
|
||||||
*/
|
*/
|
||||||
import { render, screen } from '@testing-library/react';
|
import {
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitForElementToBeRemoved,
|
||||||
|
} from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal dependencies
|
* Internal dependencies
|
||||||
*/
|
*/
|
||||||
import List, { ExperimentalList, ExperimentalListItem } from '../index';
|
import List, {
|
||||||
|
ExperimentalList,
|
||||||
|
ExperimentalListItem,
|
||||||
|
ExperimentalCollapsibleList,
|
||||||
|
} from '../index';
|
||||||
import { handleKeyDown } from '../list-item';
|
import { handleKeyDown } from '../list-item';
|
||||||
|
|
||||||
describe( 'List', () => {
|
describe( 'List', () => {
|
||||||
|
@ -145,6 +153,124 @@ describe( 'List', () => {
|
||||||
expect( item ).toHaveAttribute( 'tabindex', '0' );
|
expect( item ).toHaveAttribute( 'tabindex', '0' );
|
||||||
} );
|
} );
|
||||||
} );
|
} );
|
||||||
|
|
||||||
|
describe( 'ExperimentalListItemCollapse', () => {
|
||||||
|
it( 'should not render its children intially, but an extra list footer with show text', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ExperimentalCollapsibleList
|
||||||
|
collapseLabel="Show less"
|
||||||
|
expandLabel="Show more items"
|
||||||
|
>
|
||||||
|
<div>Test</div>
|
||||||
|
</ExperimentalCollapsibleList>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect( container ).not.toHaveTextContent( 'Test' );
|
||||||
|
expect( container ).toHaveTextContent( 'Show more items' );
|
||||||
|
} );
|
||||||
|
|
||||||
|
it( 'should render list items when footer is clicked and trigger onExpand', () => {
|
||||||
|
const onExpand = jest.fn();
|
||||||
|
const onCollapse = jest.fn();
|
||||||
|
const { container } = render(
|
||||||
|
<ExperimentalCollapsibleList
|
||||||
|
collapseLabel="Show less"
|
||||||
|
expandLabel="Show more items"
|
||||||
|
onExpand={ onExpand }
|
||||||
|
onCollapse={ onCollapse }
|
||||||
|
>
|
||||||
|
<div>Test</div>
|
||||||
|
<div>Test 2</div>
|
||||||
|
</ExperimentalCollapsibleList>
|
||||||
|
);
|
||||||
|
|
||||||
|
const listItem = container.querySelector(
|
||||||
|
'.list-item-collapse'
|
||||||
|
);
|
||||||
|
|
||||||
|
userEvent.click( listItem );
|
||||||
|
expect( container ).toHaveTextContent( 'Test' );
|
||||||
|
expect( container ).toHaveTextContent( 'Test 2' );
|
||||||
|
expect( container ).not.toHaveTextContent( 'Show more items' );
|
||||||
|
expect( container ).toHaveTextContent( 'Show less' );
|
||||||
|
expect( onExpand ).toHaveBeenCalled();
|
||||||
|
expect( onCollapse ).not.toHaveBeenCalled();
|
||||||
|
} );
|
||||||
|
|
||||||
|
it( 'should render minimum children if minChildrenToShow is set and show the rest on expand', () => {
|
||||||
|
const onExpand = jest.fn();
|
||||||
|
const onCollapse = jest.fn();
|
||||||
|
const { container } = render(
|
||||||
|
<ExperimentalCollapsibleList
|
||||||
|
collapseLabel="Show less"
|
||||||
|
expandLabel="Show more items"
|
||||||
|
onExpand={ onExpand }
|
||||||
|
onCollapse={ onCollapse }
|
||||||
|
show={ 2 }
|
||||||
|
>
|
||||||
|
<div>Test</div>
|
||||||
|
<div>Test 2</div>
|
||||||
|
<div>Test 3</div>
|
||||||
|
<div>Test 4</div>
|
||||||
|
</ExperimentalCollapsibleList>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect( container ).toHaveTextContent( 'Test' );
|
||||||
|
expect( container ).toHaveTextContent( 'Test 2' );
|
||||||
|
expect( container ).not.toHaveTextContent( 'Test 3' );
|
||||||
|
expect( container ).not.toHaveTextContent( 'Test 4' );
|
||||||
|
const listItem = container.querySelector(
|
||||||
|
'.list-item-collapse'
|
||||||
|
);
|
||||||
|
|
||||||
|
userEvent.click( listItem );
|
||||||
|
expect( container ).toHaveTextContent( 'Test' );
|
||||||
|
expect( container ).toHaveTextContent( 'Test 2' );
|
||||||
|
expect( container ).toHaveTextContent( 'Test 3' );
|
||||||
|
expect( container ).toHaveTextContent( 'Test 4' );
|
||||||
|
expect( container ).not.toHaveTextContent( 'Show more items' );
|
||||||
|
expect( container ).toHaveTextContent( 'Show less' );
|
||||||
|
expect( onExpand ).toHaveBeenCalled();
|
||||||
|
expect( onCollapse ).not.toHaveBeenCalled();
|
||||||
|
} );
|
||||||
|
|
||||||
|
it( 'should correctly toggle the list', async () => {
|
||||||
|
const onExpand = jest.fn();
|
||||||
|
const onCollapse = jest.fn();
|
||||||
|
const { container } = render(
|
||||||
|
<ExperimentalCollapsibleList
|
||||||
|
collapseLabel="Show less"
|
||||||
|
expandLabel="Show more items"
|
||||||
|
onExpand={ onExpand }
|
||||||
|
onCollapse={ onCollapse }
|
||||||
|
>
|
||||||
|
<div id="test">Test</div>
|
||||||
|
<div>Test 2</div>
|
||||||
|
</ExperimentalCollapsibleList>
|
||||||
|
);
|
||||||
|
|
||||||
|
let listItem = container.querySelector( '.list-item-collapse' );
|
||||||
|
|
||||||
|
userEvent.click( listItem );
|
||||||
|
expect( container ).toHaveTextContent( 'Test' );
|
||||||
|
expect( container ).toHaveTextContent( 'Test 2' );
|
||||||
|
expect( container ).not.toHaveTextContent( 'Show more items' );
|
||||||
|
expect( container ).toHaveTextContent( 'Show less' );
|
||||||
|
|
||||||
|
listItem = container.querySelector( '.list-item-collapse' );
|
||||||
|
|
||||||
|
userEvent.click( listItem );
|
||||||
|
await waitForElementToBeRemoved(
|
||||||
|
container.querySelector( '#test' )
|
||||||
|
);
|
||||||
|
expect( container ).not.toHaveTextContent( 'Test' );
|
||||||
|
expect( container ).not.toHaveTextContent( 'Test 2' );
|
||||||
|
expect( container ).toHaveTextContent( 'Show more items' );
|
||||||
|
expect( container ).not.toHaveTextContent( 'Show less' );
|
||||||
|
expect( onExpand ).toHaveBeenCalledTimes( 1 );
|
||||||
|
expect( onCollapse ).toHaveBeenCalledTimes( 1 );
|
||||||
|
} );
|
||||||
|
} );
|
||||||
} );
|
} );
|
||||||
|
|
||||||
describe( 'Legacy List', () => {
|
describe( 'Legacy List', () => {
|
||||||
|
|
|
@ -18,6 +18,7 @@
|
||||||
@import 'gravatar/style.scss';
|
@import 'gravatar/style.scss';
|
||||||
@import 'image-upload/style.scss';
|
@import 'image-upload/style.scss';
|
||||||
@import 'list/style.scss';
|
@import 'list/style.scss';
|
||||||
|
@import 'list/collapsible-list/style.scss';
|
||||||
@import 'order-status/style.scss';
|
@import 'order-status/style.scss';
|
||||||
@import 'pagination/style.scss';
|
@import 'pagination/style.scss';
|
||||||
@import 'pill/style.scss';
|
@import 'pill/style.scss';
|
||||||
|
|
Loading…
Reference in New Issue