diff --git a/.changeset/selfish-garlics-approve.md b/.changeset/selfish-garlics-approve.md new file mode 100644 index 00000000000..bde5a2e4709 --- /dev/null +++ b/.changeset/selfish-garlics-approve.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +[SelectPanel] Implement loading states diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-colorblind-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-colorblind-linux.png index 4bf7e046e82..34e0e971fab 100644 Binary files a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-colorblind-linux.png and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-colorblind-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-colorblind-modern-action-list--true-linux.png index f745c6b6750..21f207ab1f9 100644 Binary files a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-colorblind-modern-action-list--true-linux.png and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-colorblind-modern-action-list--true-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-dimmed-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-dimmed-linux.png index 7fd1f5a0d19..87652d17518 100644 Binary files a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-dimmed-linux.png and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-dimmed-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-dimmed-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-dimmed-modern-action-list--true-linux.png index dd9ed5215ba..1bc5bc75eab 100644 Binary files a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-dimmed-modern-action-list--true-linux.png and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-dimmed-modern-action-list--true-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-high-contrast-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-high-contrast-linux.png index 80159d482d4..3cc864de05c 100644 Binary files a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-high-contrast-linux.png and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-high-contrast-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-high-contrast-modern-action-list--true-linux.png index 80159d482d4..6d64d60a55c 100644 Binary files a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-high-contrast-modern-action-list--true-linux.png and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-high-contrast-modern-action-list--true-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-linux.png index 4bf7e046e82..34e0e971fab 100644 Binary files a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-linux.png and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-modern-action-list--true-linux.png index f745c6b6750..21f207ab1f9 100644 Binary files a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-modern-action-list--true-linux.png and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-modern-action-list--true-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-tritanopia-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-tritanopia-linux.png index 4bf7e046e82..34e0e971fab 100644 Binary files a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-tritanopia-linux.png and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-tritanopia-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-tritanopia-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-tritanopia-modern-action-list--true-linux.png index f745c6b6750..21f207ab1f9 100644 Binary files a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-tritanopia-modern-action-list--true-linux.png and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-dark-tritanopia-modern-action-list--true-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-light-colorblind-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-light-colorblind-linux.png index d046a0c6158..dea9161fa3c 100644 Binary files a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-light-colorblind-linux.png and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-light-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-light-colorblind-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-light-colorblind-modern-action-list--true-linux.png index 07a2a90e169..f5260140449 100644 Binary files a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-light-colorblind-modern-action-list--true-linux.png and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-light-colorblind-modern-action-list--true-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-light-high-contrast-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-light-high-contrast-linux.png index 1d69edbed3b..ab5ee69a0ff 100644 Binary files a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-light-high-contrast-linux.png and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-light-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-light-high-contrast-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-light-high-contrast-modern-action-list--true-linux.png index 1d69edbed3b..21dd6b1ccb5 100644 Binary files a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-light-high-contrast-modern-action-list--true-linux.png and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-light-high-contrast-modern-action-list--true-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-light-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-light-linux.png index d046a0c6158..dea9161fa3c 100644 Binary files a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-light-linux.png and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-light-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-light-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-light-modern-action-list--true-linux.png index 07a2a90e169..f5260140449 100644 Binary files a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-light-modern-action-list--true-linux.png and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-light-modern-action-list--true-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-light-tritanopia-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-light-tritanopia-linux.png index d046a0c6158..dea9161fa3c 100644 Binary files a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-light-tritanopia-linux.png and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-light-tritanopia-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-light-tritanopia-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-light-tritanopia-modern-action-list--true-linux.png index 07a2a90e169..f5260140449 100644 Binary files a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-light-tritanopia-modern-action-list--true-linux.png and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Initial-with-Underflowing-Items-After-Fetch-light-tritanopia-modern-action-list--true-linux.png differ diff --git a/package-lock.json b/package-lock.json index 8ff1ee97022..fda82aaf72e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31115,7 +31115,7 @@ "@primer/behaviors": "^1.7.2", "@primer/live-region-element": "^0.7.0", "@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", @@ -31275,7 +31275,9 @@ } }, "packages/react/node_modules/@primer/primitives": { - "version": "9.1.1", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@primer/primitives/-/primitives-9.1.2.tgz", + "integrity": "sha512-KecRJpUdIf14J3gVpoyMMJeQD6Sh5kcHk93N5bYch4XGB0GOZP3ypxz+NByMjr/2HHPsRfCCO5EEgNjmeWYUGQ==", "license": "MIT", "dependencies": { "@prettier/sync": "^0.5.2", diff --git a/packages/react/package.json b/packages/react/package.json index 37726bfb3a7..9167c3972e6 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -89,7 +89,7 @@ "@primer/behaviors": "^1.7.2", "@primer/live-region-element": "^0.7.0", "@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", diff --git a/packages/react/src/Box/Box.tsx b/packages/react/src/Box/Box.tsx index b93aeac4e4e..ab025305982 100644 --- a/packages/react/src/Box/Box.tsx +++ b/packages/react/src/Box/Box.tsx @@ -16,7 +16,7 @@ import type {SxProp} from '../sx' import sx from '../sx' import type {ComponentProps} from '../utils/types' -type StyledBoxProps = SpaceProps & +export type StyledBoxProps = SpaceProps & ColorProps & TypographyProps & LayoutProps & diff --git a/packages/react/src/FilteredActionList/FilteredActionListEntry.tsx b/packages/react/src/FilteredActionList/FilteredActionListEntry.tsx index 450608c6b0a..e185ecedfc0 100644 --- a/packages/react/src/FilteredActionList/FilteredActionListEntry.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionListEntry.tsx @@ -6,7 +6,6 @@ import {useFeatureFlag} from '../FeatureFlags' export function FilteredActionList(props: FilteredActionListProps): JSX.Element { const enabled = useFeatureFlag('primer_react_select_panel_with_modern_action_list') - if (enabled) return else return } diff --git a/packages/react/src/FilteredActionList/FilteredActionListLoaders.tsx b/packages/react/src/FilteredActionList/FilteredActionListLoaders.tsx new file mode 100644 index 00000000000..efbc951e707 --- /dev/null +++ b/packages/react/src/FilteredActionList/FilteredActionListLoaders.tsx @@ -0,0 +1,57 @@ +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), +} + +export function FilteredActionListBodyLoader({loadingType}: {loadingType: FilteredActionListLoadingType}): JSX.Element { + switch (loadingType) { + case FilteredActionListLoadingTypes.bodySpinner: + return + case FilteredActionListLoadingTypes.bodySkeleton: + return + default: + return <> + } +} + +function LoadingSpinner({...props}): JSX.Element { + return ( + + + + + + ) +} + +function LoadingSkeleton({rows = 10, ...props}: {rows: number}): JSX.Element { + return ( + + + {Array.from({length: rows}, (_, i) => ( + + + + + ))} + + + ) +} diff --git a/packages/react/src/FilteredActionList/FilteredActionListWithDeprecatedActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionListWithDeprecatedActionList.tsx index 7bec423a15c..3de9c215a72 100644 --- a/packages/react/src/FilteredActionList/FilteredActionListWithDeprecatedActionList.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionListWithDeprecatedActionList.tsx @@ -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' @@ -17,6 +16,11 @@ import {useProvidedStateOrCreate} from '../hooks/useProvidedStateOrCreate' import useScrollFlash from '../hooks/useScrollFlash' import {VisuallyHidden} from '../internal/components/VisuallyHidden' import type {SxProp} from '../sx' +import { + type FilteredActionListLoadingType, + FilteredActionListBodyLoader, + FilteredActionListLoadingTypes, +} from './FilteredActionListLoaders' const menuScrollMargins: ScrollIntoViewOptions = {startMargin: 0, endMargin: 8} @@ -25,9 +29,10 @@ export interface FilteredActionListProps ListPropsBase, SxProp { loading?: boolean + loadingType?: FilteredActionListLoadingType placeholderText?: string filterValue?: string - onFilterChange: (value: string, e: React.ChangeEvent) => void + onFilterChange: (value: string, e: React.ChangeEvent | null) => void textInputProps?: Partial> inputRef?: React.RefObject } @@ -39,6 +44,7 @@ const StyledHeader = styled.div` export function FilteredActionList({ loading = false, + loadingType = FilteredActionListLoadingTypes.bodySpinner, placeholderText, filterValue: externalFilterValue, onFilterChange, @@ -124,15 +130,15 @@ export function FilteredActionList({ aria-label={placeholderText} aria-controls={listId} aria-describedby={inputDescriptionTextId} + loaderPosition={'leading'} + loading={loading && !loadingType.appearsInBody} {...textInputProps} /> Items will be filtered as you type - {loading ? ( - - - + {loading && loadingType.appearsInBody ? ( + ) : ( )} diff --git a/packages/react/src/FilteredActionList/FilteredActionListWithModernActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionListWithModernActionList.tsx index ae791e63909..2ccc9ac9f2c 100644 --- a/packages/react/src/FilteredActionList/FilteredActionListWithModernActionList.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionListWithModernActionList.tsx @@ -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' @@ -17,6 +16,8 @@ import {useProvidedStateOrCreate} from '../hooks/useProvidedStateOrCreate' import useScrollFlash from '../hooks/useScrollFlash' import {VisuallyHidden} from '../internal/components/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' @@ -28,6 +29,7 @@ export interface FilteredActionListProps ListPropsBase, SxProp { loading?: boolean + loadingType?: FilteredActionListLoadingType placeholderText?: string filterValue?: string onFilterChange: (value: string, e: React.ChangeEvent) => void @@ -42,6 +44,7 @@ const StyledHeader = styled.div` export function FilteredActionList({ loading = false, + loadingType = FilteredActionListLoadingTypes.bodySpinner, placeholderText, filterValue: externalFilterValue, onFilterChange, @@ -51,6 +54,7 @@ export function FilteredActionList({ sx, groupMetadata, showItemDividers, + message, ...listProps }: FilteredActionListProps): JSX.Element { const [filterValue, setInternalFilterValue] = useProvidedStateOrCreate(externalFilterValue, undefined, '') @@ -141,15 +145,15 @@ export function FilteredActionList({ aria-label={placeholderText} aria-controls={listId} aria-describedby={inputDescriptionTextId} + loaderPosition={'leading'} + loading={loading && !loadingType.appearsInBody} {...textInputProps} /> Items will be filtered as you type - - {loading ? ( - - - + + {loading && loadingType.appearsInBody ? ( + ) : ( {groupMetadata?.length @@ -170,6 +174,7 @@ export function FilteredActionList({ })} )} + {message} ) diff --git a/packages/react/src/SelectPanel/SelectPanel.features.stories.tsx b/packages/react/src/SelectPanel/SelectPanel.features.stories.tsx index da4bb2084be..f2fb3cf5f0b 100644 --- a/packages/react/src/SelectPanel/SelectPanel.features.stories.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.features.stories.tsx @@ -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' @@ -14,6 +14,8 @@ import { TypographyIcon, VersionsIcon, } from '@primer/octicons-react' +import useSafeTimeout from '../hooks/useSafeTimeout' +import Link from '../Link' const meta = { title: 'Components/SelectPanel/Features', @@ -369,3 +371,123 @@ export const WithGroups = () => { /> ) } + +export const AsyncFetch: StoryObj = { + render: ({initialLoadingType}) => { + const [selected, setSelected] = React.useState([]) + const [filteredItems, setFilteredItems] = React.useState([]) + const [open, setOpen] = useState(false) + const filterTimerId = useRef(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 ( + ( + + )} + placeholderText="Filter labels" + open={open} + onOpenChange={setOpen} + items={filteredItems} + selected={selected} + onSelectedChange={setSelected} + onFilterChange={onFilterChange} + showItemDividers={true} + initialLoadingType={initialLoadingType} + /> + ) + }, + argTypes: { + initialLoadingType: { + control: 'select', + options: ['spinner', 'skeleton'], + defaultValue: 'spinner', + }, + }, +} + +export const NoItems = () => { + const [selected, setSelected] = React.useState([]) + const [filteredItems, setFilteredItems] = React.useState([]) + const [open, setOpen] = useState(true) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const onFilterChange = (value: string = '') => { + setTimeout(() => { + // fetch the items + setFilteredItems([]) + }, 0) + } + return ( + ( + + )} + open={open} + onOpenChange={setOpen} + items={filteredItems} + selected={selected} + onSelectedChange={setSelected} + onFilterChange={onFilterChange} + overlayProps={{width: 'medium', height: 'large'}} + > + + Start your first project to organise your issues. + + + Adjust your search term to find other languages + + + ) +} +export const NoMatches = () => { + const [selected, setSelected] = React.useState([]) + const [filter, setFilter] = React.useState('') + const [open, setOpen] = useState(true) + + const filteredItems = items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase())) + return ( + ( + + )} + open={open} + onOpenChange={setOpen} + items={filteredItems} + selected={selected} + onSelectedChange={setSelected} + onFilterChange={setFilter} + overlayProps={{width: 'medium', height: 'small'}} + > + + Start your first project to organise your issues. + + + Adjust your search term to find other languages + + + ) +} diff --git a/packages/react/src/SelectPanel/SelectPanel.test.tsx b/packages/react/src/SelectPanel/SelectPanel.test.tsx index 7d6adfbb3ae..1512b2341db 100644 --- a/packages/react/src/SelectPanel/SelectPanel.test.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.test.tsx @@ -5,6 +5,7 @@ import type {ItemInput, GroupedListProps} from '../deprecated/ActionList/List' import {userEvent} from '@testing-library/user-event' import ThemeProvider from '../ThemeProvider' import {FeatureFlags} from '../FeatureFlags' +import type {InitialLoadingType} from './SelectPanel' const renderWithFlag = (children: React.ReactNode, flag: boolean) => { return render( @@ -59,7 +60,7 @@ function BasicSelectPanel(passthroughProps: Record) { global.Element.prototype.scrollTo = jest.fn() -for (const useModernActionList of [false, true]) { +for (const useModernActionList of [true, false]) { describe('SelectPanel', () => { describe(`primer_react_select_panel_with_modern_action_list: ${useModernActionList}`, () => { it('should render an anchor to open the select panel using `placeholder`', () => { @@ -369,6 +370,35 @@ for (const useModernActionList of [false, true]) { ) } + function LoadingSelectPanel({ + initialLoadingType = 'spinner', + items = [], + }: { + initialLoadingType?: InitialLoadingType + items?: SelectPanelProps['items'] + }) { + const [open, setOpen] = React.useState(false) + + return ( + + {}} + selected={[]} + onSelectedChange={() => {}} + onOpenChange={isOpen => { + setOpen(isOpen) + }} + initialLoadingType={initialLoadingType} + /> + + ) + } + it('should filter the list of items when the user types into the input', async () => { const user = userEvent.setup() @@ -385,6 +415,42 @@ for (const useModernActionList of [false, true]) { it.todo('should announce the number of results') it.todo('should announce when no results are available') + + it('displays a loading spinner on first open', async () => { + const user = userEvent.setup() + + renderWithFlag(, useModernActionList) + + await user.click(screen.getByText('Select items')) + + expect(screen.getByTestId('filtered-action-list-spinner')).toBeTruthy() + }) + + it('displays a loading skeleton on first open', async () => { + const user = userEvent.setup() + + renderWithFlag(, useModernActionList) + + await user.click(screen.getByText('Select items')) + + expect(screen.getByTestId('filtered-action-list-skeleton')).toBeTruthy() + }) + + it('displays a loading spinner in the text input if items are already loaded', async () => { + const user = userEvent.setup() + + renderWithFlag(, useModernActionList) + + await user.click(screen.getByText('Select items')) + + expect(screen.getAllByRole('option')).toHaveLength(3) + + // since the test never repopulates the panel's list of items, the panel will enter + // the loading state after the following line executes and stay there indefinitely + await user.type(document.activeElement!, 'two') + + expect(screen.getByTestId('text-input-leading-visual')).toBeTruthy() + }) }) describe('with footer', () => { diff --git a/packages/react/src/SelectPanel/SelectPanel.tsx b/packages/react/src/SelectPanel/SelectPanel.tsx index edf35fafee6..59c458c5f5c 100644 --- a/packages/react/src/SelectPanel/SelectPanel.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.tsx @@ -1,9 +1,10 @@ import {SearchIcon, TriangleDownIcon} from '@primer/octicons-react' -import React, {useCallback, useMemo} from 'react' +import React, {useCallback, useEffect, useMemo, useRef} from 'react' import type {AnchoredOverlayProps} from '../AnchoredOverlay' import {AnchoredOverlay} from '../AnchoredOverlay' import type {AnchoredOverlayWrapperAnchorProps} from '../AnchoredOverlay/AnchoredOverlay' import Box from '../Box' +import Text from '../Text' import type {FilteredActionListProps} from '../FilteredActionList' import {FilteredActionList} from '../FilteredActionList' import Heading from '../Heading' @@ -17,6 +18,9 @@ import type {FocusZoneHookSettings} from '../hooks/useFocusZone' import {useId} from '../hooks/useId' import {useProvidedStateOrCreate} from '../hooks/useProvidedStateOrCreate' import {LiveRegion, LiveRegionOutlet, Message} from '../internal/components/LiveRegion' +import useSafeTimeout from '../hooks/useSafeTimeout' +import type {FilteredActionListLoadingType} from '../FilteredActionList/FilteredActionListLoaders' +import {FilteredActionListLoadingTypes} from '../FilteredActionList/FilteredActionListLoaders' interface SelectPanelSingleSelection { selected: ItemInput | undefined @@ -28,6 +32,8 @@ interface SelectPanelMultiSelection { onSelectedChange: (selected: ItemInput[]) => void } +export type InitialLoadingType = 'spinner' | 'skeleton' + interface SelectPanelBaseProps { // TODO: Make `title` required in the next major version title?: string | React.ReactElement @@ -41,13 +47,16 @@ interface SelectPanelBaseProps { inputLabel?: string overlayProps?: Partial footer?: string | React.ReactElement + initialLoadingType?: InitialLoadingType } -export type SelectPanelProps = SelectPanelBaseProps & - Omit & - Pick & - AnchoredOverlayWrapperAnchorProps & - (SelectPanelSingleSelection | SelectPanelMultiSelection) +export type SelectPanelProps = React.PropsWithChildren< + SelectPanelBaseProps & + Omit & + Pick & + AnchoredOverlayWrapperAnchorProps & + (SelectPanelSingleSelection | SelectPanelMultiSelection) +> function isMultiSelectVariant( selected: SelectPanelSingleSelection['selected'] | SelectPanelMultiSelection['selected'], @@ -60,7 +69,42 @@ const focusZoneSettings: Partial = { disabled: true, } -export function SelectPanel({ +export type SelectPanelMessageProps = { + children: React.ReactNode + title: string + variant: 'noitems' | 'nomatches' +} +// we will have more variants in the future like error / warning etc +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const SelectPanelMessage: React.FC = ({variant = 'noitems', title, children}) => { + return ( + + {title} + + {children} + + + ) +} + +function Panel({ open, onOpenChange, renderAnchor = props => { @@ -86,19 +130,70 @@ export function SelectPanel({ textInputProps, overlayProps, sx, + loading, + initialLoadingType = 'spinner', + children, ...listProps }: SelectPanelProps): JSX.Element { + const inputRef = React.useRef(null) const titleId = useId() const subtitleId = useId() + const dataLoadedOnce = useRef(false) + const [isLoading, setIsLoading] = useProvidedStateOrCreate(loading, undefined, false) const [filterValue, setInternalFilterValue] = useProvidedStateOrCreate(externalFilterValue, undefined, '') + const {safeSetTimeout, safeClearTimeout} = useSafeTimeout() + const loadingDelayTimeoutId = useRef(null) const onFilterChange: FilteredActionListProps['onFilterChange'] = useCallback( (value, e) => { + if (dataLoadedOnce.current) { + // If data has already been loaded once, delay the spinner a bit. This also helps + // not show and then immediately hide the spinner if items are loaded quickly, i.e. + // not async. + + if (loadingDelayTimeoutId.current) { + safeClearTimeout(loadingDelayTimeoutId.current) + } + + loadingDelayTimeoutId.current = safeSetTimeout(() => setIsLoading(true), 1000) + } else { + // If this is the first data load and there are no items, show the loading spinner + // immediately + + if (items.length === 0) { + setIsLoading(true) + } + } + externalOnFilterChange(value, e) setInternalFilterValue(value) }, - [externalOnFilterChange, setInternalFilterValue], + [externalOnFilterChange, setInternalFilterValue, setIsLoading, safeSetTimeout, safeClearTimeout, items], ) + useEffect(() => { + if (isLoading) { + setIsLoading(false) + dataLoadedOnce.current = true + } + // Only fire this effect if items have changed + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [items]) + + // Populate panel with items on first open + useEffect(() => { + // If data was already loaded once, do nothing + if (dataLoadedOnce.current) return + + // Only load data when the panel is open + if (open) { + // Only trigger filter change event if there are no items + if (items.length === 0) { + // Trigger filter event to populate panel on first open + onFilterChange(filterValue, null) + } + } + }, [open, dataLoadedOnce, onFilterChange, filterValue, items]) + const anchorRef = useProvidedRefOrCreate(externalAnchorRef) const onOpen: AnchoredOverlayProps['onOpen'] = useCallback( (gesture: Parameters>[0]) => onOpenChange(true, gesture), @@ -159,7 +254,6 @@ export function SelectPanel({ }) }, [onClose, onSelectedChange, items, selected]) - const inputRef = React.useRef(null) const focusTrapSettings = { initialFocusRef: inputRef, } @@ -174,6 +268,31 @@ export function SelectPanel({ } }, [inputLabel, textInputProps]) + const loadingType = (): FilteredActionListLoadingType => { + if (dataLoadedOnce.current) { + return FilteredActionListLoadingTypes.input + } else { + if (initialLoadingType === 'spinner') { + return FilteredActionListLoadingTypes.bodySpinner + } else { + return FilteredActionListLoadingTypes.bodySkeleton + } + } + } + + const isNoItemsState = items.length === 0 && dataLoadedOnce.current && !loading + const isNoMatchState = items.length === 0 && filterValue !== '' && dataLoadedOnce.current && !loading + + const deconstructChildren = (children: React.ReactNode) => { + return React.Children.toArray(children).find(child => { + if (isNoMatchState) return child.props.variant === 'nomatches' && React.isValidElement(child) + else if (isNoItemsState) return child.props.variant === 'noitems' && React.isValidElement(child) + else return [] + }) + } + + const message = deconstructChildren(children) + return ( ) : null} + - {footer && ( + + {footer ? ( {footer} - )} + ) : null} ) } -SelectPanel.displayName = 'SelectPanel' +Panel.displayName = 'SelectPanel' + +export const SelectPanel = Object.assign(Panel, { + Message: SelectPanelMessage, +}) diff --git a/packages/react/src/TextInput/TextInput.tsx b/packages/react/src/TextInput/TextInput.tsx index e2d2acd0736..5c48174ce49 100644 --- a/packages/react/src/TextInput/TextInput.tsx +++ b/packages/react/src/TextInput/TextInput.tsx @@ -137,6 +137,7 @@ const TextInput = React.forwardRef( visualPosition="leading" showLoadingIndicator={showLeadingLoadingIndicator} hasLoadingIndicator={typeof loading === 'boolean'} + data-testid="text-input-leading-visual" > {typeof LeadingVisual !== 'string' && isValidElementType(LeadingVisual) ? : LeadingVisual} @@ -155,6 +156,7 @@ const TextInput = React.forwardRef( visualPosition="trailing" showLoadingIndicator={showTrailingLoadingIndicator} hasLoadingIndicator={typeof loading === 'boolean'} + data-testid="text-input-trailing-visual" > {typeof TrailingVisual !== 'string' && isValidElementType(TrailingVisual) ? ( diff --git a/packages/react/src/__tests__/__snapshots__/TextInput.test.tsx.snap b/packages/react/src/__tests__/__snapshots__/TextInput.test.tsx.snap index 0937b41790c..39ba8f036db 100644 --- a/packages/react/src/__tests__/__snapshots__/TextInput.test.tsx.snap +++ b/packages/react/src/__tests__/__snapshots__/TextInput.test.tsx.snap @@ -4160,6 +4160,7 @@ exports[`TextInput renders with a loading indicator 1`] = ` />
` 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}; @@ -36,5 +39,15 @@ export const SkeletonBox = styled.div` outline-offset: -1px; } + ${space}; + ${color}; + ${typography}; + ${layout}; + ${flexbox}; + ${grid}; + ${background}; + ${border}; + ${position}; + ${shadow}; ${sx}; ` diff --git a/packages/react/src/internal/components/TextInputInnerVisualSlot.tsx b/packages/react/src/internal/components/TextInputInnerVisualSlot.tsx index b8370ac12fe..ca43cf800c4 100644 --- a/packages/react/src/internal/components/TextInputInnerVisualSlot.tsx +++ b/packages/react/src/internal/components/TextInputInnerVisualSlot.tsx @@ -12,7 +12,7 @@ const TextInputInnerVisualSlot: React.FC< /** Which side of this visual is being rendered */ visualPosition: 'leading' | 'trailing' }> -> = ({children, hasLoadingIndicator, showLoadingIndicator, visualPosition}) => { +> = ({children, hasLoadingIndicator, showLoadingIndicator, visualPosition, ...props}) => { if ((!children && !hasLoadingIndicator) || (visualPosition === 'leading' && !children && !showLoadingIndicator)) { return null } @@ -22,7 +22,7 @@ const TextInputInnerVisualSlot: React.FC< } return ( - + {children && {children}}