Skip to content

SelectPanel: Implement full variant for narrow screens #5170

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

Closed
wants to merge 21 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
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.
16 changes: 16 additions & 0 deletions packages/react/src/SelectPanel/SelectPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ import ThemeProvider from '../ThemeProvider'
import {FeatureFlags} from '../FeatureFlags'
import {getLiveRegion} from '../utils/testing'

// window.matchMedia() is not implemented by JSDOM so we have to create a mock:
// https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // deprecated
removeListener: jest.fn(), // deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
})

const renderWithFlag = (children: React.ReactNode, flag: boolean) => {
return render(
<FeatureFlags flags={{primer_react_select_panel_with_modern_action_list: flag}}>{children}</FeatureFlags>,
Expand Down
308 changes: 230 additions & 78 deletions packages/react/src/SelectPanel/SelectPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {SearchIcon, TriangleDownIcon} from '@primer/octicons-react'
import React, {useCallback, useMemo} from 'react'
import {SearchIcon, TriangleDownIcon, XIcon} from '@primer/octicons-react'
import React, {useCallback, useEffect, useMemo, useState} from 'react'
import type {AnchoredOverlayProps} from '../AnchoredOverlay'
import {AnchoredOverlay} from '../AnchoredOverlay'
import type {AnchoredOverlayWrapperAnchorProps} from '../AnchoredOverlay/AnchoredOverlay'
import Box from '../Box'
import type {FilteredActionListProps} from '../FilteredActionList'
Expand All @@ -11,13 +10,15 @@
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 {Button, IconButton} from '../Button'
import {useProvidedRefOrCreate, useAnchoredPosition, useOnEscapePress} 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 {useResponsiveValue} from '../hooks/useResponsiveValue'
import Overlay from '../Overlay/Overlay'
import {useFocusTrap} from '../hooks/useFocusTrap'

interface SelectPanelSingleSelection {
selected: ItemInput | undefined
Expand Down Expand Up @@ -56,11 +57,6 @@
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 @@ -118,6 +114,7 @@
const onClose = useCallback(
(gesture: Parameters<Exclude<AnchoredOverlayProps['onClose'], undefined>>[0] | 'selection') => {
onOpenChange(false, gesture)
setIsPanelOpened(false)
},
[onOpenChange],
)
Expand Down Expand Up @@ -173,9 +170,8 @@
}, [onClose, onSelectedChange, items, selected])

const inputRef = React.useRef<HTMLInputElement>(null)
const focusTrapSettings = {
initialFocusRef: inputRef,
}

const overlayRef = React.useRef<HTMLDivElement>(null)

const extendedTextInputProps: Partial<TextInputProps> = useMemo(() => {
return {
Expand All @@ -189,78 +185,234 @@

const usingModernActionList = useFeatureFlag('primer_react_select_panel_with_modern_action_list')

const responsiveVariants = Object.assign({regular: 'anchored', narrow: 'full-screen'}) // defaults

const currentVariant = useResponsiveValue(responsiveVariants, 'anchored')

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

useFocusTrap({
containerRef: overlayRef,
disabled: !open || !position,
returnFocusRef: anchorRef,
initialFocusRef: inputRef,
})

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,
onClick: onAnchorClick,
'aria-haspopup': true,
'aria-expanded': open,
onKeyDown: onAnchorKeyDown,
}

// Esc handler
useOnEscapePress(
(event: KeyboardEvent) => {
if (open) {
event.stopImmediatePropagation()
event.preventDefault()
onClose('escape')
}
},
[open],
)

const onClickOutside = () => {
onClose('click-outside')
}
const [initialSelected, setInitialSelected] = useState<ItemInput[]>([])
const [isPanelOpened, setIsPanelOpened] = useState(false)

const hasSaveAndCancelButtons = currentVariant === 'full-screen' && isMultiSelectVariant(selected)

useEffect(() => {
if (hasSaveAndCancelButtons && !isPanelOpened) {
setInitialSelected(selected)
setIsPanelOpened(true)
}
}, [hasSaveAndCancelButtons, isPanelOpened, selected])

const handleCancel = () => {
console.log('cancel in primer react')

Check failure on line 273 in packages/react/src/SelectPanel/SelectPanel.tsx

View workflow job for this annotation

GitHub Actions / lint

Unexpected console statement
if (hasSaveAndCancelButtons) {
onSelectedChange(initialSelected)
}

onClose('anchor-click')
}

console.log('current selected', selected)

Check failure on line 281 in packages/react/src/SelectPanel/SelectPanel.tsx

View workflow job for this annotation

GitHub Actions / lint

Unexpected console statement
const anchor = renderMenuAnchor ? renderMenuAnchor(anchorProps) : null
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,
}}
focusTrapSettings={focusTrapSettings}
focusZoneSettings={focusZoneSettings}
>
<LiveRegionOutlet />
{usingModernActionList ? null : (
<Message
value={
filterValue === ''
? 'Showing all items'
: items.length <= 0
? 'No matching items'
: `${items.length} matching ${items.length === 1 ? 'item' : 'items'}`
}
/>
)}
<Box sx={{display: 'flex', flexDirection: 'column', height: 'inherit', maxHeight: 'inherit'}}>
<Box sx={{pt: 2, px: 3}}>
<Heading as="h1" id={titleId} sx={{fontSize: 1}}>
{title}
</Heading>
{subtitle ? (
<Box id={subtitleId} sx={{fontSize: 0, color: 'fg.muted'}}>
{subtitle}
</Box>
) : null}
</Box>
<FilteredActionList
filterValue={filterValue}
onFilterChange={onFilterChange}
placeholderText={placeholderText}
{...listProps}
role="listbox"
// browsers give aria-labelledby precedence over aria-label so we need to make sure
// we don't accidentally override props.aria-label
aria-labelledby={listProps['aria-label'] ? undefined : titleId}
aria-multiselectable={isMultiSelectVariant(selected) ? 'true' : 'false'}
selectionVariant={isMultiSelectVariant(selected) ? 'multiple' : 'single'}
items={itemsToRender}
textInputProps={extendedTextInputProps}
inputRef={inputRef}
// inheriting height and maxHeight ensures that the FilteredActionList is never taller
// than the Overlay (which would break scrolling the items)
sx={{...sx, height: 'inherit', maxHeight: 'inherit'}}
/>
{footer && (
{anchor}
{open ? (
<Overlay
ref={overlayRef}
returnFocusRef={anchorRef}
onEscape={() => onClose('escape')}
role="dialog"
aria-labelledby={titleId}
aria-describedby={subtitle ? subtitleId : undefined}
onClickOutside={onClickOutside}
ignoreClickRefs={[anchorRef]}
data-variant={currentVariant}
initialFocusRef={inputRef}
left={currentVariant === 'full-screen' ? 0 : position?.left || 0}
top={currentVariant === 'full-screen' ? 0 : position?.top || 0}
sx={{
// reset dialog default styles
border: 'none',
display: 'flex',
padding: 0,
color: 'fg.default',

'&[data-variant="anchored"], &[data-variant="full-screen"]': {
margin: 0,
},
'&[data-variant="full-screen"]': {
margin: 0,
width: '100vw',
maxWidth: '100vw',
height: '100vh',
maxHeight: '100vh',
borderRadius: 'unset',
},
}}
{...overlayProps}
>
<LiveRegionOutlet />
{usingModernActionList ? null : (
<Message
value={
filterValue === ''
? 'Showing all items'
: items.length <= 0
? 'No matching items'
: `${items.length} matching ${items.length === 1 ? 'item' : 'items'}`
}
/>
)}
<Box sx={{display: 'flex', flexDirection: 'column', height: 'inherit', maxHeight: 'inherit', width: '100%'}}>
<Box
sx={{
display: 'flex',
borderTop: '1px solid',
borderColor: 'border.default',
padding: 2,
justifyContent: 'space-between',
alignItems: 'flex-start',
padding: '8px',
paddingBottom: 0, // search input has its own padding
}}
>
{footer}
<Box sx={{paddingInline: '8px', paddingBlock: '4px'}}>
<Heading as="h1" id={titleId} sx={{fontSize: 1, paddingBottom: '4px'}}>
{title}
</Heading>
{subtitle ? (
<Box id={subtitleId} sx={{fontSize: 0, color: 'fg.muted'}}>
{subtitle}
</Box>
) : null}
</Box>
<IconButton type="button" variant="invisible" icon={XIcon} aria-label="Close" onClick={handleCancel} />
</Box>
)}
</Box>
</AnchoredOverlay>

<FilteredActionList
filterValue={filterValue}
onFilterChange={onFilterChange}
placeholderText={placeholderText}
{...listProps}
role="listbox"
// browsers give aria-labelledby precedence over aria-label so we need to make sure
// we don't accidentally override props.aria-label
aria-labelledby={listProps['aria-label'] ? undefined : titleId}
aria-multiselectable={isMultiSelectVariant(selected) ? 'true' : 'false'}
selectionVariant={isMultiSelectVariant(selected) ? 'multiple' : 'single'}
items={itemsToRender}
textInputProps={extendedTextInputProps}
inputRef={inputRef}
// inheriting height and maxHeight ensures that the FilteredActionList is never taller
// than the Overlay (which would break scrolling the items)
sx={{...sx, height: 'inherit', maxHeight: 'inherit'}}
/>
{footer || hasSaveAndCancelButtons ? (
<Box
sx={{
display: 'flex',
borderTop: '1px solid',
borderColor: 'border.default',
padding: hasSaveAndCancelButtons ? 3 : 2,
justifyContent: footer ? 'space-between' : 'end',
alignItems: 'center',
flexShrink: 0,
minHeight: '44px',
'> button': {
// make button full width if there's just one
width: hasSaveAndCancelButtons ? 'auto' : '100%',
},
}}
>
{footer}
{hasSaveAndCancelButtons ? (
<Box sx={{display: 'flex', gap: 2}}>
<Button type="button" size="small" onClick={handleCancel}>
Cancel
</Button>
{/* TODO: loading state for save? */}
<Button
type="submit"
size="small"
variant="primary"
onClick={() => {
// assuming it was saving onSelectedChange already
onClose('anchor-click')
}}
>
Save
</Button>
</Box>
) : null}
</Box>
) : null}
</Box>
</Overlay>
) : null}
</LiveRegion>
)
}
Expand Down
Loading