Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Panel: Improve scroll view handling when expanding #23327

Merged
merged 34 commits into from
Jul 14, 2020
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
cb813a1
Add scrollIntoView interaction for PanelBody
Jun 19, 2020
5cddfb0
Adjust PanelBody story to include scrollIntoView example
Jun 19, 2020
7311fb7
Adjust bottom padding for Layout sidebar
Jun 19, 2020
7619ffd
Allow for opened prop to control disclosure state
Jun 19, 2020
546a846
Update PanelBody tests
Jun 19, 2020
ff1c7cf
Update snapshots
Jun 19, 2020
1f183ac
Merge branch 'master' into update/panel-improve-scroll-view-handling
Jun 22, 2020
fa31367
Fix tests for PanelColorSettings + Update snapshots
Jun 22, 2020
d1fed38
Add new PanelBody props to README
Jun 22, 2020
3c8326c
Merge branch 'master' into update/panel-improve-scroll-view-handling
Jun 23, 2020
29a4949
Add react-merge-refs dependency
Jun 23, 2020
5e82406
Use react-merge-refs util
Jun 23, 2020
db594d7
Update test snapshots
Jun 23, 2020
740f69e
Merge branch 'master' into update/panel-improve-scroll-view-handling
Jun 23, 2020
ff7b3a6
Merge branch 'master' into update/panel-improve-scroll-view-handling
Jun 24, 2020
277f242
Remove disableSmoothScrollIntoView prop from PanelBody
Jun 24, 2020
85d4384
Update snapshots
Jun 24, 2020
09776c1
Remove focusable prop
Jun 24, 2020
1425782
Remove use-combined-refs - No longer needed
Jun 24, 2020
73783b1
Remove onToggle from useUpdateEffect deps array
Jun 24, 2020
e61bb11
Merge branch 'master' into update/panel-improve-scroll-view-handling
Jun 26, 2020
444bc3a
Adjust onToggle callback handling
Jun 26, 2020
a2dfe87
Merge branch 'master' into update/panel-improve-scroll-view-handling
Jun 26, 2020
8c4f6a6
Merge branch 'master' into update/panel-improve-scroll-view-handling
Jun 29, 2020
a194641
Merge branch 'master' into update/panel-improve-scroll-view-handling
Jun 30, 2020
2e5b430
Merge branch 'master' into update/panel-improve-scroll-view-handling
Jul 6, 2020
1c1ff72
Merge branch 'master' into update/panel-improve-scroll-view-handling
Jul 7, 2020
fb33dc0
Remove Reakit/Disclosure integration (for now)
Jul 7, 2020
c50d006
Update PanelBody tests
Jul 7, 2020
10fdadc
Update snapshots
Jul 7, 2020
fe70a9a
Merge branch 'master' into update/panel-improve-scroll-view-handling
Jul 7, 2020
d7c22a0
Merge branch 'master' into update/panel-improve-scroll-view-handling
Jul 8, 2020
45510e7
Fix initialOpen handling for PanelBody
Jul 8, 2020
8a35791
Remove unnecessary act wrappers in panel body tests
Jul 13, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 113 additions & 68 deletions packages/components/src/panel/body.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,94 +2,139 @@
* External dependencies
*/
import classnames from 'classnames';
import { noop } from 'lodash';
import {
useDisclosureState,
Disclosure,
DisclosureContent,
} from 'reakit/Disclosure';

/**
* WordPress dependencies
*/
import { Component, forwardRef } from '@wordpress/element';
import { useReducedMotion } from '@wordpress/compose';
import { forwardRef, useRef } from '@wordpress/element';
import { chevronUp, chevronDown } from '@wordpress/icons';

/**
* Internal dependencies
*/
import Button from '../button';
import Icon from '../icon';
import { useCombinedRefs, useUpdateEffect } from '../utils';

export class PanelBody extends Component {
constructor( props ) {
super( ...arguments );
this.state = {
opened: props.initialOpen === undefined ? true : props.initialOpen,
};
this.toggle = this.toggle.bind( this );
export function PanelBody(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has an open PR here for the refactor #23065 is this based on it?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh! Not at all. I didn't know that https://github.com/WordPress/gutenberg/pull/23065/files existed. I refactored in order to integrate with Reakit

{
children,
className,
disableSmoothScrollIntoView,
focusable,
icon,
initialOpen: initialOpenProp,
onToggle = noop,
opened,
title,
},
ref
) {
const initialOpen = useRef( initialOpenProp ).current;
const disclosure = useDisclosureState( {
visible: initialOpen !== undefined ? initialOpen : opened,
} );
const nodeRef = useRef();
const combinedRefs = useCombinedRefs( ref, nodeRef );

// Defaults to 'smooth' scrolling
// https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView
let scrollBehavior = useReducedMotion() ? 'auto' : 'smooth';

// However, this behavior can be overridden by prop
if ( disableSmoothScrollIntoView ) {
scrollBehavior = 'auto';
}

toggle( event ) {
event.preventDefault();
if ( this.props.opened === undefined ) {
this.setState( ( state ) => ( {
opened: ! state.opened,
} ) );
}
const isOpened = disclosure.visible;

if ( this.props.onToggle ) {
this.props.onToggle();
// Runs after initial render
useUpdateEffect( () => {
if ( disclosure.visible ) {
/*
* Scrolls the content into view when visible.
* This improves the UX when there are multiple stacking <PanelBody />
* components in a scrollable container.
*/
if ( nodeRef.current.scrollIntoView ) {
nodeRef.current.scrollIntoView( {
inline: 'nearest',
block: 'nearest',
behavior: scrollBehavior,
} );
}
}
}

render() {
const {
title,
children,
opened,
className,
icon,
forwardedRef,
} = this.props;
const isOpened = opened === undefined ? this.state.opened : opened;
const classes = classnames( 'components-panel__body', className, {
'is-opened': isOpened,
} );
onToggle( disclosure.visible );
}, [ disclosure.visible, onToggle, scrollBehavior ] );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The onToggle here on the deps array means that this prop should always be memoized. For example, this would make this effect run on every render:

<PanelBody onToggle={() => {}} />

The user would have to do this:

const onToggle = useCallback(() => {}, []);
<PanelBody onToggle={onToggle} />

Is this intended?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@diegohaz Thanks for pointing this out! I'll remove it from the deps array

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think removing from the deps array is not enough as you could end up with a component calling an old version of the "onToggle" prop. I solve this using refs personally. Example https://github.com/WordPress/gutenberg/pull/23394/files#diff-9db94cc958a10efcb72038925b6bc24cR57-R60

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm! Interesting :). Thanks for the suggestion! If this pattern continues to pop up, maybe we can create a util for this effect :D

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I use this helper on Reakit. The difference is that it's useLayoutEffect so it runs before all useEffects and also other useLayoutEffects if it's called first. And there's no dependency array.


useUpdateEffect( () => {
disclosure.setVisible( opened );
}, [ disclosure.setVisible, opened ] );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For me using effects to sync between disclosure state and external state doesn't seems like a great API. It seems thought l like we're forced to do it this way because of how Reakit disclosure API is modeled. I wonder personally if the Reakit Disclosure API could be made more friendly for "controlled components". cc @diegohaz

Copy link
Member

@diegohaz diegohaz Jun 24, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useDisclosureState (and other Reakit state hooks) is analogous to React.useState. The argument you pass in is the initial state, and you would have to call setState to update it from an external prop.

If you use React.useState inside a component, but you want to make this component optionally controlled, you would have to do this:

function OptionallyControlledComponent(props) {
  const [visible, setVisible] = React.useState(props.visible);

  if (props.visible !== undefined && props.visible !== visible) {
    // React will bail out from rendering since this is called in the render phase
    setVisible(props.visible);
  }

  ...
}

This is basically getDerivedStateFromProps in function components. You can do the same with useDisclosureState, there's no need for useEffect:

function OptionallyControlledComponent(props) {
  const disclosure = useDisclosureState({ visible: props.visible });
  
  if (props.visible !== undefined && props.visible !== disclosure.visible) {
    disclosure.setVisible(props.visible);
  }
  
  return (
    <>
      <Disclosure {...disclosure}>Toggle</Disclosure>
      <DisclosureContent {...disclosure}>Content</DisclosureContent>
    </>
  );
}

In both cases above you would be able to pass a visible prop to have a controlled component:

<OptionallyControlledComponent visible />

Or pass nothing, and this would be an uncontrolled component:

<OptionallyControlledComponent />

You can even add a defaultVisible prop that would be passed to useState/useDisclosureState so it works exactly as a React uncontrolled form element (with value/defaultValue):

<OptionallyControlledComponent defaultVisible />

But you can always opt out from using the state hook inside the component and make it fully controlled by passing the required state props to the components:

function ControlledComponent(props) {
  const baseId = useInstanceId();
  return (
    <>
      <Disclosure
        visible={props.visible}
        baseId={baseId}
        toggle={props.onToggle}
      >
        Toggle
      </Disclosure>
      <DisclosureContent baseId={baseId} visible={props.visible}>Content</DisclosureContent>
    </>
  );
}

You can find the list of state props here:

Screenshot of the page on the link above

Unfortunately, it's still not showing which props are required and optional. This is something we should improve in the documentation. But, if the editor supports TypeScript (like VSCode), you'll see the required props without the ? symbol. This screenshot was taken from the Gutenberg project:

Screenshot of VSCode showing the required props from the Disclosure component in an autocomplete dropdown

Here's a CodeSandbox with some examples: https://codesandbox.io/s/controlled-and-uncontrolled-reakit-components-rgwck

That said, it's not all set in stone. There's an open issue about this (ariakit/ariakit#487). Because of the consistency with React.useState, the discussion there is leaning more towards maintaining the current API.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@diegohaz I just tried out the OptionallyControlledComponent implementation. It resulted in some undesired recursive action 🙈

Screen Capture on 2020-06-24 at 13-21-28

(The GIF doesn't fully capture it. It was flipping REALLY fast)

The component seems to be pretty happy with the useUpdatedEffect wrapper:

	useUpdateEffect( () => {
		disclosure.setVisible( opened );
	}, [ disclosure.setVisible, opened ] );

I hear where @youknowriad is coming from.
I've noticed and felt the challenge of creating an optimal and seamless "Optionally Controlled Component" experience where the consumer doesn't have to do too much to manage state and props.

With that said, I'm okay with the current implementation, with the idea of smoothening it out later.

I think my upcoming updates for the useControlledState hook may help?
https://github.com/WordPress/gutenberg/blob/d510d27c32bbdf3fade4c4317d38ddd5f04dcf68/packages/components/src/utils/hooks/use-controlled-state.js

That being part of this PR:
#23006

Copy link
Contributor

@youknowriad youknowriad Jun 24, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to clarify, i'm ok with the current implementation too but I do believe both useState and useDisclosureState favor local state where in our components (and I believe most UI libraries but I'm not sure), we don't build "optionally controlled components", I feel we default to "controlled components" and the uncontrolled ones are the exception.

In pure React, you don't need useState and effects to achieve that you just pass the props down.

Copy link
Member

@diegohaz diegohaz Jun 24, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ItsJonQ I believe it's because you're calling onToggle whenever disclosure.visible changes, which changes opened, which then changes disclosure.visible again and this keeps happening...

Take a look at the examples here: https://codesandbox.io/s/controlled-and-uncontrolled-reakit-components-rgwck


const classes = classnames( 'components-panel__body', className, {
'is-opened': isOpened,
} );

return (
<div className={ classes } ref={ combinedRefs }>
<PanelBodyTitle
focusable={ focusable }
title={ title }
icon={ icon }
{ ...disclosure }
/>
<DisclosureContent { ...disclosure }>
{ children }
</DisclosureContent>
</div>
);
}

const PanelBodyTitle = forwardRef(
( { focusable, isOpened, icon, title, ...props }, ref ) => {
if ( ! title ) return null;

return (
<div className={ classes } ref={ forwardedRef }>
{ !! title && (
<h2 className="components-panel__body-title">
<Button
className="components-panel__body-toggle"
onClick={ this.toggle }
aria-expanded={ isOpened }
>
{ /*
Firefox + NVDA don't announce aria-expanded because the browser
repaints the whole element, so this wrapping span hides that.
*/ }
<span aria-hidden="true">
<Icon
className="components-panel__arrow"
icon={ isOpened ? chevronUp : chevronDown }
/>
</span>
{ title }
{ icon && (
<Icon
icon={ icon }
className="components-panel__icon"
size={ 20 }
/>
) }
</Button>
</h2>
) }
{ isOpened && children }
</div>
<h2 className="components-panel__body-title">
<Disclosure
as={ Button }
className="components-panel__body-toggle"
focusable={ focusable }
ref={ ref }
{ ...props }
>
{ /*
Firefox + NVDA don't announce aria-expanded because the browser
repaints the whole element, so this wrapping span hides that.
*/ }
<span aria-hidden="true">
<Icon
className="components-panel__arrow"
icon={ isOpened ? chevronUp : chevronDown }
/>
</span>
{ title }
{ icon && (
<Icon
icon={ icon }
className="components-panel__icon"
size={ 20 }
/>
) }
</Disclosure>
</h2>
);
}
}
);

const forwardedPanelBody = ( props, ref ) => {
return <PanelBody { ...props } forwardedRef={ ref } />;
};
forwardedPanelBody.displayName = 'PanelBody';
const ForwardedComponent = forwardRef( PanelBody );

export default forwardRef( forwardedPanelBody );
export default ForwardedComponent;
58 changes: 44 additions & 14 deletions packages/components/src/panel/stories/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,21 +26,31 @@ export const _default = () => {
};

export const multipleBodies = () => {
const body1Title = text( '1: Body Title', 'First Settings' );
const body2Title = text( '2: Body Title', 'Second Settings' );
const body1Open = boolean( '1: Opened', true );
const body2Open = boolean( '2: Opened', false );
const row1Text = text( '1: Row Text', 'My Panel Inputs and Labels' );
const row2Text = text( '2: Row Text', 'My Panel Inputs and Labels' );
return (
<Panel header="My Panel">
<PanelBody title={ body1Title } opened={ body1Open }>
<PanelRow>{ row1Text }</PanelRow>
</PanelBody>
<PanelBody title={ body2Title } opened={ body2Open }>
<PanelRow>{ row2Text }</PanelRow>
</PanelBody>
</Panel>
<ScrollableContainer>
<Panel header="My Panel">
<PanelBody title="First Settings">
<PanelRow>
<Placeholder height={ 250 } />
</PanelRow>
</PanelBody>
<PanelBody title="Second Settings" initialOpen={ false }>
<PanelRow>
<Placeholder height={ 400 } />
</PanelRow>
</PanelBody>
<PanelBody title="Third Settings" initialOpen={ false }>
<PanelRow>
<Placeholder height={ 600 } />
</PanelRow>
</PanelBody>
<PanelBody title="Fourth Settings" initialOpen={ false }>
<PanelRow>
<Placeholder />
</PanelRow>
</PanelBody>
</Panel>
</ScrollableContainer>
);
};

Expand All @@ -57,3 +67,23 @@ export const withIcon = () => {
</Panel>
);
};

function ScrollableContainer( { children } ) {
return (
<div
style={ {
width: 300,
height: '100vh',
overflowY: 'auto',
margin: 'auto',
boxShadow: '0 0 0 1px #ddd inset',
} }
>
{ children }
</div>
);
}

function Placeholder( { height = 200 } ) {
return <div style={ { background: '#ddd', height, width: '100%' } } />;
}
Loading