-
Notifications
You must be signed in to change notification settings - Fork 563
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
SelectPanel: Add variant=modal #5198
Changes from 23 commits
f7c39ea
f31034f
87b84ee
36f029e
ef13937
3816a96
e72d333
2e5a623
987f189
bc9a6c9
d165dae
667fb54
ca5f714
f8d47e9
53ff7ba
859019e
e427df0
fca7e79
f0be480
1b7b968
679f5d0
d7b28be
5db37aa
2c92a4f
7c178bd
43aaf3f
2a14113
fdc0bd4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"@primer/react": patch | ||
--- | ||
|
||
SelectPanel: (Implementation detail, should have no changes for users) Replace AnchoredOverlay with Overlay and useAnchoredPosition |
siddharthkp marked this conversation as resolved.
Show resolved
Hide resolved
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -369,3 +369,66 @@ export const WithGroups = () => { | |
/> | ||
) | ||
} | ||
|
||
export const ModalVariant = () => { | ||
const [selected, setSelected] = React.useState<ItemInput[]>([items[0], items[1]]) | ||
const [filter, setFilter] = React.useState('') | ||
const filteredItems = items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase())) | ||
const [open, setOpen] = useState(false) | ||
const buttonRef = useRef<HTMLButtonElement>(null) | ||
|
||
return ( | ||
<> | ||
<h1>Multi Select Panel as Modal</h1> | ||
<SelectPanel | ||
variant="modal" | ||
renderAnchor={({children, 'aria-labelledby': ariaLabelledBy, ...anchorProps}) => ( | ||
<Button aria-labelledby={` ${ariaLabelledBy}`} {...anchorProps}> | ||
{children ?? 'Select Labels'} | ||
</Button> | ||
)} | ||
anchorRef={buttonRef} | ||
placeholderText="Filter Labels" | ||
open={open} | ||
onOpenChange={setOpen} | ||
items={filteredItems} | ||
selected={selected} | ||
onSelectedChange={setSelected} | ||
onFilterChange={setFilter} | ||
/> | ||
</> | ||
) | ||
} | ||
|
||
export const ModalVariantWithSecondaryAction = () => { | ||
const [selected, setSelected] = React.useState<ItemInput[]>([items[0], items[1]]) | ||
const [filter, setFilter] = React.useState('') | ||
const filteredItems = items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase())) | ||
const [open, setOpen] = useState(false) | ||
const buttonRef = useRef<HTMLButtonElement>(null) | ||
|
||
return ( | ||
<> | ||
<h1>Multi Select Panel as Modal with secondary action</h1> | ||
<SelectPanel | ||
variant="modal" | ||
// backward compatible API choice | ||
// TODO: improve this API, rename it to a single secondaryAction or secondaryFooterSlotSomething | ||
footer={<Button size="small">Edit labels</Button>} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. non blocking improvement 1/2: While footer is backward compatible, it's not obvious what it means. What we want here is a "secondary action". Looking at usage (3 instances), we have
|
||
renderAnchor={({children, 'aria-labelledby': ariaLabelledBy, ...anchorProps}) => ( | ||
<Button aria-labelledby={` ${ariaLabelledBy}`} {...anchorProps}> | ||
{children ?? 'Select Labels'} | ||
</Button> | ||
)} | ||
anchorRef={buttonRef} | ||
placeholderText="Filter Labels" | ||
open={open} | ||
onOpenChange={setOpen} | ||
items={filteredItems} | ||
selected={selected} | ||
onSelectedChange={setSelected} | ||
onFilterChange={setFilter} | ||
/> | ||
</> | ||
) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,7 @@ | ||
import {SearchIcon, TriangleDownIcon} from '@primer/octicons-react' | ||
import React, {useCallback, useMemo} from 'react' | ||
import type {AnchoredOverlayProps} from '../AnchoredOverlay' | ||
import {AnchoredOverlay} from '../AnchoredOverlay' | ||
import Overlay from '../Overlay' | ||
import type {AnchoredOverlayWrapperAnchorProps} from '../AnchoredOverlay/AnchoredOverlay' | ||
import Box from '../Box' | ||
import type {FilteredActionListProps} from '../FilteredActionList' | ||
|
@@ -12,12 +12,12 @@ import type {TextInputProps} from '../TextInput' | |
import type {ItemProps, ItemInput} from './types' | ||
|
||
import {Button} from '../Button' | ||
import {useProvidedRefOrCreate} from '../hooks' | ||
import type {FocusZoneHookSettings} from '../hooks/useFocusZone' | ||
import {useAnchoredPosition, useProvidedRefOrCreate} from '../hooks' | ||
import {useId} from '../hooks/useId' | ||
import {useProvidedStateOrCreate} from '../hooks/useProvidedStateOrCreate' | ||
import {LiveRegion, LiveRegionOutlet, Message} from '../internal/components/LiveRegion' | ||
import {useFeatureFlag} from '../FeatureFlags' | ||
import {useFocusTrap} from '../hooks/useFocusTrap' | ||
|
||
interface SelectPanelSingleSelection { | ||
selected: ItemInput | undefined | ||
|
@@ -29,23 +29,25 @@ interface SelectPanelMultiSelection { | |
onSelectedChange: (selected: ItemInput[]) => void | ||
} | ||
|
||
type OpenGestures = 'anchor-click' | 'anchor-key-press' | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ❤️ |
||
type CloseGestures = 'click-outside' | 'escape' | 'selection' | 'save-click' | 'cancel-click' | ||
|
||
interface SelectPanelBaseProps { | ||
// TODO: Make `title` required in the next major version | ||
title?: string | React.ReactElement | ||
subtitle?: string | React.ReactElement | ||
onOpenChange: ( | ||
open: boolean, | ||
gesture: 'anchor-click' | 'anchor-key-press' | 'click-outside' | 'escape' | 'selection', | ||
) => void | ||
onOpenChange: (open: boolean, gesture: OpenGestures | CloseGestures) => void | ||
placeholder?: string | ||
// TODO: Make `inputLabel` required in next major version | ||
inputLabel?: string | ||
overlayProps?: Partial<OverlayProps> | ||
footer?: string | React.ReactElement | ||
// TODO: do we need keep the old variants for backward-compat? there isn't any usage in dotcom | ||
variant?: 'anchored' | 'modal' | FilteredActionListProps['variant'] | ||
} | ||
|
||
export type SelectPanelProps = SelectPanelBaseProps & | ||
Omit<FilteredActionListProps, 'selectionVariant'> & | ||
Omit<FilteredActionListProps, 'selectionVariant' | 'variant'> & | ||
Pick<AnchoredOverlayProps, 'open'> & | ||
AnchoredOverlayWrapperAnchorProps & | ||
(SelectPanelSingleSelection | SelectPanelMultiSelection) | ||
|
@@ -56,11 +58,6 @@ function isMultiSelectVariant( | |
return Array.isArray(selected) | ||
} | ||
|
||
const focusZoneSettings: Partial<FocusZoneHookSettings> = { | ||
// Let FilteredActionList handle focus zone | ||
disabled: true, | ||
} | ||
|
||
const areItemsEqual = (itemA: ItemInput, itemB: ItemInput) => { | ||
// prefer checking equivality by item.id | ||
if (typeof itemA.id !== 'undefined') return itemA.id === itemB.id | ||
|
@@ -95,7 +92,8 @@ export function SelectPanel({ | |
items, | ||
footer, | ||
textInputProps, | ||
overlayProps, | ||
variant = 'anchored', | ||
overlayProps = variant === 'modal' ? {maxWidth: 'medium', height: 'fit-content', maxHeight: 'large'} : undefined, | ||
sx, | ||
...listProps | ||
}: SelectPanelProps): JSX.Element { | ||
|
@@ -116,12 +114,16 @@ export function SelectPanel({ | |
[onOpenChange], | ||
) | ||
const onClose = useCallback( | ||
(gesture: Parameters<Exclude<AnchoredOverlayProps['onClose'], undefined>>[0] | 'selection') => { | ||
(gesture: Parameters<Exclude<AnchoredOverlayProps['onClose'], undefined>>[0] | CloseGestures) => { | ||
onOpenChange(false, gesture) | ||
}, | ||
[onOpenChange], | ||
) | ||
|
||
const onCancel = () => { | ||
// TODO | ||
} | ||
|
||
const renderMenuAnchor = useMemo(() => { | ||
if (renderAnchor === null) { | ||
return null | ||
|
@@ -137,6 +139,57 @@ export function SelectPanel({ | |
} | ||
}, [placeholder, renderAnchor, selected]) | ||
|
||
/* Anchoring logic */ | ||
const overlayRef = React.useRef<HTMLDivElement>(null) | ||
const inputRef = React.useRef<HTMLInputElement>(null) | ||
|
||
const {position} = useAnchoredPosition( | ||
{ | ||
anchorElementRef: anchorRef, | ||
floatingElementRef: overlayRef, | ||
side: 'outside-bottom', | ||
align: 'start', | ||
}, | ||
[open, anchorRef.current, overlayRef.current], | ||
) | ||
|
||
const onAnchorClick = useCallback( | ||
(event: React.MouseEvent<HTMLElement>) => { | ||
if (event.defaultPrevented || event.button !== 0) { | ||
return | ||
} | ||
|
||
if (!open) { | ||
onOpen('anchor-click') | ||
} else { | ||
onClose('anchor-click') | ||
} | ||
}, | ||
[open, onOpen, onClose], | ||
) | ||
|
||
const onAnchorKeyDown = useCallback( | ||
(event: React.KeyboardEvent<HTMLElement>) => { | ||
if (!event.defaultPrevented) { | ||
if (!open && ['ArrowDown', 'ArrowUp', ' ', 'Enter'].includes(event.key)) { | ||
onOpen('anchor-key-press', event) | ||
event.preventDefault() | ||
} | ||
} | ||
}, | ||
[open, onOpen], | ||
) | ||
|
||
const anchorProps = { | ||
ref: anchorRef, | ||
'aria-haspopup': true, | ||
'aria-expanded': open, | ||
onClick: onAnchorClick, | ||
onKeyDown: onAnchorKeyDown, | ||
} | ||
// TODO: anchor should be called button because it's not an anchor anymore | ||
const anchor = renderMenuAnchor ? renderMenuAnchor(anchorProps) : null | ||
|
||
const itemsToRender = useMemo(() => { | ||
return items.map(item => { | ||
const isItemSelected = isMultiSelectVariant(selected) ? doesItemsIncludeItem(selected, item) : selected === item | ||
|
@@ -172,10 +225,13 @@ export function SelectPanel({ | |
}) | ||
}, [onClose, onSelectedChange, items, selected]) | ||
|
||
const inputRef = React.useRef<HTMLInputElement>(null) | ||
const focusTrapSettings = { | ||
/** Focus trap */ | ||
useFocusTrap({ | ||
containerRef: overlayRef, | ||
disabled: !open || !position, | ||
initialFocusRef: inputRef, | ||
} | ||
returnFocusRef: anchorRef, | ||
}) | ||
|
||
const extendedTextInputProps: Partial<TextInputProps> = useMemo(() => { | ||
return { | ||
|
@@ -189,22 +245,42 @@ export function SelectPanel({ | |
|
||
const usingModernActionList = useFeatureFlag('primer_react_select_panel_with_modern_action_list') | ||
|
||
if (!open) return <>{anchor}</> | ||
|
||
return ( | ||
<LiveRegion> | ||
<AnchoredOverlay | ||
renderAnchor={renderMenuAnchor} | ||
anchorRef={anchorRef} | ||
open={open} | ||
onOpen={onOpen} | ||
onClose={onClose} | ||
overlayProps={{ | ||
role: 'dialog', | ||
'aria-labelledby': titleId, | ||
'aria-describedby': subtitle ? subtitleId : undefined, | ||
...overlayProps, | ||
{anchor} | ||
|
||
<Overlay | ||
role="dialog" | ||
aria-labelledby={titleId} | ||
aria-describedby={subtitle ? subtitleId : undefined} | ||
ref={overlayRef} | ||
returnFocusRef={anchorRef} | ||
onEscape={() => onClose('escape')} | ||
onClickOutside={() => onClose('click-outside')} | ||
ignoreClickRefs={ | ||
/* this is required so that clicking the button while the panel is open does not re-open the panel */ | ||
[anchorRef] | ||
} | ||
{...position} | ||
{...overlayProps} | ||
sx={{ | ||
// TODO: check styles, do we need all of these? | ||
display: 'flex', | ||
padding: 0, | ||
color: 'fg.default', | ||
|
||
'&[data-variant="anchored"], &[data-variant="full-screen"]': { | ||
margin: 0, | ||
top: position?.top, | ||
left: position?.left, | ||
}, | ||
'&[data-variant="modal"]': { | ||
inset: 0, | ||
margin: 'auto', | ||
}, | ||
}} | ||
focusTrapSettings={focusTrapSettings} | ||
focusZoneSettings={focusZoneSettings} | ||
> | ||
<LiveRegionOutlet /> | ||
{usingModernActionList ? null : ( | ||
|
@@ -247,20 +323,57 @@ export function SelectPanel({ | |
// than the Overlay (which would break scrolling the items) | ||
sx={{...sx, height: 'inherit', maxHeight: 'inherit'}} | ||
/> | ||
{footer && ( | ||
{footer || variant === 'modal' ? ( | ||
<Box | ||
sx={{ | ||
display: 'flex', | ||
borderTop: '1px solid', | ||
borderColor: 'border.default', | ||
padding: 2, | ||
padding: variant === 'modal' ? 3 : 2, | ||
justifyContent: footer ? 'space-between' : 'end', | ||
alignItems: 'center', | ||
flexShrink: 0, | ||
minHeight: '44px', | ||
'> button': { | ||
// make button full width if there's just one | ||
width: variant === 'modal' ? 'auto' : '100%', | ||
}, | ||
}} | ||
> | ||
{footer} | ||
{variant === 'modal' ? ( | ||
<Box sx={{display: 'flex', gap: 2}}> | ||
<Button | ||
type="button" | ||
size="small" | ||
onClick={() => { | ||
onCancel() | ||
onClose('cancel-click') | ||
}} | ||
> | ||
Cancel | ||
</Button> | ||
{/* TODO: loading state for save? */} | ||
<Button type="submit" size="small" variant="primary" onClick={() => onClose('save-click')}> | ||
Save | ||
</Button> | ||
</Box> | ||
) : null} | ||
</Box> | ||
)} | ||
) : null} | ||
</Box> | ||
</AnchoredOverlay> | ||
</Overlay> | ||
|
||
{variant === 'modal' && ( | ||
<Box | ||
data-backdrop | ||
sx={{ | ||
backgroundColor: 'primer.canvas.backdrop', | ||
position: 'fixed', | ||
inset: 0, | ||
}} | ||
/> | ||
)} | ||
</LiveRegion> | ||
) | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the hover color is different?? It's really hard to tell