Skip to content

Add action and icon prop to SelectPanel message #6350

New issue

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

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

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Jul 25, 2025
Merged
5 changes: 5 additions & 0 deletions .changeset/sharp-buckets-study.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

Adds `icon` and `action` props to `SelectPanelMessage` to improve UX and accessibility.
4 changes: 2 additions & 2 deletions packages/react/src/SelectPanel/SelectPanel.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@
},
{
"name": "message",
"type": "{title: string | React.ReactElement; variant: 'empty' | 'error' | 'warning'; body: React.ReactNode;}",
"type": "{title: string | React.ReactElement; variant: 'empty' | 'error' | 'warning'; body: React.ReactNode; icon?:React.ComponentType<IconProps>;action?: React.ReactElement;}",
"defaultValue": "A default empty message is provided if this option is not configured. Supply a custom empty message to override the default.",
"description": "Message to display in the panel in case of error or empty results"
},
Expand Down Expand Up @@ -210,4 +210,4 @@
}
],
"subcomponents": []
}
}
103 changes: 102 additions & 1 deletion packages/react/src/SelectPanel/SelectPanel.features.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, {useState, useRef} from 'react'
import React, {useState, useRef, useEffect} from 'react'
import type {Meta, StoryObj} from '@storybook/react-vite'
import Box from '../Box'
import {Button} from '../Button'
Expand All @@ -11,19 +11,23 @@ import {
GearIcon,
InfoIcon,
NoteIcon,
PlusIcon,
ProjectIcon,
SearchIcon,
StopIcon,
TagIcon,
TriangleDownIcon,
TypographyIcon,
VersionsIcon,
type IconProps,
} from '@primer/octicons-react'
import useSafeTimeout from '../hooks/useSafeTimeout'
import ToggleSwitch from '../ToggleSwitch'
import Text from '../Text'
import FormControl from '../FormControl'
import {SegmentedControl} from '../SegmentedControl'
import {Stack} from '../Stack'
import {FeatureFlags} from '../FeatureFlags'

const meta: Meta<typeof SelectPanel> = {
title: 'Components/SelectPanel/Features',
Expand Down Expand Up @@ -889,6 +893,103 @@ export const WithInactiveItems = () => {
)
}

export const WithMessage = () => {
const [selected, setSelected] = useState<ItemInput[]>([])
const [filter, setFilter] = useState('')
const [open, setOpen] = useState(false)
const [messageVariant, setMessageVariant] = useState(0)

const messageVariants: Array<
| undefined
| {
title: string
body: string | React.ReactElement
variant: 'empty' | 'error' | 'warning'
icon?: React.ComponentType<IconProps>
action?: React.ReactElement
}
> = [
undefined, // Default message
{
variant: 'empty',
title: 'No labels found',
body: 'Try adjusting your search or create a new label',
icon: TagIcon,
action: (
<Button variant="default" size="small" leadingVisual={PlusIcon} onClick={() => {}}>
Create new label
</Button>
),
},
{
variant: 'error',
title: 'Failed to load labels',
body: (
<>
Check your network connection and try again or <Link href="/support">contact support</Link>
</>
),
},
{
variant: 'warning',
title: 'Some labels may be outdated',
body: 'Consider refreshing to get the latest data',
},
]

const itemsToShow = messageVariant === 0 ? items.slice(0, 3) : []
const filteredItems = itemsToShow.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase()))

useEffect(() => {
setFilter('')
}, [messageVariant])

return (
<FeatureFlags flags={{primer_react_select_panel_with_modern_action_list: true}}>
<Stack align="start">
<FormControl>
<FormControl.Label>Message variant</FormControl.Label>
<SegmentedControl aria-label="Message variant" onChange={setMessageVariant}>
<SegmentedControl.Button defaultSelected aria-label="Default message">
Default message
</SegmentedControl.Button>
<SegmentedControl.Button aria-label="Empty" leadingIcon={SearchIcon}>
Empty
</SegmentedControl.Button>
<SegmentedControl.Button aria-label="Error" leadingIcon={StopIcon}>
Error
</SegmentedControl.Button>
<SegmentedControl.Button aria-label="Warning" leadingIcon={AlertIcon}>
Warning
</SegmentedControl.Button>
</SegmentedControl>
</FormControl>
<FormControl>
<FormControl.Label>SelectPanel with message</FormControl.Label>
<SelectPanel
renderAnchor={({children, ...anchorProps}) => (
<Button trailingAction={TriangleDownIcon} {...anchorProps}>
{children}
</Button>
)}
placeholder="Select labels"
open={open}
onOpenChange={setOpen}
items={filteredItems}
selected={selected}
onSelectedChange={setSelected}
onFilterChange={setFilter}
overlayProps={{width: 'small', height: 'medium'}}
width="medium"
message={messageVariants[messageVariant]}
filterValue={filter}
/>
</FormControl>
</Stack>
</FeatureFlags>
)
}

export const WithSelectAll = () => {
const [selected, setSelected] = useState<ItemInput[]>([])
const [filter, setFilter] = useState('')
Expand Down
4 changes: 4 additions & 0 deletions packages/react/src/SelectPanel/SelectPanel.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@
}
}

.MessageAction {
margin-top: var(--base-size-8);
}

.ResponsiveCloseButton {
display: inline-grid;
}
Expand Down
48 changes: 43 additions & 5 deletions packages/react/src/SelectPanel/SelectPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,11 @@ for (const useModernActionList of [false, true]) {
)
}

const SelectPanelWithCustomMessages: React.FC<{items: SelectPanelProps['items']}> = ({items}) => {
const SelectPanelWithCustomMessages: React.FC<{
items: SelectPanelProps['items']
withAction?: boolean
onAction?: () => void
}> = ({items, withAction = false, onAction}) => {
const [selected, setSelected] = React.useState<SelectPanelProps['items']>([])
const [filter, setFilter] = React.useState('')
const [open, setOpen] = React.useState(false)
Expand All @@ -463,14 +467,21 @@ for (const useModernActionList of [false, true]) {
setSelected(selected)
}

const emptyMessage: {variant: 'empty'; title: string; body: string} = {
variant: 'empty',
const emptyMessage = {
variant: 'empty' as const,
title: "You haven't created any projects yet",
body: 'Start your first project to organise your issues',
...(withAction && {
action: (
<button type="button" onClick={onAction} data-testid="create-project-action">
Create new project
</button>
),
}),
}

const noResultsMessage = (filter: string): {variant: 'empty'; title: string; body: string} => ({
variant: 'empty',
const noResultsMessage = (filter: string) => ({
variant: 'empty' as const,
title: `No language found for ${filter}`,
body: 'Adjust your search term to find other languages',
})
Expand Down Expand Up @@ -900,7 +911,34 @@ for (const useModernActionList of [false, true]) {
expect(screen.getByText('Start your first project to organise your issues')).toBeVisible()
})
})

it('should display action button in custom empty state message', async () => {
const handleAction = jest.fn()
const user = userEvent.setup()

renderWithFlag(
<SelectPanelWithCustomMessages items={[]} withAction={true} onAction={handleAction} />,
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()

// Check that action button is visible
const actionButton = screen.getByTestId('create-project-action')
expect(actionButton).toBeVisible()
expect(actionButton).toHaveTextContent('Create new project')
})

// Test that action button is clickable
const actionButton = screen.getByTestId('create-project-action')
await user.click(actionButton)
expect(handleAction).toHaveBeenCalledTimes(1)
})
})

describe('with footer', () => {
function SelectPanelWithFooter() {
const [selected, setSelected] = React.useState<SelectPanelProps['items']>([])
Expand Down
14 changes: 12 additions & 2 deletions packages/react/src/SelectPanel/SelectPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import {AlertIcon, InfoIcon, SearchIcon, StopIcon, TriangleDownIcon, XIcon} from '@primer/octicons-react'
import {
AlertIcon,
InfoIcon,
SearchIcon,
StopIcon,
TriangleDownIcon,
XIcon,
type IconProps,
} from '@primer/octicons-react'
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import type {AnchoredOverlayProps} from '../AnchoredOverlay'
import {AnchoredOverlay} from '../AnchoredOverlay'
Expand Down Expand Up @@ -98,6 +106,8 @@ interface SelectPanelBaseProps {
title: string
body: string | React.ReactElement
variant: 'empty' | 'error' | 'warning'
icon?: React.ComponentType<IconProps>
action?: React.ReactElement
}
/**
* @deprecated Use `secondaryAction` instead.
Expand Down Expand Up @@ -669,7 +679,7 @@ function Panel({
return DefaultEmptyMessage
} else if (message) {
return (
<SelectPanelMessage title={message.title} variant={message.variant}>
<SelectPanelMessage title={message.title} variant={message.variant} icon={message.icon} action={message.action}>
{message.body}
</SelectPanelMessage>
)
Expand Down
28 changes: 25 additions & 3 deletions packages/react/src/SelectPanel/SelectPanelMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type React from 'react'
import Text from '../Text'
import Octicon from '../Octicon'
import {AlertIcon} from '@primer/octicons-react'
import {AlertIcon, type IconProps} from '@primer/octicons-react'
import classes from './SelectPanel.module.css'
import {clsx} from 'clsx'

Expand All @@ -10,14 +10,36 @@ export type SelectPanelMessageProps = {
title: string
variant: 'empty' | 'error' | 'warning'
className?: string
/**
* Custom icon to display above the title.
* When not provided, uses NoEntryIcon for empty states and AlertIcon for error/warning states.
*/
icon?: React.ComponentType<IconProps>
/**
* Custom action element to display below the message body.
* This can be used to render interactive elements like buttons or links.
* Ensure the action element is accessible by providing appropriate ARIA attributes
* and making it keyboard-navigable.
*/
action?: React.ReactElement
}

export const SelectPanelMessage: React.FC<SelectPanelMessageProps> = ({variant, title, children, className}) => {
export const SelectPanelMessage: React.FC<SelectPanelMessageProps> = ({
variant,
title,
children,
className,
icon: CustomIcon,
action,
}) => {
const IconComponent = CustomIcon || (variant !== 'empty' ? AlertIcon : undefined)

return (
<div className={clsx(classes.Message, className)}>
{variant !== 'empty' ? <Octicon icon={AlertIcon} className={classes.MessageIcon} data-variant={variant} /> : null}
{IconComponent && <Octicon icon={IconComponent} className={classes.MessageIcon} data-variant={variant} />}
<Text className={classes.MessageTitle}>{title}</Text>
<Text className={classes.MessageBody}>{children}</Text>
{action && <div className={classes.MessageAction}>{action}</div>}
</div>
)
}
Loading