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

SelectPanel: Introduce loadingType prop to specify initial loading style #5266

Closed
wants to merge 5 commits into from
Closed
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/many-moons-wave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/react": minor
---

SelectPanel: Introduce `loadingType` prop with `spinner` and `skeleton` options to specify initial loading style
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import type {KeyboardEventHandler} from 'react'
import React, {useCallback, useEffect, useRef} from 'react'
import styled from 'styled-components'
import Box from '../Box'
import Spinner from '../Spinner'
import {Loading} from '../FilteredActionList/Loaders'
import type {LoadingTypes} from '../FilteredActionList/Loaders'
import type {TextInputProps} from '../TextInput'
import TextInput from '../TextInput'
import {get} from '../constants'
Expand All @@ -25,6 +26,7 @@ export interface FilteredActionListProps
ListPropsBase,
SxProp {
loading?: boolean
loadingType?: LoadingTypes
placeholderText?: string
filterValue?: string
onFilterChange: (value: string, e: React.ChangeEvent<HTMLInputElement>) => void
Expand All @@ -46,6 +48,7 @@ export function FilteredActionList({
textInputProps,
inputRef: providedInputRef,
sx,
loadingType,
...listProps
}: FilteredActionListProps): JSX.Element {
const [filterValue, setInternalFilterValue] = useProvidedStateOrCreate(externalFilterValue, undefined, '')
Expand Down Expand Up @@ -128,11 +131,19 @@ export function FilteredActionList({
/>
</StyledHeader>
<VisuallyHidden id={inputDescriptionTextId}>Items will be filtered as you type</VisuallyHidden>
<Box ref={scrollContainerRef} overflow="auto">
<Box
ref={scrollContainerRef}
sx={{
overflow: 'auto',
// To be able to align the spinner centrally
'&:has([data-attribute="data-loading"])': {
height: '100%',
alignContent: 'center',
},
}}
>
{loading ? (
<Box width="100%" display="flex" flexDirection="row" justifyContent="center" pt={6} pb={7}>
<Spinner />
</Box>
<Loading data-attribute="data-loading" type={loadingType} />
) : (
<ActionList ref={listContainerRef} items={items} {...listProps} role="listbox" id={listId} />
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import type {KeyboardEventHandler} from 'react'
import React, {useCallback, useEffect, useRef} 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 @@ -22,13 +21,17 @@ import {isValidElementType} from 'react-is'
import type {RenderItemFn} from '../deprecated/ActionList/List'
import {useAnnouncements} from './useAnnouncements'

import {Loading} from '../FilteredActionList/Loaders'
import type {LoadingTypes} from '../FilteredActionList/Loaders'

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

export interface FilteredActionListProps
extends Partial<Omit<GroupedListProps, keyof ListPropsBase>>,
ListPropsBase,
SxProp {
loading?: boolean
loadingType?: LoadingTypes
placeholderText?: string
filterValue?: string
onFilterChange: (value: string, e: React.ChangeEvent<HTMLInputElement>) => void
Expand All @@ -52,6 +55,7 @@ export function FilteredActionList({
sx,
groupMetadata,
showItemDividers,
loadingType,
...listProps
}: FilteredActionListProps): JSX.Element {
const [filterValue, setInternalFilterValue] = useProvidedStateOrCreate(externalFilterValue, undefined, '')
Expand Down Expand Up @@ -150,11 +154,19 @@ export function FilteredActionList({
/>
</StyledHeader>
<VisuallyHidden id={inputDescriptionTextId}>Items will be filtered as you type</VisuallyHidden>
<Box ref={scrollContainerRef} overflow="auto">
<Box
ref={scrollContainerRef}
sx={{
overflow: 'auto',
// To be able to align the spinner centrally
'&:has([data-attribute="data-loading"])': {
height: '100%',
alignContent: 'center',
},
}}
>
{loading ? (
<Box width="100%" display="flex" flexDirection="row" justifyContent="center" pt={6} pb={7}>
<Spinner />
</Box>
<Loading data-attribute="data-loading" type={loadingType} />
) : (
<ActionList ref={listContainerRef} showDividers={showItemDividers} {...listProps} role="listbox" id={listId}>
{groupMetadata?.length
Expand Down
41 changes: 41 additions & 0 deletions packages/react/src/FilteredActionList/Loaders.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import React, {useId} from 'react'
import Box from '../Box'
import Spinner from '../Spinner'
import {Stack} from '../Stack/Stack'
import {SkeletonBox} from '../experimental/Skeleton/SkeletonBox'

export type LoadingTypes = 'spinner' | 'skeleton'

export const Loading = ({type = 'spinner', ...props}: {type?: LoadingTypes}) => {
if (type === 'spinner') {
return <LoadingSpinner {...props} />
} else {
return <LoadingSkeleton {...props} />
}
}

function LoadingSpinner({...props}): JSX.Element {
return (
<Box
sx={{display: 'flex', alignContent: 'center', justifyContent: 'center', width: '100%', padding: '40px 16px 48px'}}
>
<Spinner {...props} />
</Box>
)
}

function LoadingSkeleton({rows = 10, ...props}: {rows?: number}): JSX.Element {
const id = useId()
return (
<Box p={2} display="flex" flexGrow={1} flexDirection="column">
Copy link
Contributor

Choose a reason for hiding this comment

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

Since this is a new internal component, I think it would be best to avoid Box/sx here! Maybe this could be a Stack, or just a div using CSS Modules?

<Stack id={id} 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>
)
}
11 changes: 11 additions & 0 deletions packages/react/src/SelectPanel/SelectPanel.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,17 @@
"type": "string | React.ReactElement",
"defaultValue": "null",
"description": "Footer rendered at the end of the panel"
},
{
"name": "loading",
"type": "boolean",
"description": "When true, the loading state is displayed"
},
{
"name": "loadingType",
"type": " 'spinner' | 'skeleton'",
"defaultValue": "spinner",
"description": "The type of loading state to render when retrieving initial data"
}
],
"subcomponents": []
Expand Down
101 changes: 101 additions & 0 deletions packages/react/src/SelectPanel/SelectPanel.features.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -369,3 +369,104 @@ export const WithGroups = () => {
/>
)
}

export const AsyncFetchLoadingExternallyManaged = () => {
const [loading, setLoading] = React.useState<boolean>(true)
const [selected, setSelected] = React.useState<ItemInput[]>([])
const [filter, setFilter] = React.useState('')
const [fetchedItems, setFetchedItems] = React.useState<ItemInput[]>([])
const [open, setOpen] = useState(false)

const filteredItems = React.useMemo(
() =>
fetchedItems.filter(fetchedItem => {
if (!fetchedItem.text) return false
return fetchedItem.text.toLowerCase().startsWith(filter.toLowerCase())
}),
[fetchedItems, filter],
)

const onOpenChange = () => {
setLoading(true)
setOpen(!open)
setTimeout(() => {
setFetchedItems(items)
setLoading(false)
}, 1500)
}
return (
<SelectPanel
title="Select labels"
subtitle="Use labels to organize issues and pull requests"
renderAnchor={({children, 'aria-labelledby': ariaLabelledBy, ...anchorProps}) => (
<Button
trailingAction={TriangleDownIcon}
aria-labelledby={` ${ariaLabelledBy}`}
{...anchorProps}
aria-haspopup="dialog"
>
{children ?? 'Select Labels'}
</Button>
)}
placeholderText="Filter labels"
open={open}
onOpenChange={onOpenChange}
items={filteredItems}
selected={selected}
onSelectedChange={setSelected}
onFilterChange={setFilter}
loading={loading}
/>
)
}

export const SkeletonTypeLoading = () => {
const [loading, setLoading] = React.useState<boolean>(true)
const [selected, setSelected] = React.useState<ItemInput[]>([])
const [filter, setFilter] = React.useState('')
const [fetchedItems, setFetchedItems] = React.useState<ItemInput[]>([])
const [open, setOpen] = useState(false)

const filteredItems = React.useMemo(
() =>
fetchedItems.filter(fetchedItem => {
if (!fetchedItem.text) return false
return fetchedItem.text.toLowerCase().startsWith(filter.toLowerCase())
}),
[fetchedItems, filter],
)

const onOpenChange = () => {
setLoading(true)
setOpen(!open)
setTimeout(() => {
setFetchedItems(items)
setLoading(false)
}, 1500)
}
return (
<SelectPanel
title="Select labels"
subtitle="Use labels to organize issues and pull requests"
renderAnchor={({children, 'aria-labelledby': ariaLabelledBy, ...anchorProps}) => (
<Button
trailingAction={TriangleDownIcon}
aria-labelledby={` ${ariaLabelledBy}`}
{...anchorProps}
aria-haspopup="dialog"
>
{children ?? 'Select Labels'}
</Button>
)}
placeholderText="Filter labels"
open={open}
onOpenChange={onOpenChange}
items={filteredItems}
selected={selected}
onSelectedChange={setSelected}
onFilterChange={setFilter}
loading={loading}
loadingType="skeleton"
/>
)
}
1 change: 1 addition & 0 deletions packages/react/src/SelectPanel/SelectPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ interface SelectPanelBaseProps {

export type SelectPanelProps = SelectPanelBaseProps &
Omit<FilteredActionListProps, 'selectionVariant'> &
Pick<FilteredActionListProps, 'loading' | 'loadingType'> &
Pick<AnchoredOverlayProps, 'open'> &
AnchoredOverlayWrapperAnchorProps &
(SelectPanelSingleSelection | SelectPanelMultiSelection)
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/experimental/Skeleton/SkeletonBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const StyledSkeletonBox = toggleStyledComponent(
styled.div<SkeletonBoxProps>`
animation: ${shimmer};
display: block;
background-color: var(--bgColor-muted, ${get('colors.canvas.subtle')});
background-color: var(--skeletonLoader-bgColor, ${get('colors.canvas.subtle')});
border-radius: 3px;
height: ${props => props.height || '1rem'};
width: ${props => props.width};
Expand Down
Loading