Skip to content

Commit

Permalink
feat(SelectPanel): auto scroll when list overflows (#1247)
Browse files Browse the repository at this point in the history
  • Loading branch information
dgreif committed May 25, 2021
1 parent 3963b64 commit 10df320
Show file tree
Hide file tree
Showing 5 changed files with 123 additions and 20 deletions.
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

1 comment on commit 10df320

@vercel
Copy link

@vercel vercel bot commented on 10df320 May 25, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.