Skip to content

Commit

Permalink
SelectPanel2: Use html dialog (#4020)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
siddharthkp authored Dec 20, 2023
1 parent 6f043bc commit c2a53a0
Show file tree
Hide file tree
Showing 3 changed files with 105 additions and 36 deletions.
5 changes: 5 additions & 0 deletions .changeset/eleven-lizards-draw.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/react": patch
---

experimental/SelectPanel2: Use `<dialog>` element
2 changes: 1 addition & 1 deletion src/Overlay/Overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ function getSlideAnimationStartingVector(anchorSide?: AnchorSide): {x: number; y
return {x: 0, y: 0}
}

const StyledOverlay = styled.div<StyledOverlayProps>`
export const StyledOverlay = styled.div<StyledOverlayProps>`
background-color: ${get('colors.canvas.overlay')};
box-shadow: ${get('shadows.overlay.shadow')};
position: absolute;
Expand Down
134 changes: 99 additions & 35 deletions src/drafts/SelectPanel2/SelectPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ import {
IconButton,
Heading,
Box,
AnchoredOverlay,
AnchoredOverlayProps,
Tooltip,
TextInput,
TextInputProps,
Expand All @@ -20,8 +18,9 @@ import {
} from '../../../src/index'
import {ActionListContainerContext} from '../../../src/ActionList/ActionListContainerContext'
import {useSlots} from '../../hooks/useSlots'
import {useProvidedRefOrCreate, useId} from '../../hooks'
import {useProvidedRefOrCreate, useId, useAnchoredPosition} from '../../hooks'
import {useFocusZone} from '../../hooks/useFocusZone'
import {StyledOverlay, OverlayProps} from '../../Overlay/Overlay'

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

// TODO: move these to SelectPanel.Overlay or overlayProps
width?: AnchoredOverlayProps['width']
height?: AnchoredOverlayProps['height']
width?: OverlayProps['width']
height?: OverlayProps['height']

children: React.ReactNode
}
Expand All @@ -82,24 +81,38 @@ const Panel: React.FC<SelectPanelProps> = ({
height = 'large',
...props
}) => {
const anchorRef = useProvidedRefOrCreate(providedAnchorRef)
const [internalOpen, setInternalOpen] = React.useState(defaultOpen)

// sync open state with props
if (propsOpen !== undefined && internalOpen !== propsOpen) setInternalOpen(propsOpen)

// TODO: replace this hack with clone element?

// 🚨 Hack for good API!
// we strip out Anchor from children and pass it to AnchoredOverlay to render
// we strip out Anchor from children and wire it up to Dialog
// with additional props for accessibility
let renderAnchor: AnchoredOverlayProps['renderAnchor'] = null
let Anchor: React.ReactElement | undefined
const anchorRef = useProvidedRefOrCreate(providedAnchorRef)

const onAnchorClick = () => {
if (!internalOpen) setInternalOpen(true)
else onInternalClose()
}

const contents = React.Children.map(props.children, child => {
if (React.isValidElement(child) && child.type === SelectPanelButton) {
renderAnchor = anchorProps => React.cloneElement(child, anchorProps)
Anchor = React.cloneElement(child, {
// @ts-ignore TODO
ref: anchorRef,
onClick: onAnchorClick,
'aria-haspopup': true,
'aria-expanded': internalOpen,
})
return null
}
return child
})

const [internalOpen, setInternalOpen] = React.useState(defaultOpen)
// sync open state
if (propsOpen !== undefined && internalOpen !== propsOpen) setInternalOpen(propsOpen)

const onInternalClose = () => {
if (propsOpen === undefined) setInternalOpen(false)
if (typeof propsOnCancel === 'function') propsOnCancel()
Expand Down Expand Up @@ -135,26 +148,77 @@ const Panel: React.FC<SelectPanelProps> = ({
[internalOpen],
)

/* Dialog */
const dialogRef = React.useRef<HTMLDialogElement>(null)
if (internalOpen) dialogRef.current?.showModal()
else dialogRef.current?.close()

// dialog handles Esc automatically, so we have to sync internal state
React.useEffect(() => dialogRef.current?.addEventListener('close', onInternalClose))

// React doesn't support autoFocus for dialog: https://github.com/facebook/react/issues/23301
// tl;dr: react takes over autofocus instead of letting the browser handle it,
// but not for dialogs, so we have to do it
React.useEffect(() => {
if (internalOpen) document.querySelector('input')?.focus()
}, [internalOpen])

/* Anchored */
const {position} = useAnchoredPosition(
{
anchorElementRef: anchorRef,
floatingElementRef: dialogRef,
side: 'outside-bottom',
align: 'start',
},
[anchorRef.current, dialogRef.current],
)

/*
We don't close the panel when clicking outside.
For many years, we used to save changes and closed the dialog (for label picker)
which isn't accessible, clicking outside should discard changes and close the dialog
Fixing this a11y bug would confuse users, so as a middle ground,
we don't close the menu and nudge the user towards the footer actions
*/
const [footerAnimationEnabled, setFooterAnimationEnabled] = React.useState(false)
const onClickOutside = () => {
setFooterAnimationEnabled(true)
window.setTimeout(() => setFooterAnimationEnabled(false), 350)
}

return (
<>
<AnchoredOverlay
anchorRef={anchorRef}
renderAnchor={renderAnchor}
open={internalOpen}
onOpen={() => setInternalOpen(true)}
onClose={onInternalClose}
{Anchor}

<StyledOverlay
as="dialog"
ref={dialogRef}
aria-labelledby={`${panelId}--title`}
aria-describedby={description ? `${panelId}--description` : undefined}
width={width}
height={height}
focusZoneSettings={{
// we only want focus trap from the overlay,
// we don't want focus zone on the whole overlay because
// we have a focus zone on the list
disabled: true,
sx={{
...position,
// reset dialog default styles
border: 'none',
padding: 0,
margin: 0,
'::backdrop': {background: 'transparent'},

'& [data-selectpanel-primary-actions]': {
animation: footerAnimationEnabled ? 'selectpanel-gelatine 350ms linear' : 'none',
},
'@keyframes selectpanel-gelatine': {
'0%': {transform: 'scale(1, 1)'},
'25%': {transform: 'scale(0.9, 1.1)'},
'50%': {transform: 'scale(1.1, 0.9)'},
'75%': {transform: 'scale(0.95, 1.05)'},
'100%': {transform: 'scale(1, 1)'},
},
}}
overlayProps={{
role: 'dialog',
'aria-labelledby': `${panelId}--title`,
'aria-describedby': description ? `${panelId}--description` : undefined,
onClick={event => {
if (event.target === event.currentTarget) onClickOutside()
}}
>
<SelectPanelContext.Provider
Expand All @@ -171,15 +235,16 @@ const Panel: React.FC<SelectPanelProps> = ({
>
<Box
as="form"
method="dialog"
onSubmit={onInternalSubmit}
sx={{
display: 'flex',
flexDirection: 'column',
height: '100%',
}}
>
{/* render default header as fallback */}
{slots.header ?? <SelectPanelHeader />}
{slots.header ?? /* render default header as fallback */ <SelectPanelHeader />}

<Box
as="div"
ref={listContainerRef as React.RefObject<HTMLDivElement>}
Expand Down Expand Up @@ -209,7 +274,7 @@ const Panel: React.FC<SelectPanelProps> = ({
{slots.footer}
</Box>
</SelectPanelContext.Provider>
</AnchoredOverlay>
</StyledOverlay>
</>
)
}
Expand Down Expand Up @@ -279,6 +344,7 @@ const SelectPanelHeader: React.FC<React.PropsWithChildren> = ({children, ...prop
}

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

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

return (
<TextInput
// this autofocus doesn't seem to apply 🤔
// probably because the focus zone overrides autoFocus
autoFocus
ref={inputRef}
block
leadingVisual={SearchIcon}
Expand All @@ -303,6 +366,7 @@ const SelectPanelSearchInput: React.FC<TextInputProps> = ({onChange: propsOnChan
<TextInput.Action
icon={XCircleFillIcon}
aria-label="Clear"
tooltipDirection="w"
sx={{color: 'fg.subtle', bg: 'none'}}
onClick={() => {
if (inputRef.current) inputRef.current.value = ''
Expand Down Expand Up @@ -349,7 +413,7 @@ const SelectPanelFooter = ({...props}) => {
<Box sx={{flexGrow: hidePrimaryActions ? 1 : 0}}>{props.children}</Box>

{hidePrimaryActions ? null : (
<Box sx={{display: 'flex', gap: 2}}>
<Box data-selectpanel-primary-actions sx={{display: 'flex', gap: 2}}>
<Button size="small" type="button" onClick={() => onCancel()}>
Cancel
</Button>
Expand Down

0 comments on commit c2a53a0

Please sign in to comment.