Skip to content

Commit c2a53a0

Browse files
authored
SelectPanel2: Use html dialog (#4020)
* copy changes from #4018 * remove undefined values * add autofocus * sync esc with internalClose * move focus logic to only work once * change tooltip direction to stay within input * note for self * add temporary example for question * Revert "add temporary example for question" This reverts commit 19bc492. * move comment closer to code * nudge user towards actions when clicking outside * oops * Create eleven-lizards-draw.md * change animation duration to 350ms
1 parent 6f043bc commit c2a53a0

File tree

3 files changed

+105
-36
lines changed

3 files changed

+105
-36
lines changed

.changeset/eleven-lizards-draw.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@primer/react": patch
3+
---
4+
5+
experimental/SelectPanel2: Use `<dialog>` element

src/Overlay/Overlay.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ function getSlideAnimationStartingVector(anchorSide?: AnchorSide): {x: number; y
5555
return {x: 0, y: 0}
5656
}
5757

58-
const StyledOverlay = styled.div<StyledOverlayProps>`
58+
export const StyledOverlay = styled.div<StyledOverlayProps>`
5959
background-color: ${get('colors.canvas.overlay')};
6060
box-shadow: ${get('shadows.overlay.shadow')};
6161
position: absolute;

src/drafts/SelectPanel2/SelectPanel.tsx

Lines changed: 99 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@ import {
88
IconButton,
99
Heading,
1010
Box,
11-
AnchoredOverlay,
12-
AnchoredOverlayProps,
1311
Tooltip,
1412
TextInput,
1513
TextInputProps,
@@ -20,8 +18,9 @@ import {
2018
} from '../../../src/index'
2119
import {ActionListContainerContext} from '../../../src/ActionList/ActionListContainerContext'
2220
import {useSlots} from '../../hooks/useSlots'
23-
import {useProvidedRefOrCreate, useId} from '../../hooks'
21+
import {useProvidedRefOrCreate, useId, useAnchoredPosition} from '../../hooks'
2422
import {useFocusZone} from '../../hooks/useFocusZone'
23+
import {StyledOverlay, OverlayProps} from '../../Overlay/Overlay'
2524

2625
const SelectPanelContext = React.createContext<{
2726
title: string
@@ -58,8 +57,8 @@ export type SelectPanelProps = {
5857
onSubmit?: (event?: React.FormEvent<HTMLFormElement>) => void
5958

6059
// TODO: move these to SelectPanel.Overlay or overlayProps
61-
width?: AnchoredOverlayProps['width']
62-
height?: AnchoredOverlayProps['height']
60+
width?: OverlayProps['width']
61+
height?: OverlayProps['height']
6362

6463
children: React.ReactNode
6564
}
@@ -82,24 +81,38 @@ const Panel: React.FC<SelectPanelProps> = ({
8281
height = 'large',
8382
...props
8483
}) => {
85-
const anchorRef = useProvidedRefOrCreate(providedAnchorRef)
84+
const [internalOpen, setInternalOpen] = React.useState(defaultOpen)
85+
86+
// sync open state with props
87+
if (propsOpen !== undefined && internalOpen !== propsOpen) setInternalOpen(propsOpen)
88+
89+
// TODO: replace this hack with clone element?
8690

8791
// 🚨 Hack for good API!
88-
// we strip out Anchor from children and pass it to AnchoredOverlay to render
92+
// we strip out Anchor from children and wire it up to Dialog
8993
// with additional props for accessibility
90-
let renderAnchor: AnchoredOverlayProps['renderAnchor'] = null
94+
let Anchor: React.ReactElement | undefined
95+
const anchorRef = useProvidedRefOrCreate(providedAnchorRef)
96+
97+
const onAnchorClick = () => {
98+
if (!internalOpen) setInternalOpen(true)
99+
else onInternalClose()
100+
}
101+
91102
const contents = React.Children.map(props.children, child => {
92103
if (React.isValidElement(child) && child.type === SelectPanelButton) {
93-
renderAnchor = anchorProps => React.cloneElement(child, anchorProps)
104+
Anchor = React.cloneElement(child, {
105+
// @ts-ignore TODO
106+
ref: anchorRef,
107+
onClick: onAnchorClick,
108+
'aria-haspopup': true,
109+
'aria-expanded': internalOpen,
110+
})
94111
return null
95112
}
96113
return child
97114
})
98115

99-
const [internalOpen, setInternalOpen] = React.useState(defaultOpen)
100-
// sync open state
101-
if (propsOpen !== undefined && internalOpen !== propsOpen) setInternalOpen(propsOpen)
102-
103116
const onInternalClose = () => {
104117
if (propsOpen === undefined) setInternalOpen(false)
105118
if (typeof propsOnCancel === 'function') propsOnCancel()
@@ -135,26 +148,77 @@ const Panel: React.FC<SelectPanelProps> = ({
135148
[internalOpen],
136149
)
137150

151+
/* Dialog */
152+
const dialogRef = React.useRef<HTMLDialogElement>(null)
153+
if (internalOpen) dialogRef.current?.showModal()
154+
else dialogRef.current?.close()
155+
156+
// dialog handles Esc automatically, so we have to sync internal state
157+
React.useEffect(() => dialogRef.current?.addEventListener('close', onInternalClose))
158+
159+
// React doesn't support autoFocus for dialog: https://github.com/facebook/react/issues/23301
160+
// tl;dr: react takes over autofocus instead of letting the browser handle it,
161+
// but not for dialogs, so we have to do it
162+
React.useEffect(() => {
163+
if (internalOpen) document.querySelector('input')?.focus()
164+
}, [internalOpen])
165+
166+
/* Anchored */
167+
const {position} = useAnchoredPosition(
168+
{
169+
anchorElementRef: anchorRef,
170+
floatingElementRef: dialogRef,
171+
side: 'outside-bottom',
172+
align: 'start',
173+
},
174+
[anchorRef.current, dialogRef.current],
175+
)
176+
177+
/*
178+
We don't close the panel when clicking outside.
179+
For many years, we used to save changes and closed the dialog (for label picker)
180+
which isn't accessible, clicking outside should discard changes and close the dialog
181+
Fixing this a11y bug would confuse users, so as a middle ground,
182+
we don't close the menu and nudge the user towards the footer actions
183+
*/
184+
const [footerAnimationEnabled, setFooterAnimationEnabled] = React.useState(false)
185+
const onClickOutside = () => {
186+
setFooterAnimationEnabled(true)
187+
window.setTimeout(() => setFooterAnimationEnabled(false), 350)
188+
}
189+
138190
return (
139191
<>
140-
<AnchoredOverlay
141-
anchorRef={anchorRef}
142-
renderAnchor={renderAnchor}
143-
open={internalOpen}
144-
onOpen={() => setInternalOpen(true)}
145-
onClose={onInternalClose}
192+
{Anchor}
193+
194+
<StyledOverlay
195+
as="dialog"
196+
ref={dialogRef}
197+
aria-labelledby={`${panelId}--title`}
198+
aria-describedby={description ? `${panelId}--description` : undefined}
146199
width={width}
147200
height={height}
148-
focusZoneSettings={{
149-
// we only want focus trap from the overlay,
150-
// we don't want focus zone on the whole overlay because
151-
// we have a focus zone on the list
152-
disabled: true,
201+
sx={{
202+
...position,
203+
// reset dialog default styles
204+
border: 'none',
205+
padding: 0,
206+
margin: 0,
207+
'::backdrop': {background: 'transparent'},
208+
209+
'& [data-selectpanel-primary-actions]': {
210+
animation: footerAnimationEnabled ? 'selectpanel-gelatine 350ms linear' : 'none',
211+
},
212+
'@keyframes selectpanel-gelatine': {
213+
'0%': {transform: 'scale(1, 1)'},
214+
'25%': {transform: 'scale(0.9, 1.1)'},
215+
'50%': {transform: 'scale(1.1, 0.9)'},
216+
'75%': {transform: 'scale(0.95, 1.05)'},
217+
'100%': {transform: 'scale(1, 1)'},
218+
},
153219
}}
154-
overlayProps={{
155-
role: 'dialog',
156-
'aria-labelledby': `${panelId}--title`,
157-
'aria-describedby': description ? `${panelId}--description` : undefined,
220+
onClick={event => {
221+
if (event.target === event.currentTarget) onClickOutside()
158222
}}
159223
>
160224
<SelectPanelContext.Provider
@@ -171,15 +235,16 @@ const Panel: React.FC<SelectPanelProps> = ({
171235
>
172236
<Box
173237
as="form"
238+
method="dialog"
174239
onSubmit={onInternalSubmit}
175240
sx={{
176241
display: 'flex',
177242
flexDirection: 'column',
178243
height: '100%',
179244
}}
180245
>
181-
{/* render default header as fallback */}
182-
{slots.header ?? <SelectPanelHeader />}
246+
{slots.header ?? /* render default header as fallback */ <SelectPanelHeader />}
247+
183248
<Box
184249
as="div"
185250
ref={listContainerRef as React.RefObject<HTMLDivElement>}
@@ -209,7 +274,7 @@ const Panel: React.FC<SelectPanelProps> = ({
209274
{slots.footer}
210275
</Box>
211276
</SelectPanelContext.Provider>
212-
</AnchoredOverlay>
277+
</StyledOverlay>
213278
</>
214279
)
215280
}
@@ -279,6 +344,7 @@ const SelectPanelHeader: React.FC<React.PropsWithChildren> = ({children, ...prop
279344
}
280345

281346
const SelectPanelSearchInput: React.FC<TextInputProps> = ({onChange: propsOnChange, ...props}) => {
347+
// TODO: use forwardedRef
282348
const inputRef = React.createRef<HTMLInputElement>()
283349

284350
const {setSearchQuery} = React.useContext(SelectPanelContext)
@@ -292,9 +358,6 @@ const SelectPanelSearchInput: React.FC<TextInputProps> = ({onChange: propsOnChan
292358

293359
return (
294360
<TextInput
295-
// this autofocus doesn't seem to apply 🤔
296-
// probably because the focus zone overrides autoFocus
297-
autoFocus
298361
ref={inputRef}
299362
block
300363
leadingVisual={SearchIcon}
@@ -303,6 +366,7 @@ const SelectPanelSearchInput: React.FC<TextInputProps> = ({onChange: propsOnChan
303366
<TextInput.Action
304367
icon={XCircleFillIcon}
305368
aria-label="Clear"
369+
tooltipDirection="w"
306370
sx={{color: 'fg.subtle', bg: 'none'}}
307371
onClick={() => {
308372
if (inputRef.current) inputRef.current.value = ''
@@ -349,7 +413,7 @@ const SelectPanelFooter = ({...props}) => {
349413
<Box sx={{flexGrow: hidePrimaryActions ? 1 : 0}}>{props.children}</Box>
350414

351415
{hidePrimaryActions ? null : (
352-
<Box sx={{display: 'flex', gap: 2}}>
416+
<Box data-selectpanel-primary-actions sx={{display: 'flex', gap: 2}}>
353417
<Button size="small" type="button" onClick={() => onCancel()}>
354418
Cancel
355419
</Button>

0 commit comments

Comments
 (0)