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
|
||||
|
||||
- Add new (experimental) collapsible list item to collapse list items. #6869
|
||||
- SelectControl: fix display of multiple selections without inline tags. #6862
|
||||
- Add new (experimental) list, and add depreciation notice for the current list. #6787
|
||||
- Force `<SearchListItem>` form elements id to be unique. #6871
|
||||
|
|
|
@ -29,6 +29,8 @@ export {
|
|||
default as List,
|
||||
ExperimentalList as __experimentalList,
|
||||
ExperimentalListItem as __experimentalListItem,
|
||||
ExperimentalListItemCollapse as __experimentalListItemCollapse,
|
||||
ExperimentalCollapsibleList as __experimentalCollapsibleList,
|
||||
} from './list';
|
||||
export { default as MenuItem } from './ellipsis-menu/menu-item';
|
||||
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 ListProps = {
|
||||
export type ListProps = {
|
||||
listType?: ListType;
|
||||
animation?: ListAnimation;
|
||||
} & React.HTMLAttributes< HTMLElement >;
|
||||
|
|
|
@ -115,3 +115,4 @@ export default List;
|
|||
|
||||
export { ExperimentalListItem } from './experimental-list-item';
|
||||
export { ExperimentalList } from './experimental-list';
|
||||
export { ExperimentalCollapsibleList } from './collapsible-list';
|
||||
|
|
|
@ -7,7 +7,11 @@ import { withConsole } from '@storybook/addon-console';
|
|||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import List, { ExperimentalList, ExperimentalListItem } from '../';
|
||||
import List, {
|
||||
ExperimentalList,
|
||||
ExperimentalListItem,
|
||||
ExperimentalCollapsibleList,
|
||||
} from '../';
|
||||
import './style.scss';
|
||||
|
||||
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
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
waitForElementToBeRemoved,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import List, { ExperimentalList, ExperimentalListItem } from '../index';
|
||||
import List, {
|
||||
ExperimentalList,
|
||||
ExperimentalListItem,
|
||||
ExperimentalCollapsibleList,
|
||||
} from '../index';
|
||||
import { handleKeyDown } from '../list-item';
|
||||
|
||||
describe( 'List', () => {
|
||||
|
@ -145,6 +153,124 @@ describe( 'List', () => {
|
|||
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', () => {
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
@import 'gravatar/style.scss';
|
||||
@import 'image-upload/style.scss';
|
||||
@import 'list/style.scss';
|
||||
@import 'list/collapsible-list/style.scss';
|
||||
@import 'order-status/style.scss';
|
||||
@import 'pagination/style.scss';
|
||||
@import 'pill/style.scss';
|
||||
|
|
Loading…
Reference in New Issue