Skip to content

Commit

Permalink
Merge c4995d2 into 73135c1
Browse files Browse the repository at this point in the history
  • Loading branch information
camertron authored Oct 8, 2024
2 parents 73135c1 + c4995d2 commit 1a6c049
Show file tree
Hide file tree
Showing 32 changed files with 342 additions and 27 deletions.
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.
6 changes: 4 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@
"@primer/behaviors": "^1.7.2",
"@primer/live-region-element": "^0.7.1",
"@primer/octicons-react": "^19.9.0",
"@primer/primitives": "^9.0.3",
"@primer/primitives": "^9.1.2",
"@styled-system/css": "^5.1.5",
"@styled-system/props": "^5.1.5",
"@styled-system/theme-get": "^5.1.2",
Expand Down
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 id="foobarbaz" 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
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 @@ -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,9 +29,10 @@ 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
textInputProps?: Partial<Omit<TextInputProps, 'onChange'>>
inputRef?: React.RefObject<HTMLInputElement>
}
Expand All @@ -39,6 +44,7 @@ const StyledHeader = styled.div`

export function FilteredActionList({
loading = false,
loadingType = FilteredActionListLoadingTypes.bodySpinner,
placeholderText,
filterValue: externalFilterValue,
onFilterChange,
Expand Down Expand Up @@ -110,7 +116,7 @@ export function FilteredActionList({
useScrollFlash(scrollContainerRef)

return (
<Box display="flex" flexDirection="column" overflow="hidden" sx={sx}>
<Box display="flex" flexDirection="column" overflow="hidden" flexGrow={1} sx={sx}>
<StyledHeader>
<TextInput
ref={inputRef}
Expand All @@ -124,15 +130,15 @@ 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} />
)}
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 @@ -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,6 +30,7 @@ export interface FilteredActionListProps
ListPropsBase,
SxProp {
loading?: boolean
loadingType?: FilteredActionListLoadingType
placeholderText?: string
filterValue?: string
onFilterChange: (value: string, e: React.ChangeEvent<HTMLInputElement>) => void
Expand All @@ -43,6 +45,7 @@ const StyledHeader = styled.div`

export function FilteredActionList({
loading = false,
loadingType = FilteredActionListLoadingTypes.bodySpinner,
placeholderText,
filterValue: externalFilterValue,
onFilterChange,
Expand Down Expand Up @@ -146,17 +149,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={listContainerRef}
showDividers={showItemDividers}
{...listProps}
role="listbox"
id={listId}
sx={{flexGrow: 1}}
>
{groupMetadata?.length
? groupMetadata.map((group, index) => {
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,6 @@ export const HeightInitialWithUnderflowingItemsAfterFetch = () => {
placeholderText="Filter Labels"
open={open}
onOpenChange={onOpenChange}
loading={filteredItems.length === 0}
items={filteredItems}
selected={selected}
onSelectedChange={setSelected}
Expand Down
63 changes: 62 additions & 1 deletion packages/react/src/SelectPanel/SelectPanel.features.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, {useState, useRef, useMemo} from 'react'
import type {Meta} from '@storybook/react'
import type {Meta, StoryObj} from '@storybook/react'
import Box from '../Box'
import {Button} from '../Button'
import type {ItemInput, GroupedListProps} from '../deprecated/ActionList/List'
Expand All @@ -14,6 +14,7 @@ import {
TypographyIcon,
VersionsIcon,
} from '@primer/octicons-react'
import useSafeTimeout from '../hooks/useSafeTimeout'

const meta = {
title: 'Components/SelectPanel/Features',
Expand Down Expand Up @@ -369,3 +370,63 @@ export const WithGroups = () => {
/>
)
}

export const AsyncFetch: StoryObj<typeof SelectPanel> = {
render: ({initialLoadingType, height}) => {
const [selected, setSelected] = React.useState<ItemInput[]>([])
const [filteredItems, setFilteredItems] = React.useState<ItemInput[]>([])
const [open, setOpen] = useState(false)
const filterTimerId = useRef<number | null>(null)
const {safeSetTimeout, safeClearTimeout} = useSafeTimeout()
const onFilterChange = (value: string) => {
if (filterTimerId.current) {
safeClearTimeout(filterTimerId.current)
}

filterTimerId.current = safeSetTimeout(() => {
setFilteredItems(items.filter(item => item.text.toLowerCase().startsWith(value.toLowerCase())))
}, 2000) as unknown as number
}

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={setOpen}
items={filteredItems}
selected={selected}
onSelectedChange={setSelected}
onFilterChange={onFilterChange}
showItemDividers={true}
initialLoadingType={initialLoadingType}
height={height}
/>
)
},
args: {
initialLoadingType: 'spinner',
height: 'medium',
},
argTypes: {
initialLoadingType: {
control: 'select',
options: ['spinner', 'skeleton'],
},
height: {
control: 'select',
options: ['auto', 'xsmall', 'small', 'medium', 'large', 'xlarge'],
},
},
}
Loading

0 comments on commit 1a6c049

Please sign in to comment.