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'}}
/>
>
)