Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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/soft-schools-nail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/components": patch
---

Handle overflow and active-descendant scrolling within `SelectPanel`
46 changes: 46 additions & 0 deletions docs/content/SelectPanel.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,50 @@ A `SelectPanel` provides an anchor that will open an overlay with a list of sele

## Example

```javascript live noinline
function getColorCircle(color) {
return function () {
return <BorderBox bg={color} borderColor={color} width={14} height={14} borderRadius={10} margin="auto" />
}
}

const items = [
{leadingVisual: getColorCircle('#a2eeef'), text: 'enhancement', id: 1},
{leadingVisual: getColorCircle('#d73a4a'), text: 'bug', id: 2},
{leadingVisual: getColorCircle('#0cf478'), text: 'good first issue', id: 3},
{leadingVisual: getColorCircle('#ffd78e'), text: 'design', id: 4},
{leadingVisual: getColorCircle('#ff0000'), text: 'blocker', id: 5},
{leadingVisual: getColorCircle('#a4f287'), text: 'backend', id: 6},
{leadingVisual: getColorCircle('#8dc6fc'), text: 'frontend', id: 7}
]

function DemoComponent() {
const [selected, setSelected] = React.useState([items[0], items[1]])
const [filter, setFilter] = React.useState('')
const filteredItems = items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase()))
const [open, setOpen] = React.useState(false)

return (
<SelectPanel
renderAnchor={({children, 'aria-labelledby': ariaLabelledBy, ...anchorProps}) => (
<DropdownButton aria-labelledby={` ${ariaLabelledBy}`} {...anchorProps}>
{children || 'Select Labels'}
</DropdownButton>
)}
placeholderText="Filter Labels"
open={open}
onOpenChange={setOpen}
items={filteredItems}
selected={selected}
onSelectedChange={setSelected}
onFilterChange={setFilter}
showItemDividers={true}
overlayProps={{width: 'small', height: 'xsmall'}}
/>
)
}

render(<DemoComponent />)
```

## Component props
4 changes: 3 additions & 1 deletion docs/src/@primer/gatsby-theme-doctocat/live-code-scope.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import State from '../../../components/State'
import {Dialog as Dialog2} from '../../../../src/Dialog/Dialog'
import {AnchoredOverlay} from '../../../../src/AnchoredOverlay'
import {ConfirmationDialog, useConfirm} from '../../../../src/Dialog/ConfirmationDialog'
import {SelectPanel} from '../../../../src/SelectPanel/SelectPanel'

export default {
...doctocatComponents,
Expand All @@ -48,5 +49,6 @@ export default {
Dialog2,
ConfirmationDialog,
useConfirm,
AnchoredOverlay
AnchoredOverlay,
SelectPanel
}
79 changes: 62 additions & 17 deletions src/FilteredActionList/FilteredActionList.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import React, {KeyboardEventHandler, useCallback, useMemo, useRef} from 'react'
import React, {KeyboardEventHandler, useCallback, useEffect, useMemo, useRef} from 'react'
import {GroupedListProps, ListPropsBase} from '../ActionList/List'
import TextInput, {TextInputProps} from '../TextInput'
import Box from '../Box'
import Flex from '../Flex'
import {ActionList} from '../ActionList'
import Spinner from '../Spinner'
import {useFocusZone} from '../hooks/useFocusZone'
import {uniqueId} from '../utils/uniqueId'
import {itemActiveDescendantClass} from '../ActionList/Item'
import {useProvidedStateOrCreate} from '../hooks/useProvidedStateOrCreate'
import styled from 'styled-components'
import {get} from '../constants'

export interface FilteredActionListProps extends Partial<Omit<GroupedListProps, keyof ListPropsBase>>, ListPropsBase {
loading?: boolean
Expand All @@ -17,6 +20,34 @@ export interface FilteredActionListProps extends Partial<Omit<GroupedListProps,
textInputProps?: Partial<Omit<TextInputProps, 'onChange'>>
}

function scrollIntoViewingArea(
child: HTMLElement,
container: HTMLElement,
margin = 8,
behavior: ScrollBehavior = 'smooth'
) {
const {top: childTop, bottom: childBottom} = child.getBoundingClientRect()
const {top: containerTop, bottom: containerBottom} = container.getBoundingClientRect()

const isChildTopAboveViewingArea = childTop < containerTop + margin
const isChildBottomBelowViewingArea = childBottom > containerBottom - margin

if (isChildTopAboveViewingArea) {
const scrollHeightToChildTop = childTop - containerTop + container.scrollTop
container.scrollTo({behavior, top: scrollHeightToChildTop - margin})
} else if (isChildBottomBelowViewingArea) {
const scrollHeightToChildBottom = childBottom - containerBottom + container.scrollTop
container.scrollTo({behavior, top: scrollHeightToChildBottom + margin})
}

// either completely in view or outside viewing area on both ends, don't scroll
}

const StyledHeader = styled.div`
box-shadow: 0 1px 0 ${get('colors.border.primary')};
z-index: 1;
`

export function FilteredActionList({
loading = false,
placeholderText,
Expand All @@ -37,6 +68,7 @@ export function FilteredActionList({
)

const containerRef = useRef<HTMLInputElement>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const activeDescendantRef = useRef<HTMLElement>()
const listId = useMemo(uniqueId, [])
Expand Down Expand Up @@ -74,26 +106,39 @@ export function FilteredActionList({

if (current) {
current.classList.add(itemActiveDescendantClass)

if (scrollContainerRef.current) {
scrollIntoViewingArea(current, scrollContainerRef.current)
}
}
}
})

useEffect(() => {
// if items changed, we want to instantly move active descendant into view
if (activeDescendantRef.current && scrollContainerRef.current) {
scrollIntoViewingArea(activeDescendantRef.current, scrollContainerRef.current, undefined, 'auto')
}
}, [items])

return (
<Box ref={containerRef} flexGrow={1} flexDirection="column">
<TextInput
ref={inputRef}
block
width="auto"
color="text.primary"
value={filterValue}
onChange={onInputChange}
onKeyPress={onInputKeyPress}
placeholder={placeholderText}
aria-label={placeholderText}
aria-controls={listId}
{...textInputProps}
/>
<Box flexGrow={1} overflow="auto">
<Flex ref={containerRef} flexDirection="column" overflow="hidden">
<StyledHeader>
<TextInput
ref={inputRef}
block
width="auto"
color="text.primary"
value={filterValue}
onChange={onInputChange}
onKeyPress={onInputKeyPress}
placeholder={placeholderText}
aria-label={placeholderText}
aria-controls={listId}
{...textInputProps}
/>
</StyledHeader>
<Box ref={scrollContainerRef} overflow="auto">
{loading ? (
<Box width="100%" display="flex" flexDirection="row" justifyContent="center" pt={6} pb={7}>
<Spinner />
Expand All @@ -102,7 +147,7 @@ export function FilteredActionList({
<ActionList items={items} {...listProps} role="listbox" id={listId} />
)}
</Box>
</Box>
</Flex>
)
}

Expand Down
9 changes: 7 additions & 2 deletions src/stories/SelectPanel.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,18 @@ export default meta

function getColorCircle(color: string) {
return function () {
return <BorderBox bg={color} borderColor={color} padding={2} borderRadius={10} />
return <BorderBox bg={color} borderColor={color} width={14} height={14} borderRadius={10} margin="auto" />
}
}

const items = [
{leadingVisual: getColorCircle('#a2eeef'), text: 'enhancement', id: 1},
{leadingVisual: getColorCircle('#d73a4a'), text: 'bug', id: 2},
{leadingVisual: getColorCircle('#0cf478'), text: 'good first issue', id: 3},
{leadingVisual: getColorCircle('#8dc6fc'), text: 'design', id: 4}
{leadingVisual: getColorCircle('#ffd78e'), text: 'design', id: 4},
{leadingVisual: getColorCircle('#ff0000'), text: 'blocker', id: 5},
{leadingVisual: getColorCircle('#a4f287'), text: 'backend', id: 6},
{leadingVisual: getColorCircle('#8dc6fc'), text: 'frontend', id: 7}
]

export function MultiSelectStory(): JSX.Element {
Expand All @@ -66,6 +69,7 @@ export function MultiSelectStory(): JSX.Element {
onSelectedChange={setSelected}
onFilterChange={setFilter}
showItemDividers={true}
overlayProps={{width: 'small', height: 'xsmall'}}
/>
</>
)
Expand Down Expand Up @@ -96,6 +100,7 @@ export function SingleSelectStory(): JSX.Element {
onSelectedChange={setSelected}
onFilterChange={setFilter}
showItemDividers={true}
overlayProps={{width: 'small', height: 'xsmall'}}
/>
</>
)
Expand Down