diff --git a/.changeset/new-mangos-fold.md b/.changeset/new-mangos-fold.md new file mode 100644 index 00000000000..55929a994c9 --- /dev/null +++ b/.changeset/new-mangos-fold.md @@ -0,0 +1,5 @@ +--- +"@primer/react": minor +--- + +SelectPanel: Implement empty state (behind ff) diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-dark-colorblind-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-dark-colorblind-linux.png new file mode 100644 index 00000000000..c8493aad1bd Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-dark-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-dark-colorblind-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-dark-colorblind-modern-action-list--true-linux.png new file mode 100644 index 00000000000..eb4d308d64d Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-dark-colorblind-modern-action-list--true-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-dark-dimmed-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-dark-dimmed-linux.png new file mode 100644 index 00000000000..b86f6e84846 Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-dark-dimmed-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-dark-dimmed-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-dark-dimmed-modern-action-list--true-linux.png new file mode 100644 index 00000000000..639bd1c8bcb Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-dark-dimmed-modern-action-list--true-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-dark-high-contrast-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-dark-high-contrast-linux.png new file mode 100644 index 00000000000..9f418e05f37 Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-dark-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-dark-high-contrast-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-dark-high-contrast-modern-action-list--true-linux.png new file mode 100644 index 00000000000..0187b5d0afb Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-dark-high-contrast-modern-action-list--true-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-dark-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-dark-linux.png new file mode 100644 index 00000000000..e2440902da4 Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-dark-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-dark-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-dark-modern-action-list--true-linux.png new file mode 100644 index 00000000000..eb4d308d64d Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-dark-modern-action-list--true-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-dark-tritanopia-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-dark-tritanopia-linux.png new file mode 100644 index 00000000000..e2440902da4 Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-dark-tritanopia-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-dark-tritanopia-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-dark-tritanopia-modern-action-list--true-linux.png new file mode 100644 index 00000000000..eb4d308d64d Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-dark-tritanopia-modern-action-list--true-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-light-colorblind-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-light-colorblind-linux.png new file mode 100644 index 00000000000..6e8caf4cac2 Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-light-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-light-colorblind-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-light-colorblind-modern-action-list--true-linux.png new file mode 100644 index 00000000000..498f43b7b50 Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-light-colorblind-modern-action-list--true-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-light-high-contrast-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-light-high-contrast-linux.png new file mode 100644 index 00000000000..f4a0743991b Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-light-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-light-high-contrast-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-light-high-contrast-modern-action-list--true-linux.png new file mode 100644 index 00000000000..245dd42872f Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-light-high-contrast-modern-action-list--true-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-light-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-light-linux.png new file mode 100644 index 00000000000..6e8caf4cac2 Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-light-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-light-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-light-modern-action-list--true-linux.png new file mode 100644 index 00000000000..498f43b7b50 Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-light-modern-action-list--true-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-light-tritanopia-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-light-tritanopia-linux.png new file mode 100644 index 00000000000..6e8caf4cac2 Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-light-tritanopia-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-light-tritanopia-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-light-tritanopia-modern-action-list--true-linux.png new file mode 100644 index 00000000000..498f43b7b50 Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Height-Variations-and-Scroll-light-tritanopia-modern-action-list--true-linux.png differ diff --git a/e2e/components/SelectPanel.test.ts b/e2e/components/SelectPanel.test.ts index 49198328841..10c4bb90356 100644 --- a/e2e/components/SelectPanel.test.ts +++ b/e2e/components/SelectPanel.test.ts @@ -20,7 +20,7 @@ const scenarios = matrix({ name: 'With Placeholder for Search Input', }, {id: 'components-selectpanel-examples--above-tall-body', name: 'Above Tall Body'}, - {id: 'components-selectpanel-examples--height-variantions-and-scroll', name: 'Height Variantions and Scroll'}, + {id: 'components-selectpanel-examples--height-variations-and-scroll', name: 'Height Variations and Scroll'}, { id: 'components-selectpanel-examples--height-initial-with-overflowing-items-story', name: 'Height Initial with Overflowing Items', diff --git a/packages/react/src/FilteredActionList/FilteredActionList.module.css b/packages/react/src/FilteredActionList/FilteredActionList.module.css new file mode 100644 index 00000000000..93312579243 --- /dev/null +++ b/packages/react/src/FilteredActionList/FilteredActionList.module.css @@ -0,0 +1,6 @@ +.Container { + display: flex; + height: 100%; + overflow: auto; + flex-grow: 1; +} diff --git a/packages/react/src/FilteredActionList/FilteredActionListWithModernActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionListWithModernActionList.tsx index e2dab51eae7..e4ee594fd25 100644 --- a/packages/react/src/FilteredActionList/FilteredActionListWithModernActionList.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionListWithModernActionList.tsx @@ -14,6 +14,7 @@ import {VisuallyHidden} from '../VisuallyHidden' import type {SxProp} from '../sx' import type {FilteredActionListLoadingType} from './FilteredActionListLoaders' import {FilteredActionListLoadingTypes, FilteredActionListBodyLoader} from './FilteredActionListLoaders' +import classes from './FilteredActionList.module.css' import {ActionListContainerContext} from '../ActionList/ActionListContainerContext' import {isValidElementType} from 'react-is' @@ -32,6 +33,7 @@ export interface FilteredActionListProps onInputRefChanged?: (ref: React.RefObject) => void textInputProps?: Partial> inputRef?: React.RefObject + message?: React.ReactNode className?: string announcementsEnabled?: boolean } @@ -54,6 +56,7 @@ export function FilteredActionList({ sx, groupMetadata, showItemDividers, + message, className, selectionVariant, announcementsEnabled = true, @@ -150,6 +153,47 @@ export function FilteredActionList({ return itemsInGroup } + function getBodyContent() { + if (loading && scrollContainerRef.current && loadingType.appearsInBody) { + return + } + if (message) { + return message + } + + return ( + + + {groupMetadata?.length + ? groupMetadata.map((group, index) => { + return ( + + + {group.header?.title ? group.header.title : `Group ${group.groupId}`} + + {getItemListForEachGroup(group.groupId).map((item, index) => { + const key = item.key ?? item.id?.toString() ?? index.toString() + return + })} + + ) + }) + : items.map((item, index) => { + const key = item.key ?? item.id?.toString() ?? index.toString() + return + })} + + + ) + } useAnnouncements(items, listRef, inputRef, enableAnnouncements) return ( @@ -183,42 +227,9 @@ export function FilteredActionList({ /> Items will be filtered as you type - - {loading && scrollContainerRef.current && loadingType.appearsInBody ? ( - - ) : ( - - - {groupMetadata?.length - ? groupMetadata.map((group, index) => { - return ( - - - {group.header?.title ? group.header.title : `Group ${group.groupId}`} - - {getItemListForEachGroup(group.groupId).map((item, index) => { - const key = item.key ?? item.id?.toString() ?? index.toString() - return - })} - - ) - }) - : items.map((item, index) => { - const key = item.key ?? item.id?.toString() ?? index.toString() - return - })} - - - )} - +
+ {getBodyContent()} +
) } diff --git a/packages/react/src/SelectPanel/SelectPanel.dev.stories.tsx b/packages/react/src/SelectPanel/SelectPanel.dev.stories.tsx index 168e79e4ebb..89f76426e17 100644 --- a/packages/react/src/SelectPanel/SelectPanel.dev.stories.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.dev.stories.tsx @@ -9,13 +9,21 @@ import type {ItemInput} from '../deprecated/ActionList/List' import {FeatureFlags} from '../FeatureFlags' import FormControl from '../FormControl' -const meta = { +const meta: Meta = { title: 'Components/SelectPanel/Dev', component: SelectPanel, } satisfies Meta export default meta +const NoResultsMessage = (filter: string): {variant: 'empty'; title: string; body: string} => { + return { + variant: 'empty', + title: `No language found for \`${filter}\``, + body: 'Adjust your search term to find other languages', + } +} + function getColorCircle(color: string) { return function () { return ( @@ -108,6 +116,7 @@ export const WithCss = () => { onSelectedChange={setSelected} onFilterChange={setFilter} className="testCustomClassnameMono" + message={selectedItemsSortedFirst.length === 0 ? NoResultsMessage(filter) : undefined} /> @@ -159,6 +168,7 @@ export const WithSx = () => { onSelectedChange={setSelected} onFilterChange={setFilter} sx={{fontFamily: 'Times New Roman'}} + message={selectedItemsSortedFirst.length === 0 ? NoResultsMessage(filter) : undefined} /> @@ -211,6 +221,7 @@ export const WithSxAndCSS = () => { onFilterChange={setFilter} sx={{fontFamily: 'Times New Roman'}} className="testCustomClassnameMono" + message={selectedItemsSortedFirst.length === 0 ? NoResultsMessage(filter) : undefined} /> diff --git a/packages/react/src/SelectPanel/SelectPanel.docs.json b/packages/react/src/SelectPanel/SelectPanel.docs.json index d28e8c92ea5..ba48ce18d94 100644 --- a/packages/react/src/SelectPanel/SelectPanel.docs.json +++ b/packages/react/src/SelectPanel/SelectPanel.docs.json @@ -150,7 +150,48 @@ "type": "string | React.ReactElement", "defaultValue": "null", "description": "Footer rendered at the end of the panel" + }, + { + "name": "message", + "type": "{title: string | React.ReactElement; variant: 'empty' | 'error' | 'warning'; body: React.ReactNode;}", + "defaultValue": "A default empty message is provided by default if this option is not supplied", + "description": "Message to display in the panel in case of error or empty results" + }, + { + "name": "notice", + "type": "{text: string | React.ReactElement; variant: 'empty' | 'error' | 'warning';}", + "description": "Optional notice to display on top of the panel" } ], - "subcomponents": [] + "subcomponents": [ + { + "name": "SelectPanel.Message", + "props": [ + { + "name": "title", + "type": "string", + "description": "A title for the message" + }, + { + "name": "variant", + "type": "'empty' | 'error' | 'warning'", + "description": "The variant of the message", + "required": true + }, + { + "name": "className", + "type": "string", + "defaultValue": "", + "description": "Custom className" + }, + { + "name": "children", + "type": "React.ReactNode", + "defaultValue": "", + "required": true, + "description": "The message to display" + } + ] + } + ] } diff --git a/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx b/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx index 36abedbf65f..2f2eea43248 100644 --- a/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx @@ -11,13 +11,21 @@ import FormControl from '../FormControl' import {Stack} from '../Stack' import {Dialog} from '../experimental' -const meta = { +const meta: Meta = { title: 'Components/SelectPanel/Examples', component: SelectPanel, } satisfies Meta export default meta +const NoResultsMessage = (filter: string): {variant: 'empty'; title: string; body: string} => { + return { + variant: 'empty', + title: `No language found for \`${filter}\``, + body: 'Adjust your search term to find other languages', + } +} + function getColorCircle(color: string) { return function () { return ( @@ -82,6 +90,7 @@ export const HeightInitialWithOverflowingItemsStory = () => { onSelectedChange={setSelected} onFilterChange={setFilter} overlayProps={{width: 'small', height: 'initial', maxHeight: 'xsmall'}} + message={selectedItemsSortedFirst.length === 0 ? NoResultsMessage(filter) : undefined} /> ) @@ -127,6 +136,7 @@ export const HeightInitialWithUnderflowingItemsStory = () => { onFilterChange={setFilter} showItemDividers={true} overlayProps={{width: 'small', height: 'initial', maxHeight: 'xsmall'}} + message={selectedItemsSortedFirst.length === 0 ? NoResultsMessage(filter) : undefined} /> ) @@ -149,7 +159,7 @@ export const HeightInitialWithUnderflowingItemsAfterFetch = () => { [fetchedItems, filter, selected], ) // design guidelines say to sort selected items first - const selectedItemsSortedFirst = fetchedItems.sort((a, b) => { + const selectedItemsSortedFirst = filteredItems.sort((a, b) => { const aIsSelected = selected.some(selectedItem => selectedItem.text === a.text) const bIsSelected = selected.some(selectedItem => selectedItem.text === b.text) if (aIsSelected && !bIsSelected) return -1 @@ -179,13 +189,14 @@ export const HeightInitialWithUnderflowingItemsAfterFetch = () => { placeholder="Select labels" // button text when no items are selected open={open} onOpenChange={onOpenChange} - loading={filteredItems.length === 0} + loading={filteredItems.length === 0 && !filter} items={selectedItemsSortedFirst} selected={selected} onSelectedChange={setSelected} onFilterChange={setFilter} showItemDividers={true} overlayProps={{width: 'small', height, maxHeight: 'xsmall'}} + message={selectedItemsSortedFirst.length === 0 ? NoResultsMessage(filter) : undefined} /> ) @@ -228,6 +239,7 @@ export const AboveTallBody = () => { onSelectedChange={setSelected} onFilterChange={setFilter} showItemDividers={true} + message={selectedItemsSortedFirst.length === 0 ? NoResultsMessage(filter) : undefined} />
{ ) } -export const HeightVariantionsAndScroll = () => { +export const HeightVariationsAndScroll = () => { const longItems = [...items, ...items, ...items, ...items, ...items, ...items, ...items, ...items] const [filter, setFilter] = useState('') // Example A @@ -293,6 +305,7 @@ export const HeightVariantionsAndScroll = () => { onFilterChange={setFilter} showItemDividers={true} overlayProps={{height: 'medium'}} + message={selectedItemsSortedFirstA.length === 0 ? NoResultsMessage(filter) : undefined} />
@@ -316,6 +329,7 @@ export const HeightVariantionsAndScroll = () => { height: 'auto', maxHeight: 'medium', }} + message={selectedItemsSortedFirstB.length === 0 ? NoResultsMessage(filter) : undefined} /> @@ -390,6 +404,7 @@ export const CustomItemRenderer = () => { )} + message={selectedItemsSortedFirst.length === 0 ? NoResultsMessage(filter) : undefined} /> ) @@ -439,6 +454,7 @@ export const ItemsInScope = () => { selected={selected} onSelectedChange={setSelected} onFilterChange={setFilter} + message={selectedItemsSortedFirst.length === 0 ? NoResultsMessage(filter) : undefined} /> ) @@ -484,6 +500,7 @@ export const RepositionAfterLoading = () => { selected={selected} onSelectedChange={setSelected} onFilterChange={setFilter} + message={filteredItems.length === 0 ? NoResultsMessage(filter) : undefined} /> @@ -531,6 +548,7 @@ export const SelectPanelRepositionInsideDialog = () => { onSelectedChange={setSelected} onFilterChange={setFilter} overlayProps={{anchorSide: 'outside-top'}} + message={filteredItems.length === 0 ? NoResultsMessage(filter) : undefined} /> diff --git a/packages/react/src/SelectPanel/SelectPanel.features.stories.tsx b/packages/react/src/SelectPanel/SelectPanel.features.stories.tsx index 637340a5268..c6c6cedb2d8 100644 --- a/packages/react/src/SelectPanel/SelectPanel.features.stories.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.features.stories.tsx @@ -3,6 +3,7 @@ import type {Meta, StoryObj} from '@storybook/react' import Box from '../Box' import {Button} from '../Button' import type {ItemInput, GroupedListProps} from '../deprecated/ActionList/List' +import Link from '../Link' import {SelectPanel, type SelectPanelProps} from './SelectPanel' import { AlertIcon, @@ -18,18 +19,43 @@ import { VersionsIcon, } from '@primer/octicons-react' import useSafeTimeout from '../hooks/useSafeTimeout' +import ToggleSwitch from '../ToggleSwitch' +import Text from '../Text' import FormControl from '../FormControl' -import Link from '../Link' import {SegmentedControl} from '../SegmentedControl' import {Stack} from '../Stack' -const meta = { +const meta: Meta = { title: 'Components/SelectPanel/Features', component: SelectPanel, } satisfies Meta export default meta +const NoResultsMessage = (filter: string): {variant: 'empty'; title: string; body: string} => { + return { + variant: 'empty', + title: `No language found for \`${filter}\``, + body: 'Adjust your search term to find other languages', + } +} + +const EmptyMessage: {variant: 'empty'; title: string; body: React.ReactElement} = { + variant: 'empty', + title: `You haven't created any projects yet`, + body: ( + <> + Start your first project to organise your issues. + + ), +} + +const ErrorMessage: {variant: 'error'; title: string; body: string} = { + variant: 'error', + title: 'Oops', + body: 'Something went wrong.', +} + function getColorCircle(color: string) { return function () { return ( @@ -97,6 +123,7 @@ export const WithItemDividers = () => { onFilterChange={setFilter} showItemDividers={true} width="medium" + message={selectedItemsSortedFirst.length === 0 ? NoResultsMessage(filter) : undefined} /> ) @@ -142,6 +169,7 @@ export const WithPlaceholderForSearchInput = () => { onSelectedChange={setSelected} onFilterChange={setFilter} width="medium" + message={selectedItemsSortedFirst.length === 0 ? NoResultsMessage(filter) : undefined} /> ) @@ -179,6 +207,7 @@ export const SingleSelect = () => { onFilterChange={setFilter} onCancel={() => setOpen(false)} width="medium" + message={selectedItemsSortedFirst.length === 0 ? NoResultsMessage(filter) : undefined} /> ) @@ -223,6 +252,7 @@ export const MultiSelect = () => { onSelectedChange={setSelected} onFilterChange={setFilter} width="medium" + message={selectedItemsSortedFirst.length === 0 ? NoResultsMessage(filter) : undefined} /> ) @@ -265,6 +295,7 @@ export const WithExternalAnchor = () => { onSelectedChange={setSelected} onFilterChange={setFilter} width="medium" + message={filteredItems.length === 0 ? NoResultsMessage(filter) : undefined} /> ) @@ -313,6 +344,7 @@ export const WithFooter = () => { } width="medium" + message={selectedItemsSortedFirst.length === 0 ? NoResultsMessage(filter) : undefined} /> ) @@ -492,6 +524,7 @@ export const WithGroups = () => { onFilterChange={setFilter} overlayProps={{width: 'large', height: 'xlarge'}} width="medium" + message={selectedItemsSortedFirst.length === 0 ? NoResultsMessage(filter) : undefined} /> ) @@ -536,6 +569,7 @@ export const WithLabelVisuallyHidden = () => { onSelectedChange={setSelected} onFilterChange={setFilter} width="medium" + message={selectedItemsSortedFirst.length === 0 ? NoResultsMessage(filter) : undefined} /> ) @@ -583,6 +617,7 @@ export const WithLabelInternally = () => { onSelectedChange={setSelected} onFilterChange={setFilter} width="medium" + message={selectedItemsSortedFirst.length === 0 ? NoResultsMessage(filter) : undefined} /> ) } @@ -594,10 +629,12 @@ export const AsyncFetch: StoryObj = { const [open, setOpen] = useState(false) const filterTimerId = useRef(null) const {safeSetTimeout, safeClearTimeout} = useSafeTimeout() + const [query, setQuery] = useState('') const fetchItems = (query: string) => { if (filterTimerId.current) { safeClearTimeout(filterTimerId.current) + setQuery(query) } filterTimerId.current = safeSetTimeout(() => { @@ -635,6 +672,122 @@ export const AsyncFetch: StoryObj = { height={height} initialLoadingType={initialLoadingType} width="medium" + message={filteredItems.length === 0 ? NoResultsMessage(query) : undefined} + /> + ) + }, + args: { + initialLoadingType: 'spinner', + height: 'medium', + }, + argTypes: { + initialLoadingType: { + control: 'select', + options: ['spinner', 'skeleton'], + }, + height: { + control: 'select', + options: ['auto', 'xsmall', 'small', 'medium', 'large', 'xlarge'], + }, + }, +} + +export const CustomisedNoInitialItems = () => { + const [selected, setSelected] = React.useState([]) + const [filteredItems, setFilteredItems] = React.useState([]) + const [open, setOpen] = useState(false) + const [filter, setFilter] = useState('') + const onFilterChange = (value: string = '') => { + setFilter(value) + setTimeout(() => { + // fetch the items + setFilteredItems([]) + }, 0) + } + const [isError, setIsError] = React.useState(false) + + const onClick = React.useCallback(() => { + setIsError(!isError) + }, [setIsError, isError]) + + function getMessage(): {variant: 'empty' | 'error'; title: string; body: string | React.ReactElement} { + if (isError) return ErrorMessage + else if (filter) return NoResultsMessage(filter) + else return EmptyMessage + } + + return ( + <> + + Enable Error State :{isError ? 'On' : 'Off'} + + + ( + + )} + open={open} + onOpenChange={setOpen} + items={filteredItems} + selected={selected} + onSelectedChange={setSelected} + onFilterChange={onFilterChange} + width="medium" + height="large" + message={getMessage()} + /> + + ) +} + +export const CustomisedNoResults: StoryObj = { + render: ({initialLoadingType, height}) => { + const [selected, setSelected] = React.useState([]) + const [filteredItems, setFilteredItems] = React.useState([]) + const [filterValue, setFilterValue] = React.useState('') + const [open, setOpen] = useState(false) + const filterTimerId = useRef(null) + const {safeSetTimeout, safeClearTimeout} = useSafeTimeout() + const onFilterChange = (value: string) => { + setFilterValue(value) + 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} + height={height} + overlayProps={{maxHeight: height === 'auto' || height === 'initial' ? 'xlarge' : height}} + message={filteredItems.length === 0 ? NoResultsMessage(filterValue) : undefined} /> ) }, diff --git a/packages/react/src/SelectPanel/SelectPanel.module.css b/packages/react/src/SelectPanel/SelectPanel.module.css index 9b3acb44a17..e737e17f13b 100644 --- a/packages/react/src/SelectPanel/SelectPanel.module.css +++ b/packages/react/src/SelectPanel/SelectPanel.module.css @@ -1,3 +1,8 @@ +.Overlay { + /* CSS variables values are passed in via styles */ + --max-height: 0; +} + .Wrapper { display: flex; height: inherit; @@ -74,6 +79,45 @@ max-height: inherit; } +.Message { + display: flex; + height: 100%; + min-height: min(calc(var(--max-height) - 150px), 324px); /* maxHeight of dialog - (header & footer) */ + padding: var(--base-size-24); + text-align: center; + flex-direction: column; + justify-content: center; + align-items: center; + flex-grow: 1; + gap: var(--base-size-4); + + a { + color: inherit; + text-decoration: underline; + } +} + +.MessageTitle { + font-size: var(--text-body-size-medium); + font-weight: var(--base-text-weight-semibold); +} + +.MessageBody { + font-size: var(--text-body-size-small); + color: var(--fgColor-muted); + align-items: center; + gap: var(--stack-gap-condensed); +} + +.MessageIcon { + margin-bottom: var(--base-size-8); + color: var(--fgColor-attention); + + &:where([data-variant='error']) { + color: var(--fgColor-danger); + } +} + .ResponsiveCloseButton { display: none; diff --git a/packages/react/src/SelectPanel/SelectPanel.stories.tsx b/packages/react/src/SelectPanel/SelectPanel.stories.tsx index 24d0ba377d5..96e8e20c23f 100644 --- a/packages/react/src/SelectPanel/SelectPanel.stories.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.stories.tsx @@ -8,7 +8,7 @@ import {SelectPanel} from '../SelectPanel' import type {ItemInput} from '../deprecated/ActionList/List' import FormControl from '../FormControl' -const meta = { +const meta: Meta = { title: 'Components/SelectPanel', component: SelectPanel, } satisfies Meta @@ -101,6 +101,15 @@ export const Default = () => { onSelectedChange={setSelected} onFilterChange={setFilter} width="medium" + message={ + selectedItemsSortedFirst.length === 0 + ? { + variant: 'empty', + title: `No language found for \`${filter}\``, + body: 'Adjust your search term to find other languages', + } + : undefined + } /> ) diff --git a/packages/react/src/SelectPanel/SelectPanel.test.tsx b/packages/react/src/SelectPanel/SelectPanel.test.tsx index d00754bf453..f8112d38010 100644 --- a/packages/react/src/SelectPanel/SelectPanel.test.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.test.tsx @@ -491,6 +491,97 @@ for (const useModernActionList of [false, true]) { ) } + const SelectPanelWithCustomMessages: React.FC<{items: SelectPanelProps['items']}> = ({items}) => { + const [selected, setSelected] = React.useState([]) + const [filter, setFilter] = React.useState('') + const [open, setOpen] = React.useState(false) + + const onSelectedChange = (selected: SelectPanelProps['items']) => { + setSelected(selected) + } + + const emptyMessage: {variant: 'empty'; title: string; body: string} = { + variant: 'empty', + title: "You haven't created any projects yet", + body: 'Start your first project to organise your issues', + } + + const noResultsMessage = (filter: string): {variant: 'empty'; title: string; body: string} => ({ + variant: 'empty', + title: `No language found for ${filter}`, + body: 'Adjust your search term to find other languages', + }) + + const filteredItems = items.filter(item => item.text?.includes(filter)) + + function getMessage() { + if (filteredItems.length === 0 && !filter) { + return emptyMessage + } + if (filteredItems.length === 0 && filter) { + return noResultsMessage(filter) + } + return undefined + } + + return ( + + { + setFilter(value) + }} + open={open} + onOpenChange={isOpen => { + setOpen(isOpen) + }} + message={getMessage()} + /> + + ) + } + + function NoItemAvailableSelectPanel() { + const [selected, setSelected] = React.useState([]) + const [filter, setFilter] = React.useState('') + const [open, setOpen] = React.useState(false) + + const onSelectedChange = (selected: SelectPanelProps['items']) => { + setSelected(selected) + } + + const items: SelectPanelProps['items'] = [] + + return ( + + item.text?.includes(filter))} + placeholder="Select items" + placeholderText="Filter items" + selected={selected} + onSelectedChange={onSelectedChange} + filterValue={filter} + onFilterChange={value => { + setFilter(value) + }} + open={open} + onOpenChange={isOpen => { + setOpen(isOpen) + }} + /> + + ) + } + describe('filtering', () => { it('should filter the list of items when the user types into the input', async () => { const user = userEvent.setup() @@ -652,6 +743,76 @@ for (const useModernActionList of [false, true]) { }) }) + describe('Empty state', () => { + // This is only implemented with the feature flag (for now) + if (!useModernActionList) return + + it('should display the default empty state message when there is no matching item after filtering (No custom message is provided)', async () => { + const user = userEvent.setup() + + renderWithFlag(, useModernActionList) + + await user.click(screen.getByText('Select items')) + + expect(screen.getAllByRole('option')).toHaveLength(3) + + await user.type(document.activeElement!, 'something') + expect(screen.getByText("You haven't created any items yet")).toBeVisible() + expect(screen.getByText('Please add or create new items to populate the list.')).toBeVisible() + }) + + it('should display the default empty state message when there is no item after the initial load (No custom message is provided)', async () => { + const user = userEvent.setup() + + renderWithFlag(, useModernActionList) + + await waitFor(async () => { + await user.click(screen.getByText('Select items')) + expect(screen.getByText("You haven't created any items yet")).toBeVisible() + expect(screen.getByText('Please add or create new items to populate the list.')).toBeVisible() + }) + }) + it('should display the custom empty state message when there is no matching item after filtering', async () => { + const user = userEvent.setup() + + renderWithFlag( + , + useModernActionList, + ) + + await user.click(screen.getByText('Select items')) + + expect(screen.getAllByRole('option')).toHaveLength(3) + + await user.type(document.activeElement!, 'something') + expect(screen.getByText('No language found for something')).toBeVisible() + expect(screen.getByText('Adjust your search term to find other languages')).toBeVisible() + }) + + it('should display the custom empty state message when there is no item after the initial load', async () => { + const user = userEvent.setup() + + renderWithFlag(, useModernActionList) + + await waitFor(async () => { + await user.click(screen.getByText('Select items')) + expect(screen.getByText("You haven't created any projects yet")).toBeVisible() + expect(screen.getByText('Start your first project to organise your issues')).toBeVisible() + }) + }) + }) describe('with footer', () => { function SelectPanelWithFooter() { const [selected, setSelected] = React.useState([]) diff --git a/packages/react/src/SelectPanel/SelectPanel.tsx b/packages/react/src/SelectPanel/SelectPanel.tsx index 3154baa1682..0758d4a6b2f 100644 --- a/packages/react/src/SelectPanel/SelectPanel.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.tsx @@ -10,13 +10,13 @@ import Heading from '../Heading' import type {OverlayProps} from '../Overlay' import type {TextInputProps} from '../TextInput' import type {ItemProps, ItemInput} from './types' +import {SelectPanelMessage} from './SelectPanelMessage' import {Button, IconButton} from '../Button' import {useProvidedRefOrCreate} from '../hooks' 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' @@ -24,11 +24,18 @@ import {useFeatureFlag} from '../FeatureFlags' import {announce} from '@primer/live-region-element' import classes from './SelectPanel.module.css' import {clsx} from 'clsx' +import {heightMap} from '../Overlay/Overlay' // we add a delay so that it does not interrupt default screen reader announcement and queues after it const delayMs = 500 const loadingDelayMs = 1000 +const DefaultEmptyMessage = ( + + Please add or create new items to populate the list. + +) + const getItemWithActiveDescendant = ( listRef: React.RefObject, items: FilteredActionListProps['items'], @@ -44,7 +51,7 @@ const getItemWithActiveDescendant = ( const activeItem = items[index] as ItemInput | undefined const text = activeItem?.text - const selected = activeItem?.selected + const selected = activeItemElement.getAttribute('aria-selected') === 'true' return {index, text, selected} } @@ -127,15 +134,21 @@ interface SelectPanelBaseProps { text: string | React.ReactElement variant: 'info' | 'warning' | 'error' } + message?: { + title: string + body: string | React.ReactElement + variant: 'empty' | 'error' | 'warning' + } onCancel?: () => void } -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'], ): selected is SelectPanelMultiSelection['selected'] { @@ -189,6 +202,7 @@ export function SelectPanel({ height, width, id, + message, notice, onCancel, ...listProps @@ -272,13 +286,15 @@ export function SelectPanel({ useEffect(() => { if (open) { - if (items.length === 0 && !usingModernActionList) { - announceNoItems() - } else { - if (listContainerElement) { - announceItemsChanged(items, {current: listContainerElement}) + if (!usingModernActionList) { + if (items.length === 0) { + announceNoItems() } else { - setNeedItemsChangedAnnouncement(true) + if (listContainerElement) { + announceItemsChanged(items, {current: listContainerElement}) + } else { + setNeedItemsChangedAnnouncement(true) + } } } } @@ -291,7 +307,7 @@ export function SelectPanel({ return } - if (isLoading) { + if (isLoading || items.length > 0) { setIsLoading(false) setDataLoadedOnce(true) } @@ -433,167 +449,172 @@ export function SelectPanel({ error: , } + function getMessage() { + // If there is no items after the first load, show the no items state + if (items.length === 0 && !message) { + return DefaultEmptyMessage + } else if (message) { + return ( + + {message.body} + + ) + } + } + return ( - - + - - {usingModernActionList ? null : ( - - )} +
+ + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} +
+ {onCancel && ( + { + onCancel() + onClose('escape') + }} + /> + )} +
+ {notice && ( +
+ {iconForNoticeVariant[notice.variant]} +
{notice.text}
+
+ )} + + {footer ? ( -
- - {title} - - {subtitle ? ( - - {subtitle} - - ) : null} -
+ {footer} +
+ ) : isMultiSelectVariant(selected) ? ( + /* Save and Cancel buttons are only useful for multiple selection, single selection instantly closes the panel */ +
+ {/* we add a save and cancel button on narrow screens when SelectPanel is full-screen */} {onCancel && ( - { onCancel() onClose('escape') }} - /> - )} - - {notice && ( -
- {iconForNoticeVariant[notice.variant]} -
{notice.text}
-
- )} - - {footer ? ( - - {footer} - - ) : isMultiSelectVariant(selected) ? ( - /* Save and Cancel buttons are only useful for multiple selection, single selection instantly closes the panel */ -
- {/* we add a save and cancel button on narrow screens when SelectPanel is full-screen */} - {onCancel && ( - - )} - -
- ) : null} - - - + )} + +
+ ) : null} +
+
) } - -SelectPanel.displayName = 'SelectPanel' diff --git a/packages/react/src/SelectPanel/SelectPanelMessage.tsx b/packages/react/src/SelectPanel/SelectPanelMessage.tsx new file mode 100644 index 00000000000..a2f099d231d --- /dev/null +++ b/packages/react/src/SelectPanel/SelectPanelMessage.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import Text from '../Text' +import Octicon from '../Octicon' +import {AlertIcon} from '@primer/octicons-react' +import classes from './SelectPanel.module.css' +import {clsx} from 'clsx' + +export type SelectPanelMessageProps = { + children: React.ReactNode + title: string + variant: 'empty' | 'error' | 'warning' + className?: string +} + +export const SelectPanelMessage: React.FC = ({variant, title, children, className}) => { + return ( +
+ {variant !== 'empty' ? : null} + {title} + {children} +
+ ) +} diff --git a/script/generate-e2e-tests.js b/script/generate-e2e-tests.js index 8405ba30bd8..3c38372326f 100755 --- a/script/generate-e2e-tests.js +++ b/script/generate-e2e-tests.js @@ -1142,8 +1142,8 @@ const components = new Map([ name: 'Above Tall Body', }, { - id: 'components-selectpanel-examples--height-variantions-and-scroll', - name: 'Height Variantions and Scroll', + id: 'components-selectpanel-examples--height-variations-and-scroll', + name: 'Height Variations and Scroll', }, { id: 'components-selectpanel-examples--height-initial-with-overflowing-items-story',