Skip to content
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

Closed
wants to merge 28 commits into from
Closed
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
f7c39ea
WIP - full variant
broccolinisoup Oct 21, 2024
f31034f
wip
broccolinisoup Oct 21, 2024
87b84ee
full variant style fix
broccolinisoup Oct 23, 2024
36f029e
Merge branch 'main' into select-panel-full-variant
broccolinisoup Oct 23, 2024
ef13937
Merge branch 'main' into select-panel-full-variant
broccolinisoup Oct 28, 2024
3816a96
implement anchor for full variant and close button
broccolinisoup Oct 28, 2024
e72d333
Merge branch 'main' into select-panel-full-variant
broccolinisoup Oct 29, 2024
2e5a623
Refactor selectpanel to use styledoverlay instead of anchored overlay
broccolinisoup Oct 30, 2024
987f189
Merge branch 'main' into select-panel-full-variant
broccolinisoup Oct 30, 2024
bc9a6c9
wip
siddharthkp Oct 30, 2024
d165dae
default modal size + add backdrop
siddharthkp Oct 31, 2024
667fb54
add footer and onCancel fn
siddharthkp Oct 31, 2024
ca5f714
add example of modal and secondary action, leave notes
siddharthkp Oct 31, 2024
f8d47e9
add docs.json entries
siddharthkp Oct 31, 2024
53ff7ba
use Overlay instead of AnchoredOverlay
siddharthkp Nov 5, 2024
859019e
Create fluffy-days-brake.md
siddharthkp Nov 7, 2024
e427df0
the tiniest chnage
siddharthkp Nov 7, 2024
fca7e79
move focus trap for easier review
siddharthkp Nov 7, 2024
f0be480
tiny formatting to make it easier to review
siddharthkp Nov 7, 2024
1b7b968
Merge branch 'selectpanel-use-anchored-position' into selectpanel-mod…
siddharthkp Nov 7, 2024
679f5d0
remove onCancel
siddharthkp Nov 7, 2024
d7b28be
dont need mock for modal
siddharthkp Nov 7, 2024
5db37aa
test(vrt): update snapshots
siddharthkp Nov 7, 2024
2c92a4f
trying to center
siddharthkp Nov 7, 2024
7c178bd
centering works now
siddharthkp Nov 7, 2024
43aaf3f
add modal stories to e2e
siddharthkp Nov 7, 2024
2a14113
test(vrt): update snapshots
siddharthkp Nov 7, 2024
fdc0bd4
Merge branch 'main' into selectpanel-modal-variant-2
siddharthkp Nov 8, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fluffy-days-brake.md
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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Copy link
Member Author

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

siddharthkp marked this conversation as resolved.
Show resolved Hide resolved
6 changes: 6 additions & 0 deletions packages/react/src/SelectPanel/SelectPanel.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@
"defaultValue": "",
"description": "See [TextInput props](/react/TextInput#props)."
},
{
"name": "variant",
"type": "'anchored' | 'modal'",
"defaultValue": "anchored",
"description": "Anchored to the button or centered on the screen. Modal variant also adds Save and Cancel buttons."
},
{
"name": "footer",
"type": "string | React.ReactElement",
Expand Down
63 changes: 63 additions & 0 deletions packages/react/src/SelectPanel/SelectPanel.features.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>}
Copy link
Member Author

@siddharthkp siddharthkp Oct 31, 2024

Choose a reason for hiding this comment

The 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

  • Button
  • LinkButton
  • custom FooterActions component that has a spinner or an error message! (these cases are filling in for missing features and we should be able to simplify them with error messages and loading states)

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}
/>
</>
)
}
183 changes: 148 additions & 35 deletions packages/react/src/SelectPanel/SelectPanel.tsx
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'
Expand All @@ -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
Expand All @@ -29,23 +29,25 @@ interface SelectPanelMultiSelection {
onSelectedChange: (selected: ItemInput[]) => void
}

type OpenGestures = 'anchor-click' | 'anchor-key-press'
Copy link
Member

Choose a reason for hiding this comment

The 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)
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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 : (
Expand Down Expand Up @@ -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>
)
}
Expand Down
Loading