Skip to content

Commit 9a02c90

Browse files
committed
Add are you sure popover?
1 parent 967df3d commit 9a02c90

File tree

9 files changed

+331
-34
lines changed

9 files changed

+331
-34
lines changed

package-lock.json

Lines changed: 23 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@
127127
}
128128
},
129129
"dependencies": {
130+
"@floating-ui/react": "^0.27.5",
130131
"@seamapi/http": "^1.20.0",
131132
"@tanstack/react-query": "^5.27.5",
132133
"classnames": "^2.3.2",

src/lib/ui/IconButton.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
11
import classNames from 'classnames'
2+
import type { Ref } from 'react'
23

34
import type { ButtonProps } from 'lib/ui/types.js'
45

5-
export function IconButton({ className, ...props }: ButtonProps): JSX.Element {
6+
export type IconProps = ButtonProps & {
7+
elRef?: Ref<HTMLButtonElement>
8+
}
9+
10+
export function IconButton({
11+
className,
12+
elRef,
13+
...props
14+
}: IconProps): JSX.Element {
615
return (
716
<button
817
{...props}
18+
ref={elRef}
919
className={classNames(
1020
'seam-icon-btn',
1121
props.disabled === true && 'seam-icon-btn-disabled',

src/lib/ui/Popover/Popover.tsx

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import {
2+
autoUpdate,
3+
flip,
4+
limitShift,
5+
offset,
6+
type ReferenceElement,
7+
shift,
8+
useFloating,
9+
} from '@floating-ui/react'
10+
import {
11+
type ReactNode,
12+
type Ref,
13+
useCallback,
14+
useEffect,
15+
useImperativeHandle,
16+
useMemo,
17+
useRef,
18+
useState,
19+
} from 'react'
20+
import { createPortal } from 'react-dom'
21+
22+
import { seamComponentsClassName } from 'lib/seam/SeamProvider.js'
23+
24+
export interface PopoverInstance {
25+
show: () => void
26+
hide: () => void
27+
toggle: () => void
28+
}
29+
30+
type PopoverChildren = (
31+
params: {
32+
setRef: (ref: HTMLElement | undefined | null) => void
33+
} & PopoverInstance
34+
) => ReactNode
35+
36+
export interface PopoverProps {
37+
children: PopoverChildren
38+
content: ReactNode | ((instance: PopoverInstance) => ReactNode)
39+
instanceRef?: Ref<PopoverInstance>
40+
preventCloseOnClickOutside?: boolean
41+
}
42+
43+
export function Popover(props: PopoverProps): JSX.Element {
44+
const { children, content, instanceRef, preventCloseOnClickOutside } = props
45+
46+
const [open, setOpen] = useState(false)
47+
48+
const { refs, floatingStyles } = useFloating({
49+
whileElementsMounted: autoUpdate,
50+
transform: false,
51+
open,
52+
onOpenChange: setOpen,
53+
placement: 'bottom',
54+
middleware: [
55+
shift({
56+
crossAxis: true,
57+
limiter: limitShift(),
58+
}),
59+
flip(),
60+
offset(5),
61+
],
62+
})
63+
64+
const referenceEl = useRef<HTMLElement | null>()
65+
const floatingEl = useRef<HTMLElement | null>()
66+
67+
const setFLoating = useCallback(
68+
(ref: HTMLElement | null): void => {
69+
refs.setFloating(ref)
70+
floatingEl.current = ref
71+
},
72+
[refs, floatingEl]
73+
)
74+
75+
const toggle = useCallback(() => {
76+
setOpen((value) => !value)
77+
}, [])
78+
79+
const instance = useMemo(
80+
() => ({
81+
show: () => {
82+
setOpen(true)
83+
},
84+
hide: () => {
85+
setOpen(false)
86+
},
87+
toggle,
88+
}),
89+
[toggle]
90+
)
91+
92+
const setReference = useCallback(
93+
(ref: ReferenceElement | undefined | null): void => {
94+
if (!(ref instanceof HTMLElement) || referenceEl.current === ref) return
95+
96+
if (referenceEl.current != null) {
97+
referenceEl.current.removeEventListener('click', toggle)
98+
}
99+
100+
refs.setReference(ref)
101+
ref.addEventListener('click', toggle)
102+
referenceEl.current = ref
103+
},
104+
[toggle, refs]
105+
)
106+
107+
useImperativeHandle(instanceRef, () => instance)
108+
109+
/**
110+
* Closes the popover when the user clicks outside of it.
111+
*/
112+
const windowClickHandler = useCallback((e: MouseEvent): void => {
113+
const target = e.target as HTMLElement
114+
115+
// If the target is the reference element, do nothing.
116+
if (
117+
referenceEl.current === target ||
118+
referenceEl.current?.contains(target) === true
119+
) {
120+
return
121+
}
122+
123+
const closest = target.closest('[data-seam-popover]')
124+
125+
// Prevents closing if target is floating element, also adds support for nested popovers somehow :)
126+
if (
127+
closest != null &&
128+
referenceEl.current != null &&
129+
!closest.contains(referenceEl.current)
130+
) {
131+
return
132+
}
133+
134+
setOpen(false)
135+
}, [])
136+
137+
useEffect(() => {
138+
setTimeout(() => {
139+
if (preventCloseOnClickOutside === false) return
140+
141+
window.addEventListener('click', windowClickHandler)
142+
}, 0)
143+
144+
return () => {
145+
window.removeEventListener('click', windowClickHandler)
146+
}
147+
}, [windowClickHandler, preventCloseOnClickOutside])
148+
149+
return (
150+
<>
151+
{children({ setRef: setReference, ...instance })}
152+
{open &&
153+
createPortal(
154+
<div
155+
className={seamComponentsClassName}
156+
data-seam-popover=''
157+
ref={setFLoating}
158+
style={floatingStyles}
159+
>
160+
<div className='seam-popover'>
161+
{typeof content === 'function' ? content(instance) : content}
162+
</div>
163+
</div>,
164+
document.body
165+
)}
166+
</>
167+
)
168+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { Button } from 'lib/ui/Button.js'
2+
3+
export interface PopoverContentPromptProps {
4+
onConfirm?: () => void
5+
onCancel?: () => void
6+
prompt?: string
7+
description?: string
8+
confirmText?: string
9+
cancelText?: string
10+
confirmLoading?: boolean
11+
}
12+
13+
export function PopoverContentPrompt(
14+
props: PopoverContentPromptProps
15+
): JSX.Element {
16+
const {
17+
confirmText = t.confirm,
18+
cancelText = t.cancel,
19+
confirmLoading = false,
20+
prompt = t.areYouSure,
21+
description,
22+
onConfirm,
23+
onCancel,
24+
} = props
25+
26+
return (
27+
<div className='seam-popover-content-prompt'>
28+
<div>
29+
<div className='seam-popover-content-prompt-text'>{prompt}</div>
30+
{description != null && (
31+
<div className='seam-popover-content-prompt-description'>
32+
{description}
33+
</div>
34+
)}
35+
</div>
36+
<div className='seam-popover-content-prompt-buttons'>
37+
<Button
38+
variant='solid'
39+
onClick={onConfirm}
40+
loading={confirmLoading}
41+
size='small'
42+
>
43+
{confirmText}
44+
</Button>
45+
46+
<Button variant='danger' size='small' onClick={onCancel}>
47+
{cancelText}
48+
</Button>
49+
</div>
50+
</div>
51+
)
52+
}
53+
54+
const t = {
55+
confirm: 'Confirm',
56+
cancel: 'Cancel',
57+
areYouSure: 'Are you sure?',
58+
}

0 commit comments

Comments
 (0)