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

New Panels component #2001

Draft
wants to merge 93 commits into
base: main
Choose a base branch
from
Draft

New Panels component #2001

wants to merge 93 commits into from

Conversation

r100-stack
Copy link
Member

@r100-stack r100-stack commented Apr 15, 2024

Changes

Fixes #653. Introduces a new Panels API based on a coding session with @mayank99 (#2001 (comment)). This API allows users to easily create a multi panel UI with panel transition animations.

Basic usage demo
const panelIdRoot = 'root';
const panelIdMoreInfo = 'more-info';

return (
  <Panels.Wrapper
    initialActiveId={panelIdRoot}
    as={Surface}
    style={{
      inlineSize: 'min(300px, 30vw)',
      blockSize: 'min(500px, 50vh)',
    }}
  >
    <Panels.Panel id={panelIdRoot} as={Surface} border={false} elevation={0}>
      <Surface.Header as={Panels.Header}>Root</Surface.Header>
      <Surface.Body as={List}>
        <ListItem>
          <Panels.Trigger for={panelIdMoreInfo}>
            <ListItem.Action>More details</ListItem.Action>
          </Panels.Trigger>
        </ListItem>
      </Surface.Body>
    </Panels.Panel>

    <Panels.Panel
      id={panelIdMoreInfo}
      as={Surface}
      border={false}
      elevation={0}
    >
      <Surface.Header as={Panels.Header}>More details</Surface.Header>
      <Surface.Body isPadded>
        <Text>Content</Text>
      </Surface.Body>
    </Panels.Panel>
  </Panels.Wrapper>
);

Notes for reviewers:

  • Since this is a new component, all names are bike-sheddable.
  • I kept everything except the docs in this PR since I felt they are all related. But I can also split this PR, if needed.

Subcomponents:

  • Panels.Wrapper wraps all the panels. An explicit size must be given to Panels.Wrapper. The initialActiveId prop is required.
  • Panels.Panel takes an id and the panel content. Match this id with a Panels.Triggers's for prop to create a link between them.
  • Panels.Trigger wraps the button to append an onClick that changes the activeId to the for prop. This component also creates a triggers map that stores a link between a <trigger + its current panel> and the panel it points to. This is useful for back navigation in Panels.Header.
  • Panels.BackButton (not exposed), goes to the previous panel (i.e. panel that has a trigger that points to the current panel).
  • Panels.Header required component to add an accessible name and also a back button (if previous panel exists) to the panel.

Requirements:

  • A panel can have only one trigger pointing to it. i.e. out of all the triggers across all panels,
    only one can point to a particular panel.
    • Why: Since we use a simple map instead of a complex navigation history stack. Since in the map we can create one <trigger ⇔ panel> mapping, only one trigger can exist.
  • The Panels.Panel within the wrapper should be in the order of the navigation. E.g.:
    <Panels.Wrapper>
      <Panels.Panel id={root} /> // Must come before moreDetails since it contains the trigger to moreDetails
      <Panels.Panel id={moreDetails}> // Must come after root since it is navigated to from root
    </Panels.Wrapper>
    • Why: Since we use scrollIntoView() for the panel sliding effect, we scroll in the same direction as in the DOM. Thus, DOM order should be equal to the navigation order

Implementation: All inactive panels are unmounted. When a trigger is clicked, the new panel is mounted, and the old panel is made inert. Focus is moved to the new panel's header if going forward or to the triggering trigger if going backwards. For the actual slide effect, we use `scrollIntoView() and scroll snapping to prevent abrupt perceived scroll jumps when DOM elements are added or removed.

Accessibility considerations:

  • Scrolling is instance when prefers-reduced-motion !== no-preference.

Instance:

  • To expose instance methods to the user, I re-used the useSynchronizeInstance from expose show()/close() methods via Dialog.useInstance() #1983. As suggested by @mayank99, I moved the useSynchronizeInstance call in an intermediate component so that we can replace useSynchronizeInstance in the future (if needed) without changing the component code.

Testing

Added e2e, unit, and image tests.

Docs

Proper docs will come in a new PR in this PR chain. That way I can write the docs based on our design decisions from this PR.

@r100-stack r100-stack self-assigned this Apr 15, 2024
@r100-stack r100-stack linked an issue Apr 15, 2024 that may be closed by this pull request
@mayank99
Copy link
Contributor

Before we get too far down this rabbithole, I just want to remind/note that this needs to be a generic pattern that shouldn't be tied to DropdownMenu. There are valid cases for a "sliding" panel that can be triggered by any arbitrary button within an InformationPanel or SideNavigation (submenu) or an AppUI widget.

Some other important requirements:

  • It should likely be implemented as a disclosure pattern.
  • It should probably move focus into the new panel when "opened".
  • Since the "original" panel is off screen, it should be hidden from keyboard/AT users.
  • It should contain a back button, which "closes" the panel and moves focus back.
  • Maybe it should close on pressing Esc? Unsure.

It might be tricky to come up with the right abstraction for this, but that's part of the exploration work.

@mayank99
Copy link
Contributor

Sharing this example from Firefox as an inspiration. It's basically a multi-level popover (rather than a menu).

I like the subtle animation and I like that it moves focus (although it's questionable that the focus is so far down the second screen. I think it should be on the back button instead).

Screen.Recording.2024-04-25.at.3.56.12.PM.mov

@mayank99
Copy link
Contributor

During a pairing session, we experimented with a generic Panels component that handles all the "panel switching" logic, and can be used anywhere (whether that's Popover or InformationPanel or Menu or an AppUI widget). It uses a simple disclosure pattern and manages focus.

Code
import {
  Flex,
  IconButton,
  List,
  ListItem,
  Surface,
  Text,
  ToggleSwitch,
} from '@itwin/itwinui-react';
import * as React from 'react';
import { useAtom, useAtomValue, useSetAtom, atom } from 'jotai';
import { SvgChevronLeft } from '@itwin/itwinui-icons-react';
import { flushSync } from 'react-dom';

const App = () => {
  const basePanelId = React.useId();
  const qualityPanelId = React.useId();
  const repeatId = React.useId();

  // Note: Will not work, because BackButton currently relies on context.
  const { goBack } = Panels.useInstance();

  return (
    <Surface style={{ display: 'inline-block' }}>
      <Panels defaultActiveId={basePanelId}>
        <Panel id={basePanelId}>
          <List>
            <ListItem>
              <Flex>
                <label htmlFor={repeatId}>Repeat</label>
                <Flex.Spacer />
                <ToggleSwitch id={repeatId} />
              </Flex>
            </ListItem>
            <ListItem>
              <Panel.Trigger for={qualityPanelId}>
                <ListItem.Action>Quality</ListItem.Action>
              </Panel.Trigger>
            </ListItem>
            <ListItem>Speed</ListItem>
            <ListItem>Loop</ListItem>
          </List>
        </Panel>

        <Panel id={qualityPanelId}>
          <Surface.Header as={Panel.Header}>Quality</Surface.Header>
          <List>
            <ListItem>
              <ListItem.Action
                onClick={() => {
                  // setQuality('240p');
                  goBack();
                }}
              >
                240p
              </ListItem.Action>
            </ListItem>
            <ListItem>360p</ListItem>
            <ListItem>480p</ListItem>
            <ListItem>720p</ListItem>
            <ListItem>1080p</ListItem>
          </List>
        </Panel>
      </Panels>
    </Surface>
  );
};

// ----------------------------------------------------------------------------

const expandedIdAtom = atom<string | undefined>(undefined);
const triggersAtom = atom(
  new Map<string, { triggerId: string; panelId: string }>(),
);

const Panels = ({
  children,
  defaultActiveId,
}: React.PropsWithChildren<any>) => {
  const [expandedId, setExpandedId] = useAtom(expandedIdAtom);

  if (expandedId === undefined) {
    setExpandedId(defaultActiveId);
  }

  return <>{children}</>;
};

const Panel = ({ children, id, ...rest }: React.PropsWithChildren<any>) => {
  const [expandedId] = useAtom(expandedIdAtom);
  return (
    <PanelIdContext.Provider value={id}>
      <div id={id} hidden={id !== expandedId} {...rest}>
        {children}
      </div>
    </PanelIdContext.Provider>
  );
};

const PanelIdContext = React.createContext('');

Panel.Header = ({ children, ...props }: React.PropsWithChildren<any>) => {
  return (
    <Flex {...props}>
      <Panel.BackButton />
      <Text
        as='h2'
        tabIndex={-1}
        // TODO: Confirm that focus moves correctly to the Text after the next panel is opened.
        // When a keyboard user triggers the panel, they should be able to continue tabbing into the panel.
        // When a screen-reader user triggers the panel, they should hear the name of the panel announced.
        //
        // Alternate idea: maybe the Panel itself could be focused. But then the panel needs a role and a label.
        ref={React.useCallback((el: HTMLElement | null) => el?.focus(), [])}
      >
        {children}
      </Text>
    </Flex>
  );
};

Panel.BackButton = () => {
  const setExpandedId = useSetAtom(expandedIdAtom);
  const panelId = React.useContext(PanelIdContext);
  const trigger = useAtomValue(triggersAtom).get(panelId);

  const goBack = () => {
    flushSync(() => setExpandedId(trigger?.panelId));

    if (trigger?.triggerId) {
      document.getElementById(trigger?.triggerId)?.focus();
    }
  };

  return (
    <IconButton
      label='Back'
      styleType='borderless'
      onClick={goBack}
      size='small'
      data-iui-shift='left'
    >
      <SvgChevronLeft />
    </IconButton>
  );
};

Panels.useInstance = () => ({
  goBack: () => {},
});

Panel.Trigger = ({
  children: childrenProp,
  for: forProp,
}: React.PropsWithChildren<{ for: string }>) => {
  const [expandedId, setExpandedId] = useAtom(expandedIdAtom);
  const [triggers, setTriggers] = useAtom(triggersAtom);
  const panelId = React.useContext(PanelIdContext);

  const children = React.Children.only(childrenProp) as any;

  const triggerFallbackId = React.useId();
  const triggerId = children?.props?.id || triggerFallbackId;

  if (triggers.get(forProp)?.triggerId !== triggerId) {
    setTriggers(new Map(triggers.set(forProp, { triggerId, panelId })));
  }

  return (
    React.isValidElement(children) &&
    React.cloneElement(children, {
      id: triggerId,
      onClick: () => setExpandedId(forProp),
      'aria-expanded': expandedId === forProp,
      'aria-controls': forProp,
    } as any)
  );
};

export default App;

@r100-stack r100-stack marked this pull request as draft September 4, 2024 16:47
packages/itwinui-react/src/core/Panels/Panels.tsx Outdated Show resolved Hide resolved
if (prevElement != null && activeElement != null) {
const isActivePanelAfterPrevPanelInDom =
prevElement?.compareDocumentPosition(activeElement) &
Node.DOCUMENT_POSITION_FOLLOWING;
Copy link
Contributor

@mayank99 mayank99 Sep 4, 2024

Choose a reason for hiding this comment

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

Such hacks should not be necessary at all. I'm not sure what you're comparing the DOM position like this when we already have a dedicated goBack function which is separate from the normal trigger action.

Copy link
Member Author

Choose a reason for hiding this comment

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

I see, I can change it. I thought to use it since it seemed more robust to use DOM positions to know the direction of movement instead of relying on a new direction parameter in changeActivePanel(). Also, I saw that it had a high browser compatibility.

Copy link
Contributor

@mayank99 mayank99 Sep 4, 2024

Choose a reason for hiding this comment

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

It is well-supported, but it is a hack - this is an API that we should almost never need to use.

Here's how I think about it:

  1. When a Panel.Trigger is clicked:
    • We store this trigger element's ID (let's call it triggeredBy). This state is only relevant for the lifecycle of the Panel.
    • We move focus into the Panel.Header.
  2. When goBack is called:
    • We move focus to triggeredBy. This should work for both Panel.BackButton and also for our consumer's custom goBack calls (e.g. they might call goBack after an element is selected from the second Panel).
    • We clear triggeredBy, since the Panel has reached the end of its lifecycle. It can be repopulated when triggered again (by the same trigger or a different trigger).

Copy link
Member Author

Choose a reason for hiding this comment

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

Removed compareDocumentPosition in favor of a simple new direction parameter in changeActivePanel (1d844a8).

I believe there are places where the code can be cleaned (e.g. similar code could be condensed somehow). Will be doing that soon.

setTriggers,
triggersRef,
panelElements: panelElementsRef.current,
panelHeaderElements: panelHeaderElementsRef.current,
Copy link
Contributor

Choose a reason for hiding this comment

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

these refs are being read during render again.

is there a reason you can't just use state?

Copy link
Member Author

Choose a reason for hiding this comment

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

I was going to leave a comment about this before making the PR ready for review.

The reason state didn't in some places work is because we are now unmounting the inactive panels. Whenever a panel is remounted, the random ids are re-created and thus the relevant objects are updated with the new ids.

But if in changeActivePanel, for example, we used the object directly as a callback dep, then even after the flushSync() call that waits for the mount to happen, the old objects (e.g. triggers) are used in the function leading to ids not found issues. Thus, I had to wrap the objects in a useLatestRef and use that latest ref within functions like changeActivePanel.

} = {},
): T {
const motionOk = getWindow()?.matchMedia?.(
'(prefers-reduced-motion: no-preference)',
Copy link
Contributor

Choose a reason for hiding this comment

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

This prefers-reduced-motion check doesn't belong in this hook. it makes the hook unnecessarily confusing.

In fact i would recommend reverting a891dcd altogether. It shouldn't be in CSS either, because it could interfere with the scroll snapping that was added in 18af31d.

The correct way to support reduced-motion is as follows:

+ const motionOk = useMediaQuery('(prefers-reduced-motion: no-preference)');

  panelElements.current[newActiveId]?.scrollIntoView({
    block: 'nearest',
    inline: 'center',
+   behavior: motionOk ? 'smooth' : 'instant',
  });

Copy link
Member Author

Choose a reason for hiding this comment

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

This prefers-reduced-motion check doesn't belong in this hook. it makes the hook unnecessarily confusing.

So where do you suggest I create motionOk? In the components that call useDelayed() and then make delay a required parameter, or something along those lines?

The correct way to support reduced-motion is as follows:

So, should we replace the getWindow()?.matchMedia?.('(prefers-reduced-motion: no-preference)',)?.matches calls in other components with useMediaQuery() in a future PR?

Copy link
Contributor

Choose a reason for hiding this comment

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

So where do you suggest I create motionOk? In the components that call useDelayed() and then make delay a required parameter, or something along those lines?

It should be called only where it's needed. In this case, the main Panels component would call it, since it only needs to be used for changing the scrollIntoView behavior. I think changing the delay might not be necessary (unconditionally leaving the panel in the DOM for 500ms should not cause any adverse effect).

So, should we replace the getWindow()?.matchMedia?.('(prefers-reduced-motion: no-preference)',)?.matches calls in other components with useMediaQuery() in a future PR?

Yeah that makes sense to me.

Copy link
Member Author

Choose a reason for hiding this comment

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

It should be called only where it's needed. In this case, the main Panels component would call it, since it only needs to be used for changing the scrollIntoView behavior.

Sounds good, made that change (c296e05).

So, should we replace the getWindow()?.matchMedia?.('(prefers-reduced-motion: no-preference)',)?.matches calls in other components with useMediaQuery() in a future PR?

Yeah that makes sense to me.

Created #2223

Copy link
Contributor

Choose a reason for hiding this comment

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

Sounds good, made that change (c296e05).

Not sure if it's on purpose but the delay is now 5000 (instead of 500) and the initial value returned by useDelayed is no longer undefined.

Copy link
Member Author

Choose a reason for hiding this comment

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

Not sure if it's on purpose but the delay is now 5000 (instead of 500)

Oops, thanks for spotting that testing leftover 😅 Reverted (6c2c2cf).

the initial value returned by useDelayed is no longer undefined.

This was intentional since in all cases expect the beginning, previousActivePanel will equal the activePanel around 500ms after the panel has changed. So, I thought that the initial previousActivePanel could also be equal to the activePanel instead of being undefined.

But if you wanted useDelayed() to not be modified based on Panels's needs, I can revert those changes and maybe even add a || activePanel so that previousActivePanel will never be null. Did that in https://github.com/iTwin/iTwinUI/pull/2001/files/6c2c2cfdd86ad14d64b65d11c6a475d9f29404b3..760391f9e75d10e85fd44d72b135941575bc2f03.

packages/itwinui-react/src/utils/hooks/useInertPolyfill.ts Outdated Show resolved Hide resolved
@r100-stack r100-stack changed the base branch from main to r/add-missing-inert-polyfills September 6, 2024 13:01
Base automatically changed from r/add-missing-inert-polyfills to main September 16, 2024 13:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Layered Drop Down Menu
3 participants