Skip to content

Commit

Permalink
Merge ca28bb6 into cbeed21
Browse files Browse the repository at this point in the history
  • Loading branch information
broccolinisoup authored Oct 31, 2024
2 parents cbeed21 + ca28bb6 commit 86f3ab7
Show file tree
Hide file tree
Showing 2 changed files with 212 additions and 80 deletions.
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
276 changes: 196 additions & 80 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 {SearchIcon, TriangleDownIcon, XIcon} from '@primer/octicons-react'
import React, {useCallback, useMemo} 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,16 @@ import type {OverlayProps} from '../Overlay'
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, useOnOutsideClick} 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 {StyledOverlay} from '../Overlay/Overlay'
import {useFocusTrap} from '../hooks/useFocusTrap'
import Portal from '../Portal'

interface SelectPanelSingleSelection {
selected: ItemInput | undefined
Expand Down Expand Up @@ -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 @@ -173,9 +170,8 @@ export function SelectPanel({
}, [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,198 @@ export function SelectPanel({

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')
}
useOnOutsideClick({
onClickOutside,
containerRef: overlayRef,
ignoreClickRefs: [anchorRef],
})

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 ? (
<Portal>
<StyledOverlay
ref={overlayRef}
role="dialog"
aria-labelledby={titleId}
aria-describedby={subtitle ? subtitleId : undefined}
data-variant={currentVariant}
sx={{
// reset dialog default styles
// width: 'medium',
border: 'none',
display: 'flex',
padding: 0,
color: 'fg.default',
'&[data-variant="anchored"], &[data-variant="full-screen"]': {
margin: 0,
top: position?.top,
left: position?.left,
'::backdrop': {backgroundColor: 'transparent'},
},
'&[data-variant="full-screen"]': {
margin: 0,
top: 0,
left: 0,
width: '100%',
maxWidth: '100vw',
height: '100%',
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',
borderTop: '1px solid',
borderColor: 'border.default',
padding: 2,
}}
sx={{display: 'flex', flexDirection: 'column', height: 'inherit', maxHeight: 'inherit', width: '100%'}}
>
{footer}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
padding: '8px',
paddingBottom: 0, // search input has its own padding
}}
>
<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={() => onClose('anchor-click')}
/>
</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 && (
<Box
sx={{
display: 'flex',
borderTop: '1px solid',
borderColor: 'border.default',
padding: 2,
}}
>
{footer}
</Box>
)}
</Box>
)}
</Box>
</AnchoredOverlay>
</StyledOverlay>
</Portal>
) : null}
</LiveRegion>
)
}
Expand Down

0 comments on commit 86f3ab7

Please sign in to comment.