Skip to content

Commit 8731f43

Browse files
liuliu-devCopilot
andauthored
Add action and icon prop to SelectPanel message (#6350)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent dbf4dbf commit 8731f43

File tree

7 files changed

+192
-12
lines changed

7 files changed

+192
-12
lines changed

.changeset/sharp-buckets-study.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/react': minor
3+
---
4+
5+
Adds `icon` and `action` props to `SelectPanelMessage` to improve UX and accessibility.

packages/react/src/SelectPanel/SelectPanel.docs.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@
163163
},
164164
{
165165
"name": "message",
166-
"type": "{title: string | React.ReactElement; variant: 'empty' | 'error' | 'warning'; body: React.ReactNode;}",
166+
"type": "{title: string | React.ReactElement; variant: 'empty' | 'error' | 'warning'; body: React.ReactNode; icon?:React.ComponentType<IconProps>;action?: React.ReactElement;}",
167167
"defaultValue": "A default empty message is provided if this option is not configured. Supply a custom empty message to override the default.",
168168
"description": "Message to display in the panel in case of error or empty results"
169169
},
@@ -210,4 +210,4 @@
210210
}
211211
],
212212
"subcomponents": []
213-
}
213+
}

packages/react/src/SelectPanel/SelectPanel.features.stories.tsx

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,23 @@ import {
1111
GearIcon,
1212
InfoIcon,
1313
NoteIcon,
14+
PlusIcon,
1415
ProjectIcon,
1516
SearchIcon,
1617
StopIcon,
18+
TagIcon,
1719
TriangleDownIcon,
1820
TypographyIcon,
1921
VersionsIcon,
22+
type IconProps,
2023
} from '@primer/octicons-react'
2124
import useSafeTimeout from '../hooks/useSafeTimeout'
2225
import ToggleSwitch from '../ToggleSwitch'
2326
import Text from '../Text'
2427
import FormControl from '../FormControl'
2528
import {SegmentedControl} from '../SegmentedControl'
2629
import {Stack} from '../Stack'
30+
import {FeatureFlags} from '../FeatureFlags'
2731

2832
const meta: Meta<typeof SelectPanel> = {
2933
title: 'Components/SelectPanel/Features',
@@ -909,6 +913,103 @@ export const WithInactiveItems = () => {
909913
)
910914
}
911915

916+
export const WithMessage = () => {
917+
const [selected, setSelected] = useState<ItemInput[]>([])
918+
const [filter, setFilter] = useState('')
919+
const [open, setOpen] = useState(false)
920+
const [messageVariant, setMessageVariant] = useState(0)
921+
922+
const messageVariants: Array<
923+
| undefined
924+
| {
925+
title: string
926+
body: string | React.ReactElement
927+
variant: 'empty' | 'error' | 'warning'
928+
icon?: React.ComponentType<IconProps>
929+
action?: React.ReactElement
930+
}
931+
> = [
932+
undefined, // Default message
933+
{
934+
variant: 'empty',
935+
title: 'No labels found',
936+
body: 'Try adjusting your search or create a new label',
937+
icon: TagIcon,
938+
action: (
939+
<Button variant="default" size="small" leadingVisual={PlusIcon} onClick={() => {}}>
940+
Create new label
941+
</Button>
942+
),
943+
},
944+
{
945+
variant: 'error',
946+
title: 'Failed to load labels',
947+
body: (
948+
<>
949+
Check your network connection and try again or <Link href="/support">contact support</Link>
950+
</>
951+
),
952+
},
953+
{
954+
variant: 'warning',
955+
title: 'Some labels may be outdated',
956+
body: 'Consider refreshing to get the latest data',
957+
},
958+
]
959+
960+
const itemsToShow = messageVariant === 0 ? items.slice(0, 3) : []
961+
const filteredItems = itemsToShow.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase()))
962+
963+
useEffect(() => {
964+
setFilter('')
965+
}, [messageVariant])
966+
967+
return (
968+
<FeatureFlags flags={{primer_react_select_panel_with_modern_action_list: true}}>
969+
<Stack align="start">
970+
<FormControl>
971+
<FormControl.Label>Message variant</FormControl.Label>
972+
<SegmentedControl aria-label="Message variant" onChange={setMessageVariant}>
973+
<SegmentedControl.Button defaultSelected aria-label="Default message">
974+
Default message
975+
</SegmentedControl.Button>
976+
<SegmentedControl.Button aria-label="Empty" leadingIcon={SearchIcon}>
977+
Empty
978+
</SegmentedControl.Button>
979+
<SegmentedControl.Button aria-label="Error" leadingIcon={StopIcon}>
980+
Error
981+
</SegmentedControl.Button>
982+
<SegmentedControl.Button aria-label="Warning" leadingIcon={AlertIcon}>
983+
Warning
984+
</SegmentedControl.Button>
985+
</SegmentedControl>
986+
</FormControl>
987+
<FormControl>
988+
<FormControl.Label>SelectPanel with message</FormControl.Label>
989+
<SelectPanel
990+
renderAnchor={({children, ...anchorProps}) => (
991+
<Button trailingAction={TriangleDownIcon} {...anchorProps}>
992+
{children}
993+
</Button>
994+
)}
995+
placeholder="Select labels"
996+
open={open}
997+
onOpenChange={setOpen}
998+
items={filteredItems}
999+
selected={selected}
1000+
onSelectedChange={setSelected}
1001+
onFilterChange={setFilter}
1002+
overlayProps={{width: 'small', height: 'medium'}}
1003+
width="medium"
1004+
message={messageVariants[messageVariant]}
1005+
filterValue={filter}
1006+
/>
1007+
</FormControl>
1008+
</Stack>
1009+
</FeatureFlags>
1010+
)
1011+
}
1012+
9121013
export const WithSelectAll = () => {
9131014
const [selected, setSelected] = useState<ItemInput[]>([])
9141015
const [filter, setFilter] = useState('')

packages/react/src/SelectPanel/SelectPanel.module.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,10 @@
141141
}
142142
}
143143

144+
.MessageAction {
145+
margin-top: var(--base-size-8);
146+
}
147+
144148
.ResponsiveCloseButton {
145149
display: inline-grid;
146150
}

packages/react/src/SelectPanel/SelectPanel.test.tsx

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -454,7 +454,11 @@ for (const useModernActionList of [false, true]) {
454454
)
455455
}
456456

457-
const SelectPanelWithCustomMessages: React.FC<{items: SelectPanelProps['items']}> = ({items}) => {
457+
const SelectPanelWithCustomMessages: React.FC<{
458+
items: SelectPanelProps['items']
459+
withAction?: boolean
460+
onAction?: () => void
461+
}> = ({items, withAction = false, onAction}) => {
458462
const [selected, setSelected] = React.useState<SelectPanelProps['items']>([])
459463
const [filter, setFilter] = React.useState('')
460464
const [open, setOpen] = React.useState(false)
@@ -463,14 +467,21 @@ for (const useModernActionList of [false, true]) {
463467
setSelected(selected)
464468
}
465469

466-
const emptyMessage: {variant: 'empty'; title: string; body: string} = {
467-
variant: 'empty',
470+
const emptyMessage = {
471+
variant: 'empty' as const,
468472
title: "You haven't created any projects yet",
469473
body: 'Start your first project to organise your issues',
474+
...(withAction && {
475+
action: (
476+
<button type="button" onClick={onAction} data-testid="create-project-action">
477+
Create new project
478+
</button>
479+
),
480+
}),
470481
}
471482

472-
const noResultsMessage = (filter: string): {variant: 'empty'; title: string; body: string} => ({
473-
variant: 'empty',
483+
const noResultsMessage = (filter: string) => ({
484+
variant: 'empty' as const,
474485
title: `No language found for ${filter}`,
475486
body: 'Adjust your search term to find other languages',
476487
})
@@ -900,7 +911,34 @@ for (const useModernActionList of [false, true]) {
900911
expect(screen.getByText('Start your first project to organise your issues')).toBeVisible()
901912
})
902913
})
914+
915+
it('should display action button in custom empty state message', async () => {
916+
const handleAction = jest.fn()
917+
const user = userEvent.setup()
918+
919+
renderWithFlag(
920+
<SelectPanelWithCustomMessages items={[]} withAction={true} onAction={handleAction} />,
921+
useModernActionList,
922+
)
923+
924+
await waitFor(async () => {
925+
await user.click(screen.getByText('Select items'))
926+
expect(screen.getByText("You haven't created any projects yet")).toBeVisible()
927+
expect(screen.getByText('Start your first project to organise your issues')).toBeVisible()
928+
929+
// Check that action button is visible
930+
const actionButton = screen.getByTestId('create-project-action')
931+
expect(actionButton).toBeVisible()
932+
expect(actionButton).toHaveTextContent('Create new project')
933+
})
934+
935+
// Test that action button is clickable
936+
const actionButton = screen.getByTestId('create-project-action')
937+
await user.click(actionButton)
938+
expect(handleAction).toHaveBeenCalledTimes(1)
939+
})
903940
})
941+
904942
describe('with footer', () => {
905943
function SelectPanelWithFooter() {
906944
const [selected, setSelected] = React.useState<SelectPanelProps['items']>([])

packages/react/src/SelectPanel/SelectPanel.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
import {AlertIcon, InfoIcon, SearchIcon, StopIcon, TriangleDownIcon, XIcon} from '@primer/octicons-react'
1+
import {
2+
AlertIcon,
3+
InfoIcon,
4+
SearchIcon,
5+
StopIcon,
6+
TriangleDownIcon,
7+
XIcon,
8+
type IconProps,
9+
} from '@primer/octicons-react'
210
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
311
import type {AnchoredOverlayProps} from '../AnchoredOverlay'
412
import {AnchoredOverlay} from '../AnchoredOverlay'
@@ -98,6 +106,8 @@ interface SelectPanelBaseProps {
98106
title: string
99107
body: string | React.ReactElement
100108
variant: 'empty' | 'error' | 'warning'
109+
icon?: React.ComponentType<IconProps>
110+
action?: React.ReactElement
101111
}
102112
/**
103113
* @deprecated Use `secondaryAction` instead.
@@ -669,7 +679,7 @@ function Panel({
669679
return DefaultEmptyMessage
670680
} else if (message) {
671681
return (
672-
<SelectPanelMessage title={message.title} variant={message.variant}>
682+
<SelectPanelMessage title={message.title} variant={message.variant} icon={message.icon} action={message.action}>
673683
{message.body}
674684
</SelectPanelMessage>
675685
)
Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type React from 'react'
22
import Text from '../Text'
33
import Octicon from '../Octicon'
4-
import {AlertIcon} from '@primer/octicons-react'
4+
import {AlertIcon, type IconProps} from '@primer/octicons-react'
55
import classes from './SelectPanel.module.css'
66
import {clsx} from 'clsx'
77

@@ -10,14 +10,36 @@ export type SelectPanelMessageProps = {
1010
title: string
1111
variant: 'empty' | 'error' | 'warning'
1212
className?: string
13+
/**
14+
* Custom icon to display above the title.
15+
* When not provided, uses AlertIcon for error/warning states and no icon for empty state.
16+
*/
17+
icon?: React.ComponentType<IconProps>
18+
/**
19+
* Custom action element to display below the message body.
20+
* This can be used to render interactive elements like buttons or links.
21+
* Ensure the action element is accessible by providing appropriate ARIA attributes
22+
* and making it keyboard-navigable.
23+
*/
24+
action?: React.ReactElement
1325
}
1426

15-
export const SelectPanelMessage: React.FC<SelectPanelMessageProps> = ({variant, title, children, className}) => {
27+
export const SelectPanelMessage: React.FC<SelectPanelMessageProps> = ({
28+
variant,
29+
title,
30+
children,
31+
className,
32+
icon: CustomIcon,
33+
action,
34+
}) => {
35+
const IconComponent = CustomIcon || (variant !== 'empty' ? AlertIcon : undefined)
36+
1637
return (
1738
<div className={clsx(classes.Message, className)}>
18-
{variant !== 'empty' ? <Octicon icon={AlertIcon} className={classes.MessageIcon} data-variant={variant} /> : null}
39+
{IconComponent && <Octicon icon={IconComponent} className={classes.MessageIcon} data-variant={variant} />}
1940
<Text className={classes.MessageTitle}>{title}</Text>
2041
<Text className={classes.MessageBody}>{children}</Text>
42+
{action && <div className={classes.MessageAction}>{action}</div>}
2143
</div>
2244
)
2345
}

0 commit comments

Comments
 (0)