Skip to content

Commit c182ba8

Browse files
Pollepsjoepio
authored andcommitted
Improve Performance and accessibility of sidebar
1 parent ede0b15 commit c182ba8

File tree

7 files changed

+172
-211
lines changed

7 files changed

+172
-211
lines changed
Lines changed: 26 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -1,137 +1,54 @@
1-
import React, { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
1+
import React, { useEffect, useState } from 'react';
22
import styled from 'styled-components';
3-
import { timeoutEffect, timeoutEffects } from '../helpers/timeoutEffect';
3+
import { timeoutEffect } from '../helpers/timeoutEffect';
44
import { animationDuration } from '../styling';
55

6-
export interface CollapseProps {
7-
open: boolean;
8-
/** Animation speed in ms, Defaults to the animation duration defined in the theme */
9-
duration?: number;
6+
interface CollapseProps {
7+
open?: boolean;
108
className?: string;
119
}
1210

13-
const SPEED_MODIFIER = 1.5;
11+
// the styling file is not loaded at boot so we have to use a function here
12+
const ANIMATION_DURATION = () => animationDuration * 1.5;
1413

15-
/**
16-
* Collapsible Component that only shows `children` when `open={true}`. Animates
17-
* the transition for `{duration}`ms. Designed to work in the sidebar with
18-
* potentially deeply nested, recurring Collapse units.
19-
*/
2014
export function Collapse({
2115
open,
22-
children,
23-
duration,
2416
className,
17+
children,
2518
}: React.PropsWithChildren<CollapseProps>): JSX.Element {
26-
const node = useRef<HTMLDivElement>(null);
27-
const [initialHeight, setInitialHeight] = React.useState(0);
28-
29-
// We can't use a `useState` here.
30-
// When `measureAndSet` is used as a callback for the MutationObserver,
31-
// the scope will be outdated when the props update.
32-
// To work around this we have to use a ref in order to reference the most up to date value.
33-
const openRef = useRef(open);
34-
35-
const measureAndSet = () => {
36-
const div = node.current;
37-
38-
if (!div) {
39-
return;
40-
}
41-
42-
div.style.height = 'inital';
43-
const height = div.scrollHeight;
44-
45-
setInitialHeight(height);
46-
47-
div.style.position = 'static';
48-
49-
if (!openRef.current) {
50-
div.style.height = '0px';
51-
div.style.visibility = 'hidden';
52-
} else {
53-
div.style.height = `${height}px`;
54-
div.style.visibility = 'visible';
55-
}
56-
};
57-
58-
const mutationObserver = useRef(new MutationObserver(measureAndSet));
19+
const [mountChildren, setMountChildren] = useState(open);
5920

60-
const speed = duration ?? animationDuration * SPEED_MODIFIER;
61-
62-
// Measure the height of the element when it is added to the DOM.
63-
const onRefConnect = useCallback((div: HTMLDivElement) => {
64-
if (!div) return;
65-
66-
// @ts-ignore this works and is mutable, ts doesn't like it
67-
node.current = div;
68-
measureAndSet();
69-
}, []);
70-
71-
// Measure the height again when a child is added or removed.
7221
useEffect(() => {
73-
node.current &&
74-
mutationObserver.current.observe(node.current, { childList: true });
75-
76-
return () => mutationObserver.current.disconnect();
77-
}, []);
78-
79-
// Perform the animation when the `open` prop changes.
80-
useLayoutEffect(() => {
81-
openRef.current = open;
82-
if (!node.current) return;
83-
84-
const wrapper = node.current;
85-
86-
if (open) {
87-
wrapper.style.visibility = 'visible';
88-
wrapper.style.height = `${initialHeight}px`;
89-
90-
// after the animation has finished, remove the set height so the element can grow if it needs to.
22+
if (!open) {
9123
return timeoutEffect(() => {
92-
wrapper.style.overflowY = 'visible';
93-
wrapper.style.height = 'initial';
94-
}, speed);
95-
} else {
96-
// recalculate the height as it might have changed, then set it so it can be animated from.
97-
const newHeight = wrapper.scrollHeight;
98-
setInitialHeight(newHeight);
99-
wrapper.style.height = `${newHeight}px`;
100-
wrapper.style.overflowY = 'hidden';
101-
102-
return timeoutEffects(
103-
[
104-
() => {
105-
wrapper.style.height = '0px';
106-
},
107-
0,
108-
],
109-
[
110-
() => {
111-
wrapper.style.visibility = 'hidden';
112-
},
113-
speed,
114-
],
115-
);
24+
setMountChildren(false);
25+
}, ANIMATION_DURATION());
11626
}
27+
28+
setMountChildren(true);
11729
}, [open]);
11830

11931
return (
120-
<Wrapper ref={onRefConnect} duration={speed} className={className}>
121-
{children}
122-
</Wrapper>
32+
<GridCollapser open={open} className={className}>
33+
<CollapseInner>{mountChildren && children}</CollapseInner>
34+
</GridCollapser>
12335
);
12436
}
12537

126-
interface WrapperProps {
127-
duration: number;
38+
interface GridCollapserProps {
39+
open?: boolean;
12840
}
12941

130-
const Wrapper = styled.div<WrapperProps>`
131-
overflow-y: hidden;
132-
transition: height ${p => p.duration}ms ease-in-out;
42+
const GridCollapser = styled.div<GridCollapserProps>`
43+
display: grid;
44+
grid-template-rows: ${({ open }) => (open ? '1fr' : '0fr')};
45+
transition: grid-template-rows ${() => ANIMATION_DURATION()}ms ease-in-out;
13346
13447
@media (prefers-reduced-motion) {
13548
transition: unset;
13649
}
13750
`;
51+
52+
const CollapseInner = styled.div`
53+
overflow: hidden;
54+
`;

data-browser/src/components/Dropdown/index.tsx

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -220,32 +220,33 @@ export function DropdownMenu({
220220
menuId={menuId}
221221
/>
222222
<Menu ref={dropdownRef} isActive={isActive} x={x} y={y} id={menuId}>
223-
{normalizedItems.map((props, i) => {
224-
if (!isItem(props)) {
225-
return <ItemDivider key={i} />;
226-
}
227-
228-
const { label, onClick, helper, id, disabled, shortcut, icon } =
229-
props;
230-
231-
return (
232-
<MenuItem
233-
onClick={() => {
234-
handleClose();
235-
onClick();
236-
}}
237-
id={id}
238-
data-test={`menu-item-${id}`}
239-
disabled={disabled}
240-
key={id}
241-
helper={shortcut ? `${helper} (${shortcut})` : helper}
242-
label={label}
243-
selected={useKeys && selectedIndex === i}
244-
icon={icon}
245-
shortcut={shortcut}
246-
/>
247-
);
248-
})}
223+
{isActive &&
224+
normalizedItems.map((props, i) => {
225+
if (!isItem(props)) {
226+
return <ItemDivider key={i} />;
227+
}
228+
229+
const { label, onClick, helper, id, disabled, shortcut, icon } =
230+
props;
231+
232+
return (
233+
<MenuItem
234+
onClick={() => {
235+
handleClose();
236+
onClick();
237+
}}
238+
id={id}
239+
data-test={`menu-item-${id}`}
240+
disabled={disabled}
241+
key={id}
242+
helper={shortcut ? `${helper} (${shortcut})` : helper}
243+
label={label}
244+
selected={useKeys && selectedIndex === i}
245+
icon={icon}
246+
shortcut={shortcut}
247+
/>
248+
);
249+
})}
249250
</Menu>
250251
</>
251252
);

data-browser/src/components/SideBar/ResourceSideBar/FloatingActions.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ const Wrapper = styled.span`
5050
export const floatingHoverStyles = css`
5151
position: relative;
5252
53-
&:hover ${Wrapper}, &:focus ${Wrapper} {
53+
&:hover ${Wrapper}, &:focus-within ${Wrapper} {
5454
visibility: visible;
5555
display: inline;
5656
}

data-browser/src/components/SideBar/ResourceSideBar/ResourceSideBar.tsx

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import React, { useCallback, useEffect, useRef, useState } from 'react';
1+
import React, {
2+
useCallback,
3+
useDeferredValue,
4+
useEffect,
5+
useMemo,
6+
useRef,
7+
useState,
8+
} from 'react';
29
import { useString, useResource, useTitle, urls, useArray } from '@tomic/react';
310
import { useCurrentSubject } from '../../../helpers/useCurrentSubject';
411
import { SideBarItem } from '../SideBarItem';
@@ -30,10 +37,12 @@ export function ResourceSideBar({
3037
const spanRef = useRef<HTMLSpanElement>(null);
3138
const resource = useResource(subject, { allowIncomplete: true });
3239
const [currentUrl] = useCurrentSubject();
40+
const deferredUrl = useDeferredValue(currentUrl);
41+
3342
const [title] = useTitle(resource);
3443
const [description] = useString(resource, urls.properties.description);
3544

36-
const active = currentUrl === subject;
45+
const active = deferredUrl === subject;
3746
const [open, setOpen] = useState(active);
3847

3948
const [subResources] = useArray(resource, urls.properties.subResources);
@@ -60,6 +69,29 @@ export function ResourceSideBar({
6069
}
6170
}, [active, open]);
6271

72+
const TitleComp = useMemo(
73+
() => (
74+
<ActionWrapper>
75+
<Title subject={subject} clean active={active}>
76+
<SideBarItem
77+
onClick={handleClose}
78+
disabled={active}
79+
resource={subject}
80+
title={description}
81+
ref={spanRef}
82+
>
83+
<TextWrapper>
84+
<Icon />
85+
{title}
86+
</TextWrapper>
87+
</SideBarItem>
88+
</Title>
89+
<FloatingActions subject={subject} />
90+
</ActionWrapper>
91+
),
92+
[subject, active, handleClose, description, title],
93+
);
94+
6395
if (resource.loading) {
6496
return (
6597
<SideBarItem
@@ -95,32 +127,14 @@ export function ResourceSideBar({
95127
disabled={!hasSubResources}
96128
onStateToggle={handleDetailsToggle}
97129
data-test='resource-sidebar'
98-
title={
99-
<ActionWrapper>
100-
<Title subject={subject} clean active={active}>
101-
<SideBarItem
102-
onClick={handleClose}
103-
disabled={active}
104-
resource={subject}
105-
title={description}
106-
ref={spanRef}
107-
>
108-
<TextWrapper>
109-
<Icon />
110-
{title}
111-
</TextWrapper>
112-
</SideBarItem>
113-
</Title>
114-
<FloatingActions subject={subject} />
115-
</ActionWrapper>
116-
}
130+
title={TitleComp}
117131
>
118132
{hasSubResources &&
119133
subResources.map(child => (
120134
<ResourceSideBar
121135
subject={child}
122-
key={child}
123136
onOpen={setAndPropagateOpen}
137+
key={child}
124138
/>
125139
))}
126140
</Details>

0 commit comments

Comments
 (0)