-
Notifications
You must be signed in to change notification settings - Fork 38
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
base: main
Are you sure you want to change the base?
New Panels
component
#2001
Conversation
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 Some other important requirements:
It might be tricky to come up with the right abstraction for this, but that's part of the exploration work. |
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 |
During a pairing session, we experimented with a generic Codeimport {
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; |
Addressed TODOs.
Controlled mode. Don't show BackButton in Header when not needed. Don't go back when no prev page exists. Demos. Basic MultiPanelInformationPanel.
Deleted TODOs.
if (prevElement != null && activeElement != null) { | ||
const isActivePanelAfterPrevPanelInDom = | ||
prevElement?.compareDocumentPosition(activeElement) & | ||
Node.DOCUMENT_POSITION_FOLLOWING; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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:
- 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
.
- We store this trigger element's ID (let's call it
- When
goBack
is called:- We move focus to
triggeredBy
. This should work for bothPanel.BackButton
and also for our consumer's customgoBack
calls (e.g. they might callgoBack
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).
- We move focus to
There was a problem hiding this comment.
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.
- Allow changing panels during transition
setTriggers, | ||
triggersRef, | ||
panelElements: panelElementsRef.current, | ||
panelHeaderElements: panelHeaderElementsRef.current, |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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)', |
There was a problem hiding this comment.
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',
});
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 calluseDelayed()
and then makedelay
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 withuseMediaQuery()
in a future PR?
Yeah that makes sense to me.
There was a problem hiding this comment.
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 thescrollIntoView
behavior
.
Sounds good, made that change (c296e05).
So, should we replace the
getWindow()?.matchMedia?.('(prefers-reduced-motion: no-preference)',)?.matches
calls in other components withuseMediaQuery()
in a future PR?Yeah that makes sense to me.
Created #2223
There was a problem hiding this comment.
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
.
There was a problem hiding this comment.
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 now5000
(instead of500
)
Oops, thanks for spotting that testing leftover 😅 Reverted (6c2c2cf).
the initial value returned by
useDelayed
is no longerundefined
.
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.
…nto rohan/layered-dropdown-menu
Co-authored-by: Mayank <[email protected]>
Co-authored-by: Mayank <[email protected]>
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
Notes for reviewers:
Subcomponents:
Panels.Wrapper
wraps all the panels. An explicit size must be given toPanels.Wrapper
. TheinitialActiveId
prop is required.Panels.Panel
takes anid
and the panel content. Match thisid
with aPanels.Triggers
'sfor
prop to create a link between them.Panels.Trigger
wraps the button to append anonClick
that changes theactiveId
to thefor
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 inPanels.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:
only one can point to a particular panel.
Panels.Panel
within the wrapper should be in the order of the navigation. E.g.: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 orderImplementation: 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:
prefers-reduced-motion !== no-preference
.Instance:
useSynchronizeInstance
from exposeshow()
/close()
methods viaDialog.useInstance()
#1983. As suggested by @mayank99, I moved theuseSynchronizeInstance
call in an intermediate component so that we can replaceuseSynchronizeInstance
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.