diff --git a/.changeset/soft-schools-nail.md b/.changeset/soft-schools-nail.md new file mode 100644 index 00000000000..f1238040154 --- /dev/null +++ b/.changeset/soft-schools-nail.md @@ -0,0 +1,5 @@ +--- +"@primer/components": patch +--- + +Handle overflow and active-descendant scrolling within `SelectPanel` diff --git a/docs/content/SelectPanel.mdx b/docs/content/SelectPanel.mdx index 549a762d7cf..06216bb4f85 100644 --- a/docs/content/SelectPanel.mdx +++ b/docs/content/SelectPanel.mdx @@ -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 + } +} + +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 ( + ( + + {children || 'Select Labels'} + + )} + placeholderText="Filter Labels" + open={open} + onOpenChange={setOpen} + items={filteredItems} + selected={selected} + onSelectedChange={setSelected} + onFilterChange={setFilter} + showItemDividers={true} + overlayProps={{width: 'small', height: 'xsmall'}} + /> + ) +} + +render() +``` + ## Component props diff --git a/docs/src/@primer/gatsby-theme-doctocat/live-code-scope.js b/docs/src/@primer/gatsby-theme-doctocat/live-code-scope.js index e80b9fa4b32..0c928bffe25 100644 --- a/docs/src/@primer/gatsby-theme-doctocat/live-code-scope.js +++ b/docs/src/@primer/gatsby-theme-doctocat/live-code-scope.js @@ -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, @@ -48,5 +49,6 @@ export default { Dialog2, ConfirmationDialog, useConfirm, - AnchoredOverlay + AnchoredOverlay, + SelectPanel } diff --git a/src/FilteredActionList/FilteredActionList.tsx b/src/FilteredActionList/FilteredActionList.tsx index b56a21d300c..1d48f150e71 100644 --- a/src/FilteredActionList/FilteredActionList.tsx +++ b/src/FilteredActionList/FilteredActionList.tsx @@ -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>, ListPropsBase { loading?: boolean @@ -17,6 +20,34 @@ export interface FilteredActionListProps extends Partial> } +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, @@ -37,6 +68,7 @@ export function FilteredActionList({ ) const containerRef = useRef(null) + const scrollContainerRef = useRef(null) const inputRef = useRef(null) const activeDescendantRef = useRef() const listId = useMemo(uniqueId, []) @@ -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 ( - - - + + + + + {loading ? ( @@ -102,7 +147,7 @@ export function FilteredActionList({ )} - + ) } diff --git a/src/stories/SelectPanel.stories.tsx b/src/stories/SelectPanel.stories.tsx index 2728692b71a..2f1d16c5d53 100644 --- a/src/stories/SelectPanel.stories.tsx +++ b/src/stories/SelectPanel.stories.tsx @@ -31,7 +31,7 @@ export default meta function getColorCircle(color: string) { return function () { - return + return } } @@ -39,7 +39,10 @@ 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 { @@ -66,6 +69,7 @@ export function MultiSelectStory(): JSX.Element { onSelectedChange={setSelected} onFilterChange={setFilter} showItemDividers={true} + overlayProps={{width: 'small', height: 'xsmall'}} /> ) @@ -96,6 +100,7 @@ export function SingleSelectStory(): JSX.Element { onSelectedChange={setSelected} onFilterChange={setFilter} showItemDividers={true} + overlayProps={{width: 'small', height: 'xsmall'}} /> )