* 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:
Sam Seay 2021-04-30 05:52:51 +12:00 committed by GitHub
parent 1edb849c67
commit b84799d470
9 changed files with 321 additions and 5 deletions

View File

@ -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

View File

@ -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';

View File

@ -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>
);
};

View File

@ -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;
}
}

View File

@ -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 >;

View File

@ -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';

View File

@ -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.';

View File

@ -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', () => {

View File

@ -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';