8
8
IconButton ,
9
9
Heading ,
10
10
Box ,
11
- AnchoredOverlay ,
12
- AnchoredOverlayProps ,
13
11
Tooltip ,
14
12
TextInput ,
15
13
TextInputProps ,
@@ -20,8 +18,9 @@ import {
20
18
} from '../../../src/index'
21
19
import { ActionListContainerContext } from '../../../src/ActionList/ActionListContainerContext'
22
20
import { useSlots } from '../../hooks/useSlots'
23
- import { useProvidedRefOrCreate , useId } from '../../hooks'
21
+ import { useProvidedRefOrCreate , useId , useAnchoredPosition } from '../../hooks'
24
22
import { useFocusZone } from '../../hooks/useFocusZone'
23
+ import { StyledOverlay , OverlayProps } from '../../Overlay/Overlay'
25
24
26
25
const SelectPanelContext = React . createContext < {
27
26
title : string
@@ -58,8 +57,8 @@ export type SelectPanelProps = {
58
57
onSubmit ?: ( event ?: React . FormEvent < HTMLFormElement > ) => void
59
58
60
59
// TODO: move these to SelectPanel.Overlay or overlayProps
61
- width ?: AnchoredOverlayProps [ 'width' ]
62
- height ?: AnchoredOverlayProps [ 'height' ]
60
+ width ?: OverlayProps [ 'width' ]
61
+ height ?: OverlayProps [ 'height' ]
63
62
64
63
children : React . ReactNode
65
64
}
@@ -82,24 +81,38 @@ const Panel: React.FC<SelectPanelProps> = ({
82
81
height = 'large' ,
83
82
...props
84
83
} ) => {
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?
86
90
87
91
// 🚨 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
89
93
// 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
+
91
102
const contents = React . Children . map ( props . children , child => {
92
103
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
+ } )
94
111
return null
95
112
}
96
113
return child
97
114
} )
98
115
99
- const [ internalOpen , setInternalOpen ] = React . useState ( defaultOpen )
100
- // sync open state
101
- if ( propsOpen !== undefined && internalOpen !== propsOpen ) setInternalOpen ( propsOpen )
102
-
103
116
const onInternalClose = ( ) => {
104
117
if ( propsOpen === undefined ) setInternalOpen ( false )
105
118
if ( typeof propsOnCancel === 'function' ) propsOnCancel ( )
@@ -135,26 +148,77 @@ const Panel: React.FC<SelectPanelProps> = ({
135
148
[ internalOpen ] ,
136
149
)
137
150
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
+
138
190
return (
139
191
< >
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 }
146
199
width = { width }
147
200
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
+ } ,
153
219
} }
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 ( )
158
222
} }
159
223
>
160
224
< SelectPanelContext . Provider
@@ -171,15 +235,16 @@ const Panel: React.FC<SelectPanelProps> = ({
171
235
>
172
236
< Box
173
237
as = "form"
238
+ method = "dialog"
174
239
onSubmit = { onInternalSubmit }
175
240
sx = { {
176
241
display : 'flex' ,
177
242
flexDirection : 'column' ,
178
243
height : '100%' ,
179
244
} }
180
245
>
181
- { /* render default header as fallback */ }
182
- { slots . header ?? < SelectPanelHeader /> }
246
+ { slots . header ?? /* render default header as fallback */ < SelectPanelHeader /> }
247
+
183
248
< Box
184
249
as = "div"
185
250
ref = { listContainerRef as React . RefObject < HTMLDivElement > }
@@ -209,7 +274,7 @@ const Panel: React.FC<SelectPanelProps> = ({
209
274
{ slots . footer }
210
275
</ Box >
211
276
</ SelectPanelContext . Provider >
212
- </ AnchoredOverlay >
277
+ </ StyledOverlay >
213
278
</ >
214
279
)
215
280
}
@@ -279,6 +344,7 @@ const SelectPanelHeader: React.FC<React.PropsWithChildren> = ({children, ...prop
279
344
}
280
345
281
346
const SelectPanelSearchInput : React . FC < TextInputProps > = ( { onChange : propsOnChange , ...props } ) => {
347
+ // TODO: use forwardedRef
282
348
const inputRef = React . createRef < HTMLInputElement > ( )
283
349
284
350
const { setSearchQuery} = React . useContext ( SelectPanelContext )
@@ -292,9 +358,6 @@ const SelectPanelSearchInput: React.FC<TextInputProps> = ({onChange: propsOnChan
292
358
293
359
return (
294
360
< TextInput
295
- // this autofocus doesn't seem to apply 🤔
296
- // probably because the focus zone overrides autoFocus
297
- autoFocus
298
361
ref = { inputRef }
299
362
block
300
363
leadingVisual = { SearchIcon }
@@ -303,6 +366,7 @@ const SelectPanelSearchInput: React.FC<TextInputProps> = ({onChange: propsOnChan
303
366
< TextInput . Action
304
367
icon = { XCircleFillIcon }
305
368
aria-label = "Clear"
369
+ tooltipDirection = "w"
306
370
sx = { { color : 'fg.subtle' , bg : 'none' } }
307
371
onClick = { ( ) => {
308
372
if ( inputRef . current ) inputRef . current . value = ''
@@ -349,7 +413,7 @@ const SelectPanelFooter = ({...props}) => {
349
413
< Box sx = { { flexGrow : hidePrimaryActions ? 1 : 0 } } > { props . children } </ Box >
350
414
351
415
{ hidePrimaryActions ? null : (
352
- < Box sx = { { display : 'flex' , gap : 2 } } >
416
+ < Box data-selectpanel-primary-actions sx = { { display : 'flex' , gap : 2 } } >
353
417
< Button size = "small" type = "button" onClick = { ( ) => onCancel ( ) } >
354
418
Cancel
355
419
</ Button >
0 commit comments