Skip to content
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 AlertIcon for error/warning states and no icon for empty state.
*/
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