Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Select panel] Loading states re-submission #5139

Merged
merged 41 commits into from
Jan 30, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
245a841
Revert "Revert "Select panel loading states (#4929)" (#5136)"
broccolinisoup Oct 17, 2024
af26d78
Merge branch 'main' into revert-5136-revert-4929-select_panel_loading…
broccolinisoup Oct 21, 2024
49c7005
Use published primer/behaviors
camertron Oct 21, 2024
3f33d6e
Merge upstream
camertron Oct 21, 2024
d80d5f1
Merge upstream again
camertron Oct 21, 2024
2bed50b
Revert snapshot change to TextInput
camertron Oct 21, 2024
121718b
Huh ok never mind
camertron Oct 21, 2024
1906413
Use role in test
camertron Oct 21, 2024
cc2fa9a
Prevent active descendant from resetting when items change
camertron Oct 22, 2024
bcba97d
Merge branch 'main' into revert-5136-revert-4929-select_panel_loading…
broccolinisoup Oct 24, 2024
b2e6d65
Allow loading state to be managed by the user
camertron Oct 25, 2024
1d47771
Merge branch 'revert-5136-revert-4929-select_panel_loading_states' of…
camertron Oct 25, 2024
1898b28
Merge branch 'main' into revert-5136-revert-4929-select_panel_loading…
camertron Oct 25, 2024
15d81f5
Merge branch 'main' into revert-5136-revert-4929-select_panel_loading…
broccolinisoup Nov 7, 2024
543ff54
Encounter the loading state for the initial state announcement
broccolinisoup Nov 8, 2024
9a4a628
Merge upstream changes
camertron Nov 25, 2024
549d98b
It's working
camertron Dec 9, 2024
6f0d035
Cleanup
camertron Dec 9, 2024
f31f3fa
Merge main
camertron Dec 9, 2024
a00e399
Merging upstream
camertron Jan 21, 2025
0f6b5dd
Fix linting issues
camertron Jan 21, 2025
36ca499
Merge branch 'main' into revert-5136-revert-4929-select_panel_loading…
camertron Jan 21, 2025
ca34373
Revert snapshots
camertron Jan 21, 2025
1ddf03e
Merge branch 'revert-5136-revert-4929-select_panel_loading_states' of…
camertron Jan 21, 2025
30c1134
test(vrt): update snapshots
francinelucca Jan 21, 2025
da05622
Backport #5551 to this PR
camertron Jan 21, 2025
9b14f3e
Merge branch 'revert-5136-revert-4929-select_panel_loading_states' of…
camertron Jan 21, 2025
3c8826b
Merge branch 'main' into revert-5136-revert-4929-select_panel_loading…
francinelucca Jan 22, 2025
74765d0
Merge branch 'main' into revert-5136-revert-4929-select_panel_loading…
camertron Jan 22, 2025
b1773dc
Merge branch 'main' into revert-5136-revert-4929-select_panel_loading…
camertron Jan 22, 2025
30cd041
Fix auto-focus behavior
camertron Jan 23, 2025
e409c49
Merge upstream changes
camertron Jan 23, 2025
4b1ac99
Add test to ensure focus behavior
camertron Jan 23, 2025
8b41ec0
Merge branch 'main' into revert-5136-revert-4929-select_panel_loading…
camertron Jan 27, 2025
97b50ba
Allow consumers to pass an inputRef; DRY up timeout cleanup; simplify…
camertron Jan 27, 2025
0cd6dbd
DRY up announcement code; restore useAnnouncements hook for non-Selec…
camertron Jan 27, 2025
986dd49
Fix types tests
camertron Jan 27, 2025
d49d955
Remove testing ID
camertron Jan 27, 2025
7215b43
Merge upstream
camertron Jan 28, 2025
e2b8446
Remove confusing componentManagesLoading control in async story
camertron Jan 28, 2025
6d12edc
Merge branch 'main' into revert-5136-revert-4929-select_panel_loading…
camertron Jan 30, 2025
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/selfish-garlics-approve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

[SelectPanel] Implement loading states
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import React from 'react'
import Box from '../Box'
import Spinner from '../Spinner'
import {Stack} from '../Stack/Stack'
import {SkeletonBox} from '../experimental/Skeleton/SkeletonBox'

export class FilteredActionListLoadingType {
public name: string
public appearsInBody: boolean

constructor(name: string, appearsInBody: boolean) {
this.name = name
this.appearsInBody = appearsInBody
}
}

export const FilteredActionListLoadingTypes = {
bodySpinner: new FilteredActionListLoadingType('body-spinner', true),
bodySkeleton: new FilteredActionListLoadingType('body-skeleton', true),
input: new FilteredActionListLoadingType('input', false),
}

const SKELETON_ROW_HEIGHT = 24
const SKELETON_MIN_ROWS = 3

export function FilteredActionListBodyLoader({
loadingType,
height,
}: {
loadingType: FilteredActionListLoadingType
height: number
}): JSX.Element {
switch (loadingType) {
case FilteredActionListLoadingTypes.bodySpinner:
return <LoadingSpinner data-testid="filtered-action-list-spinner" />
case FilteredActionListLoadingTypes.bodySkeleton: {
const rows = height < SKELETON_ROW_HEIGHT ? SKELETON_MIN_ROWS : height / SKELETON_ROW_HEIGHT
return <LoadingSkeleton data-testid="filtered-action-list-skeleton" rows={rows} />
}
default:
return <></>
}
}

function LoadingSpinner({...props}): JSX.Element {
return (
<Box p={3} flexGrow={1} sx={{alignContent: 'center', textAlign: 'center', height: '100%'}}>
<Spinner {...props} />
</Box>
)
}

function LoadingSkeleton({rows = 10, ...props}: {rows: number}): JSX.Element {
return (
<Box p={2} display="flex" flexGrow={1} flexDirection="column">
<Stack direction="vertical" justify="center" gap="condensed" {...props}>
{Array.from({length: rows}, (_, i) => (
<Stack key={i} direction="horizontal" gap="condensed" align="center">
<SkeletonBox width="16px" height="16px" />
<SkeletonBox height="10px" width={`${Math.random() * 60 + 20}%`} sx={{borderRadius: '4px'}} />
</Stack>
))}
</Stack>
</Box>
)
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import type {ScrollIntoViewOptions} from '@primer/behaviors'
import {scrollIntoView} from '@primer/behaviors'
import type {KeyboardEventHandler} from 'react'
import React, {useCallback, useEffect, useRef} from 'react'
import type {KeyboardEventHandler, RefObject} from 'react'
import React, {useCallback, useEffect, useRef, useState} from 'react'
import styled from 'styled-components'
import Box from '../Box'
import Spinner from '../Spinner'
import type {TextInputProps} from '../TextInput'
import TextInput from '../TextInput'
import {get} from '../constants'
Expand All @@ -17,6 +16,11 @@ import {useProvidedStateOrCreate} from '../hooks/useProvidedStateOrCreate'
import useScrollFlash from '../hooks/useScrollFlash'
import {VisuallyHidden} from '../VisuallyHidden'
import type {SxProp} from '../sx'
import {
type FilteredActionListLoadingType,
FilteredActionListBodyLoader,
FilteredActionListLoadingTypes,
} from './FilteredActionListLoaders'

const menuScrollMargins: ScrollIntoViewOptions = {startMargin: 0, endMargin: 8}

Expand All @@ -25,12 +29,16 @@ export interface FilteredActionListProps
ListPropsBase,
SxProp {
loading?: boolean
loadingType?: FilteredActionListLoadingType
placeholderText?: string
filterValue?: string
onFilterChange: (value: string, e: React.ChangeEvent<HTMLInputElement>) => void
onFilterChange: (value: string, e: React.ChangeEvent<HTMLInputElement> | null) => void
onListContainerRefChanged?: (ref: HTMLElement | null) => void
onInputRefChanged?: (ref: React.RefObject<HTMLInputElement>) => void
textInputProps?: Partial<Omit<TextInputProps, 'onChange'>>
inputRef?: React.RefObject<HTMLInputElement>
className?: string
announcementsEnabled?: boolean
}

const StyledHeader = styled.div`
Expand All @@ -40,14 +48,18 @@ const StyledHeader = styled.div`

export function FilteredActionList({
loading = false,
loadingType = FilteredActionListLoadingTypes.bodySpinner,
placeholderText,
filterValue: externalFilterValue,
onFilterChange,
onListContainerRefChanged,
onInputRefChanged,
items,
textInputProps,
inputRef: providedInputRef,
sx,
className,
announcementsEnabled: _announcementsEnabled = true,
camertron marked this conversation as resolved.
Show resolved Hide resolved
...listProps
}: FilteredActionListProps): JSX.Element {
const [filterValue, setInternalFilterValue] = useProvidedStateOrCreate(externalFilterValue, undefined, '')
Expand All @@ -61,7 +73,7 @@ export function FilteredActionList({
)

const scrollContainerRef = useRef<HTMLDivElement>(null)
const listContainerRef = useRef<HTMLDivElement>(null)
const [listContainerElement, setListContainerElement] = useState<HTMLDivElement | null>(null)
const inputRef = useProvidedRefOrCreate<HTMLInputElement>(providedInputRef)
const activeDescendantRef = useRef<HTMLElement>()
const listId = useId()
Expand All @@ -80,9 +92,21 @@ export function FilteredActionList({
[activeDescendantRef],
)

const listContainerRefCallback = useCallback(
(node: HTMLDivElement | null) => {
setListContainerElement(node)
onListContainerRefChanged?.(node)
},
[onListContainerRefChanged],
)

useEffect(() => {
onInputRefChanged?.(inputRef)
}, [inputRef, onInputRefChanged])

useFocusZone(
{
containerRef: listContainerRef,
containerRef: {current: listContainerElement},
focusOutBehavior: 'wrap',
focusableElementFilter: element => {
return !(element instanceof HTMLInputElement)
Expand All @@ -97,15 +121,18 @@ export function FilteredActionList({
},
},
[
// List ref isn't set while loading. Need to re-bind focus zone when it changes
loading,
// List container isn't in the DOM while loading. Need to re-bind focus zone when it changes.
listContainerElement,
],
)

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

Expand All @@ -116,6 +143,7 @@ export function FilteredActionList({
display="flex"
flexDirection="column"
overflow="hidden"
flexGrow={1}
sx={sx}
className={className}
data-testid="filtered-action-list"
Expand All @@ -133,17 +161,17 @@ export function FilteredActionList({
aria-label={placeholderText}
aria-controls={listId}
aria-describedby={inputDescriptionTextId}
loaderPosition={'leading'}
loading={loading && !loadingType.appearsInBody}
{...textInputProps}
/>
</StyledHeader>
<VisuallyHidden id={inputDescriptionTextId}>Items will be filtered as you type</VisuallyHidden>
<Box ref={scrollContainerRef} overflow="auto">
{loading ? (
<Box width="100%" display="flex" flexDirection="row" justifyContent="center" pt={6} pb={7}>
<Spinner />
</Box>
<Box ref={scrollContainerRef} overflow="auto" flexGrow={1}>
{loading && scrollContainerRef.current && loadingType.appearsInBody ? (
<FilteredActionListBodyLoader loadingType={loadingType} height={scrollContainerRef.current.clientHeight} />
) : (
<ActionList ref={listContainerRef} items={items} {...listProps} role="listbox" id={listId} />
<ActionList ref={listContainerRefCallback} items={items} {...listProps} role="listbox" id={listId} />
)}
</Box>
</Box>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import type {ScrollIntoViewOptions} from '@primer/behaviors'
import {scrollIntoView, FocusKeys} from '@primer/behaviors'
import type {KeyboardEventHandler} from 'react'
import React, {useCallback, useEffect, useRef} from 'react'
import React, {useCallback, useEffect, useRef, useState} from 'react'
import styled from 'styled-components'
import Box from '../Box'
import Spinner from '../Spinner'
import type {TextInputProps} from '../TextInput'
import TextInput from '../TextInput'
import {get} from '../constants'
Expand All @@ -17,6 +16,8 @@ import {useProvidedStateOrCreate} from '../hooks/useProvidedStateOrCreate'
import useScrollFlash from '../hooks/useScrollFlash'
import {VisuallyHidden} from '../VisuallyHidden'
import type {SxProp} from '../sx'
import type {FilteredActionListLoadingType} from './FilteredActionListLoaders'
import {FilteredActionListLoadingTypes, FilteredActionListBodyLoader} from './FilteredActionListLoaders'

import {isValidElementType} from 'react-is'
import type {RenderItemFn} from '../deprecated/ActionList/List'
Expand All @@ -29,12 +30,16 @@ export interface FilteredActionListProps
ListPropsBase,
SxProp {
loading?: boolean
loadingType?: FilteredActionListLoadingType
placeholderText?: string
filterValue?: string
onFilterChange: (value: string, e: React.ChangeEvent<HTMLInputElement>) => void
onListContainerRefChanged?: (ref: HTMLElement | null) => void
onInputRefChanged?: (ref: React.RefObject<HTMLInputElement>) => void
textInputProps?: Partial<Omit<TextInputProps, 'onChange'>>
inputRef?: React.RefObject<HTMLInputElement>
className?: string
announcementsEnabled?: boolean
}

const StyledHeader = styled.div`
Expand All @@ -46,14 +51,18 @@ export function FilteredActionList({
loading = false,
placeholderText,
filterValue: externalFilterValue,
loadingType = FilteredActionListLoadingTypes.bodySpinner,
onFilterChange,
onListContainerRefChanged,
onInputRefChanged,
items,
textInputProps,
inputRef: providedInputRef,
sx,
groupMetadata,
showItemDividers,
className,
announcementsEnabled = true,
...listProps
}: FilteredActionListProps): JSX.Element {
const [filterValue, setInternalFilterValue] = useProvidedStateOrCreate(externalFilterValue, undefined, '')
Expand All @@ -67,8 +76,8 @@ export function FilteredActionList({
)

const scrollContainerRef = useRef<HTMLDivElement>(null)
const listContainerRef = useRef<HTMLUListElement>(null)
const inputRef = useProvidedRefOrCreate<HTMLInputElement>(providedInputRef)
const [listContainerElement, setListContainerElement] = useState<HTMLUListElement | null>(null)
const activeDescendantRef = useRef<HTMLElement>()
const listId = useId()
const inputDescriptionTextId = useId()
Expand All @@ -86,9 +95,21 @@ export function FilteredActionList({
[activeDescendantRef],
)

const listContainerRefCallback = useCallback(
(node: HTMLUListElement | null) => {
setListContainerElement(node)
onListContainerRefChanged?.(node)
},
[onListContainerRefChanged],
)

useEffect(() => {
onInputRefChanged?.(inputRef)
}, [inputRef, onInputRefChanged])

useFocusZone(
{
containerRef: listContainerRef,
containerRef: {current: listContainerElement},
bindKeys: FocusKeys.ArrowVertical | FocusKeys.PageUpDown,
focusOutBehavior: 'wrap',
focusableElementFilter: element => {
Expand All @@ -104,20 +125,23 @@ export function FilteredActionList({
},
},
[
// List ref isn't set while loading. Need to re-bind focus zone when it changes
loading,
// List container isn't in the DOM while loading. Need to re-bind focus zone when it changes.
listContainerElement,
],
)

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

useAnnouncements(items, {current: listContainerElement}, inputRef, announcementsEnabled)
useScrollFlash(scrollContainerRef)
useAnnouncements(items, listContainerRef, inputRef)
camertron marked this conversation as resolved.
Show resolved Hide resolved

function getItemListForEachGroup(groupId: string) {
const itemsInGroup = []
Expand Down Expand Up @@ -155,17 +179,24 @@ export function FilteredActionList({
aria-controls={listId}
aria-label={placeholderText}
aria-describedby={inputDescriptionTextId}
loaderPosition={'leading'}
loading={loading && !loadingType.appearsInBody}
{...textInputProps}
/>
</StyledHeader>
<VisuallyHidden id={inputDescriptionTextId}>Items will be filtered as you type</VisuallyHidden>
<Box ref={scrollContainerRef} overflow="auto">
{loading ? (
<Box width="100%" display="flex" flexDirection="row" justifyContent="center" pt={6} pb={7}>
<Spinner />
</Box>
<Box ref={scrollContainerRef} overflow="auto" display="flex" flexGrow={1}>
{loading && scrollContainerRef.current && loadingType.appearsInBody ? (
<FilteredActionListBodyLoader loadingType={loadingType} height={scrollContainerRef.current.clientHeight} />
) : (
<ActionList ref={listContainerRef} showDividers={showItemDividers} {...listProps} role="listbox" id={listId}>
<ActionList
ref={listContainerRefCallback}
showDividers={showItemDividers}
{...listProps}
role="listbox"
id={listId}
sx={{flexGrow: 1}}
>
{groupMetadata?.length
? groupMetadata.map((group, index) => {
return (
Expand All @@ -174,13 +205,15 @@ export function FilteredActionList({
{group.header?.title ? group.header.title : `Group ${group.groupId}`}
</ActionList.GroupHeading>
{getItemListForEachGroup(group.groupId).map((item, index) => {
return <MappedActionListItem key={index} {...item} renderItem={listProps.renderItem} />
const key = item.key ?? item.id?.toString() ?? index.toString()
return <MappedActionListItem key={key} {...item} renderItem={listProps.renderItem} />
})}
</ActionList.Group>
)
})
: items.map((item, index) => {
return <MappedActionListItem key={index} {...item} renderItem={listProps.renderItem} />
const key = item.key ?? item.id?.toString() ?? index.toString()
return <MappedActionListItem key={key} {...item} renderItem={listProps.renderItem} />
})}
</ActionList>
)}
Expand Down
Loading
Loading