-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathStickyExpandableBlock.tsx
147 lines (127 loc) · 4.51 KB
/
StickyExpandableBlock.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
/*---------------------------------------------------------------------------------------------
* Copyright (c) Bentley Systems, Incorporated. All rights reserved.
* See LICENSE.md in the project root for license terms and full copyright notice.
*--------------------------------------------------------------------------------------------*/
import { ExpandableBlock } from "@itwin/itwinui-react";
import {
useLayoutEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactElement, type ReactNode,
type RefObject,
} from "react";
import "./StickyExpandableBlock.css";
interface StickyExpandableBlockProps {
/** Expandable block title. */
title: ReactNode;
/** Icon that goes after the expandable block title. */
endIcon?: ReactNode | undefined;
/** Forwarded to iTwinUI `<ExpandableBlock />`. */
isExpanded?: boolean | undefined;
/** Forwarded to iTwinUI `<ExpandableBlock />`. */
onToggle?: ((expanded: boolean) => void) | undefined;
/** `className` of the wrapping container. */
className?: string | undefined;
/** `className` of the title container. */
titleClassName?: string | undefined;
/** Expandable block content. */
children?: ReactNode | undefined;
}
/**
* Alternative to <ExpandableBlock /> component from iTwinUI. Its label sticks to the top of scroll container when user
* scrolls down.
*
* @example
* <StickyExpandableBlock title="Block">
* Content
* </StickyExpandableBlock>
*/
export function StickyExpandableBlock(props: StickyExpandableBlockProps): ReactElement {
// Prevent clicks on the end icon toggling expansion state
const handleGroupMenuClick = (event: MouseEvent) => {
event.stopPropagation();
};
// When clicking the label while it's stuck at the top, we want the label to remain visible after the expandable block
// collapses
const scrollbackRef = useRef<HTMLDivElement>(null);
const handleExpandToggle = (expanded: boolean) => {
props.onToggle?.(expanded);
if (!expanded) {
scrollbackRef.current?.scrollIntoView({ block: "nearest" });
}
};
const stickyRef = useRef<HTMLDivElement>(null);
const stuck = useSticky(stickyRef);
// We are pretending that expandable block title is a button element for accessibility
const handleKeyDown = (e: KeyboardEvent) => {
if (e.target === e.currentTarget && e.key === "Enter" || e.key === " ") {
handleExpandToggle(!props.isExpanded);
// We don't want spacebar to cause scroll down
e.preventDefault();
}
};
return (
<ExpandableBlock.Wrapper
className={props.className}
styleType="borderless"
isExpanded={props.isExpanded}
onToggle={handleExpandToggle}
>
<div ref={scrollbackRef} />
<ExpandableBlock.Trigger
ref={stickyRef}
className="svr-expandable-block-header"
data-stuck={stuck}
>
<ExpandableBlock.ExpandIcon />
<ExpandableBlock.LabelArea>
<ExpandableBlock.Title
as="div"
className={props.titleClassName}
tabIndex={0}
role="button"
onKeyDown={handleKeyDown}
>
{props.title}
</ExpandableBlock.Title>
</ExpandableBlock.LabelArea>
{
props.endIcon &&
<ExpandableBlock.EndIcon onClick={handleGroupMenuClick}>
{props.endIcon}
</ExpandableBlock.EndIcon>
}
</ExpandableBlock.Trigger>
<ExpandableBlock.Content>
{props.children}
</ExpandableBlock.Content>
</ExpandableBlock.Wrapper>
);
}
function useSticky(ref: RefObject<HTMLElement>): boolean {
const [stuck, setStuck] = useState(false);
useLayoutEffect(
() => {
const stuckElement = ref.current;
if (!stuckElement) {
return;
}
const scrollableParent = findScrollableParent(stuckElement.parentElement);
const handleScroll = () => {
const parentOffset = stuckElement.parentElement?.offsetTop ?? stuckElement.offsetTop;
setStuck(parentOffset < stuckElement.offsetTop);
};
scrollableParent?.addEventListener("scroll", handleScroll);
return () => scrollableParent?.removeEventListener("scroll", handleScroll);
},
[ref],
);
return stuck;
}
function findScrollableParent(element: HTMLElement | null | undefined): HTMLElement | undefined {
if (!element) {
return undefined;
}
const style = getComputedStyle(element);
if (style.overflowY !== "visible" && style.overflowY !== "hidden") {
return element;
}
return findScrollableParent(element.parentElement);
}