Skip to content

Commit e9f544e

Browse files
committed
dialog component v1
1 parent 1ee6be3 commit e9f544e

File tree

6 files changed

+328
-2
lines changed

6 files changed

+328
-2
lines changed

packages/components/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@
3131
"@codesandbox/common": "^1.0.8",
3232
"@codesandbox/template-icons": "^1.1.0",
3333
"@reach/auto-id": "^0.7.1",
34+
"@reach/dialog": "^0.9.0",
3435
"@reach/menu-button": "^0.8.5",
36+
"@reach/rect": "^0.9.0",
3537
"@reach/tooltip": "^0.8.6",
3638
"@reach/visually-hidden": "^0.7.0",
3739
"@styled-system/css": "^5.1.4",
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import React from 'react';
2+
3+
import { Dialog } from '.';
4+
import { Stack, Text, Select } from '../..';
5+
6+
export default {
7+
title: 'components/Dialog',
8+
component: Dialog,
9+
};
10+
11+
export const Small = () => (
12+
<Stack justify="center" align="center" paddingX={2}>
13+
<Stack align="center" gap={1}>
14+
<Text size={3} variant="muted">
15+
My Sandboxes
16+
</Text>
17+
<Text size={3} variant="muted">
18+
/
19+
</Text>
20+
<Text size={3}>nifty-mccarthy-s5ny3</Text>
21+
<Dialog label="Adjust privacy settings">
22+
<Dialog.IconButton name="caret" size={2} />
23+
<Dialog.Content>
24+
<Stack direction="vertical" gap={4} padding={4}>
25+
<Text size={3} block>
26+
Adjust privacy settings.
27+
</Text>
28+
<Select>
29+
<option>Public</option>
30+
<option>Unlisted</option>
31+
<option>Private</option>
32+
</Select>
33+
<Text size={2} variant="muted">
34+
Everyone can see this Sandbox.
35+
</Text>
36+
</Stack>
37+
</Dialog.Content>
38+
</Dialog>
39+
</Stack>
40+
</Stack>
41+
);
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import React from 'react';
2+
import deepmerge from 'deepmerge';
3+
4+
import css from '@styled-system/css';
5+
import { createGlobalStyle } from 'styled-components';
6+
import { AnimatePresence, motion } from 'framer-motion';
7+
8+
import Rect, { useRect } from '@reach/rect';
9+
import { DialogOverlay, DialogContent } from '@reach/dialog';
10+
import '@reach/dialog/styles.css';
11+
12+
import { Button } from '../Button';
13+
import { IconButton } from '../IconButton';
14+
15+
const GlobalStyle = createGlobalStyle(
16+
css({
17+
'[data-reach-dialog-overlay][data-component="DialogOverlay"]': {
18+
background: 'transparent',
19+
},
20+
'[data-reach-dialog-content][data-component="DialogContent"]': {
21+
backgroundColor: 'dialog.background',
22+
color: 'dialog.foreground',
23+
border: '1px solid',
24+
borderColor: 'dialog.border',
25+
borderRadius: 3,
26+
boxShadow: 2,
27+
position: 'absolute',
28+
// override reach styles
29+
padding: 0,
30+
margin: 0,
31+
width: 'auto',
32+
},
33+
})
34+
);
35+
36+
const context = React.createContext({
37+
dialogVisible: false,
38+
setDialogVisibility: null,
39+
triggerRef: null,
40+
triggerRect: null,
41+
label: null,
42+
});
43+
44+
type DialogTypes = React.FC<{
45+
/** Accessible label for dialog content */
46+
label: string;
47+
}> & {
48+
Content: typeof DialogContent;
49+
Button: typeof DialogButton;
50+
IconButton: typeof DialogIconButton;
51+
};
52+
53+
const Dialog: DialogTypes = ({ label, children }) => {
54+
const [dialogVisible, setDialogVisibility] = React.useState(false);
55+
const triggerRef = React.useRef();
56+
const triggerRect = useRect(triggerRef);
57+
58+
return (
59+
<context.Provider
60+
value={{
61+
dialogVisible,
62+
setDialogVisibility,
63+
triggerRef,
64+
triggerRect,
65+
label,
66+
}}
67+
>
68+
<GlobalStyle />
69+
{children}
70+
</context.Provider>
71+
);
72+
};
73+
74+
const DialogButton = props => {
75+
const { setDialogVisibility, triggerRef } = React.useContext(context);
76+
77+
return (
78+
<Button
79+
{...props}
80+
css={deepmerge({ width: 'auto' }, props.css || {})}
81+
ref={triggerRef}
82+
onClick={() => setDialogVisibility(true)}
83+
/>
84+
);
85+
};
86+
87+
const DialogIconButton = props => {
88+
const { setDialogVisibility, triggerRef, label } = React.useContext(context);
89+
90+
return (
91+
<IconButton
92+
{...props}
93+
title={props.label || label}
94+
css={deepmerge({ width: '26px' }, props.css || {})}
95+
innerRef={triggerRef}
96+
onClick={() => setDialogVisibility(true)}
97+
/>
98+
);
99+
};
100+
101+
const Content = ({ style = {}, children, ...props }) => {
102+
if (props.css)
103+
console.warn(`Dialog.Content: Please use style instead of css
104+
105+
This component is rendered in a portal and is outside
106+
the scope of scope of design language.
107+
`);
108+
109+
const {
110+
dialogVisible,
111+
setDialogVisibility,
112+
triggerRect,
113+
label,
114+
} = React.useContext(context);
115+
116+
const [overlayVisible, setOverlayVisiblity] = React.useState(false);
117+
118+
React.useEffect(() => {
119+
if (dialogVisible) setOverlayVisiblity(true);
120+
}, [dialogVisible]);
121+
122+
return (
123+
<DialogOverlay
124+
isOpen={overlayVisible}
125+
onDismiss={() => setDialogVisibility(false)}
126+
data-component="DialogOverlay"
127+
>
128+
<Rect>
129+
{({ rect, ref }) => (
130+
<AnimatePresence onExitComplete={() => setOverlayVisiblity(false)}>
131+
{dialogVisible && (
132+
<motion.div
133+
initial={{ y: -4, scaleY: 0.98 }}
134+
animate={{ opacity: 1, y: 0, scaleY: 1 }}
135+
exit={{ y: -4, opacity: 0, transition: { duration: 0.1 } }}
136+
transition={{ duration: 0.25 }}
137+
>
138+
<DialogContent
139+
data-component="DialogContent"
140+
aria-label={label}
141+
ref={ref}
142+
style={{
143+
...centered(triggerRect, rect),
144+
...style,
145+
}}
146+
{...props}
147+
>
148+
{children}
149+
</DialogContent>
150+
</motion.div>
151+
)}
152+
</AnimatePresence>
153+
)}
154+
</Rect>
155+
</DialogOverlay>
156+
);
157+
};
158+
159+
const centered = (triggerRect, dialogRect) => {
160+
if (!dialogRect || !triggerRect) return { left: 0, top: 0 };
161+
162+
const triggerCenter = triggerRect.left + triggerRect.width / 2;
163+
const left = triggerCenter - dialogRect.width / 2;
164+
const maxLeft = window.innerWidth - dialogRect.width - 2;
165+
166+
return {
167+
left: Math.min(Math.max(2, left), maxLeft) + window.scrollX,
168+
top: triggerRect.bottom + 8 + window.scrollY,
169+
};
170+
};
171+
172+
/**
173+
* Attaching components to the parent for an easier API
174+
*/
175+
Dialog.Button = DialogButton;
176+
Dialog.IconButton = DialogIconButton;
177+
Dialog.Content = Content;
178+
179+
export { Dialog };

packages/components/src/components/IconButton/index.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,23 @@ type IconButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
1212
title: string;
1313
/** Size of the icon, the button is set to 26x26 */
1414
size?: number;
15+
/** optional ref to bypass tooltip */
16+
innerRef?: React.Ref;
1517
};
1618

1719
export const IconButton: React.FC<IconButtonProps> = ({
1820
name,
1921
title,
2022
size,
23+
innerRef,
2124
css = {},
2225
...props
2326
}) => (
2427
// @ts-ignore
2528
<Tooltip label={title}>
2629
<Button
2730
variant="link"
31+
ref={innerRef}
2832
css={deepmerge(
2933
{
3034
width: '26px', // same width as (height of the button)

packages/components/src/components/Tooltip/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ const Tooltip = props => {
9494
return (
9595
<>
9696
<TooltipStyles />
97-
{React.cloneElement(props.children, trigger)}
97+
<span {...trigger}>{props.children}</span>
9898
<TooltipPopup
9999
{...tooltip}
100100
data-component="Tooltip"

0 commit comments

Comments
 (0)