Skip to content

Add Modal variant with declarative buttons to PRC SelectPanel #5883

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 27 commits into from
Apr 16, 2025
Merged
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
5362fa2
Squash every previous commit and rebase
hectahertz Apr 15, 2025
6963c2a
Don't sort items on the stories
hectahertz Apr 15, 2025
5d26562
Last minute fixes
hectahertz Apr 15, 2025
a8f11e2
Fix unused class
hectahertz Apr 15, 2025
ad34b44
test(SelectPanel): add modal tests
francinelucca Apr 15, 2025
1d5e5fd
Merge branch 'main' into hectahertz/selectpanel-modal
francinelucca Apr 15, 2025
becb424
Merge branch 'main' into hectahertz/selectpanel-modal
francinelucca Apr 16, 2025
6a68d2d
make onCancel prop required for modal variant, add negative tabIndex …
francinelucca Apr 16, 2025
535f251
Rename select modal test cases
francinelucca Apr 16, 2025
f838dd0
test(vrt): update snapshots
francinelucca Apr 16, 2025
3038838
remove outdated snapshots
francinelucca Apr 16, 2025
cdef934
Merge branch 'hectahertz/selectpanel-modal' of github.com:primer/reac…
francinelucca Apr 16, 2025
882c765
Make the Save button full width when there is no cancel button
hectahertz Apr 16, 2025
4f831ea
Make "All variants" story a dev story
hectahertz Apr 16, 2025
1cc777b
Fix Radio styling for StyledComponents
hectahertz Apr 16, 2025
a7d2f47
test(vrt): update snapshots
hectahertz Apr 16, 2025
20a2719
Update packages/react/src/SelectPanel/SelectPanel.tsx
hectahertz Apr 16, 2025
ce9a435
Restore stories labels
hectahertz Apr 16, 2025
55a6ab8
Fix syntax error
hectahertz Apr 16, 2025
1f4afda
test(vrt): update snapshots
hectahertz Apr 16, 2025
eb5a60c
Merge branch 'main' into hectahertz/selectpanel-modal
francinelucca Apr 16, 2025
b4ba814
sx fix
francinelucca Apr 16, 2025
d8c7fb5
onClose tweak
francinelucca Apr 16, 2025
66dc942
test(vrt): update snapshots
francinelucca Apr 16, 2025
f2266d5
single select modal: allow for deselection
francinelucca Apr 16, 2025
793dba8
Merge branch 'hectahertz/selectpanel-modal' of github.com:primer/reac…
francinelucca Apr 16, 2025
738cb3c
lint fix
francinelucca Apr 16, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/selectpanel-modal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/react": minor
---

SelectPanel: Add variant="modal"
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions e2e/components/SelectPanel.test.ts
Original file line number Diff line number Diff line change
@@ -15,6 +15,8 @@ const scenarios = matrix({
{id: 'components-selectpanel-features--with-item-dividers', name: 'With Item Dividers'},
{id: 'components-selectpanel-features--with-label-internally', name: 'With Label Internally'},
{id: 'components-selectpanel-features--with-label-visually-hidden', name: 'With Label Visually Hidden'},
{id: 'components-selectpanel-features--multi-select-modal', name: 'Multi Select Modal'},
{id: 'components-selectpanel-features--single-select-modal', name: 'Single Select Modal'},
{
id: 'components-selectpanel-features--with-placeholder-for-search-input',
name: 'With Placeholder for Search Input',
10 changes: 10 additions & 0 deletions packages/react/src/ActionList/Selection.tsx
Original file line number Diff line number Diff line change
@@ -8,6 +8,7 @@ import Box from '../Box'
import {useFeatureFlag} from '../FeatureFlags'
import classes from './ActionList.module.css'
import {actionListCssModulesFlag} from './featureflag'
import Radio from '../Radio'

type SelectionProps = Pick<ActionListItemProps, 'selected' | 'className'>
export const Selection: React.FC<React.PropsWithChildren<SelectionProps>> = ({selected, className}) => {
@@ -34,6 +35,15 @@ export const Selection: React.FC<React.PropsWithChildren<SelectionProps>> = ({se
}
}

if (selectionVariant === 'radio') {
return (
<VisualContainer className={className} sx={enabled ? undefined : {marginRight: '8px'}}>
{/* This is just a way to get the visuals from Radio, but it should be ignored in terms of accessibility */}
<Radio value="unused" checked={selected} aria-hidden tabIndex={-1} />
</VisualContainer>
)
}

if (selectionVariant === 'single' || listRole === 'menu') {
if (enabled) {
return (
2 changes: 1 addition & 1 deletion packages/react/src/ActionList/shared.ts
Original file line number Diff line number Diff line change
@@ -124,7 +124,7 @@ export type ActionListProps = React.PropsWithChildren<{
/**
* Whether multiple Items or a single Item can be selected.
*/
selectionVariant?: 'single' | 'multiple'
selectionVariant?: 'single' | 'radio' | 'multiple'
/**
* Display a divider above each `Item` in this `List` when it does not follow a `Header` or `Divider`.
*/
1 change: 1 addition & 0 deletions packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx
Original file line number Diff line number Diff line change
@@ -216,6 +216,7 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr
width={width}
top={currentResponsiveVariant === 'anchored' ? position?.top || 0 : undefined}
left={currentResponsiveVariant === 'anchored' ? position?.left || 0 : undefined}
responsiveVariant={variant.narrow === 'fullscreen' ? 'fullscreen' : undefined}
data-variant={currentResponsiveVariant}
anchorSide={position?.anchorSide}
className={className}
7 changes: 7 additions & 0 deletions packages/react/src/Overlay/Overlay.docs.json
Original file line number Diff line number Diff line change
@@ -143,6 +143,13 @@
{
"name": "sx",
"type": "SystemStyleObject"
},
{
"name": "responsiveVariant",
"type": "'fullscreen'",
"required": false,
"description": "Optional prop to set responsive variant for narrow screen sizes",
"defaultValue": ""
}
],
"subcomponents": []
18 changes: 10 additions & 8 deletions packages/react/src/Overlay/Overlay.module.css
Original file line number Diff line number Diff line change
@@ -157,14 +157,16 @@
visibility: hidden;
}

&:where([data-variant='fullscreen']) {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
margin: 0;
border-radius: unset;
&:where([data-responsive='fullscreen']) {
@media screen and (--viewportRange-narrow) {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
margin: 0;
border-radius: unset;
}
}

@supports (height: 100dvh) {
21 changes: 13 additions & 8 deletions packages/react/src/Overlay/Overlay.tsx
Original file line number Diff line number Diff line change
@@ -103,14 +103,16 @@ const StyledOverlay = toggleStyledComponent(
max-width: calc(100vw - 2rem);
}

&:where([data-variant='fullscreen']) {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
margin: 0;
border-radius: unset;
&:where([data-responsive='fullscreen']) {
@media screen and (max-width: calc(768px - 0.02px)) {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
margin: 0;
border-radius: unset;
}
}

${sx};
@@ -128,6 +130,7 @@ type BaseOverlayProps = {
role?: AriaRole
children?: React.ReactNode
className?: string
responsiveVariant?: 'fullscreen' // we only support fullscreen today but we might add bottomsheet in the future
}

type OwnOverlayProps = Merge<StyledOverlayProps, BaseOverlayProps>
@@ -266,6 +269,7 @@ const Overlay = React.forwardRef<HTMLDivElement, internalOverlayProps>(
role = 'none',
visibility = 'visible',
width = 'auto',
responsiveVariant,
...props
},
forwardedRef,
@@ -323,6 +327,7 @@ const Overlay = React.forwardRef<HTMLDivElement, internalOverlayProps>(
right={right}
height={height}
visibility={visibility}
data-responsive={responsiveVariant}
{...props}
/>
</Portal>
4 changes: 3 additions & 1 deletion packages/react/src/Radio/Radio.tsx
Original file line number Diff line number Diff line change
@@ -51,6 +51,7 @@ const Radio = React.forwardRef<HTMLInputElement, RadioProps>(
required,
value,
className,
'aria-hidden': ariaHidden = false,
...rest
}: RadioProps,
ref,
@@ -62,7 +63,7 @@ const Radio = React.forwardRef<HTMLInputElement, RadioProps>(
}
const name = nameProp || radioGroupContext?.name

if (!name) {
if (!name && !ariaHidden) {
// eslint-disable-next-line no-console
console.warn(
'A radio input must have a `name` attribute. Pass `name` as a prop directly to each Radio, or nest them in a `RadioGroup` component with a `name` prop',
@@ -84,6 +85,7 @@ const Radio = React.forwardRef<HTMLInputElement, RadioProps>(
required={required}
onChange={handleOnChange}
className={clsx(className, sharedClasses.Input, classes.Radio)}
aria-hidden={ariaHidden}
{...rest}
/>
)
44 changes: 44 additions & 0 deletions packages/react/src/SelectPanel/SelectPanel.dev.stories.tsx
Original file line number Diff line number Diff line change
@@ -7,6 +7,8 @@ import {Button} from '../Button'
import {SelectPanel} from '.'
import type {ItemInput} from '../deprecated/ActionList/List'
import FormControl from '../FormControl'
import Text from '../Text'
import {MultiSelectModal, SingleSelect, SingleSelectModal, WithOnCancel} from './SelectPanel.features.stories'

const meta: Meta<typeof SelectPanel> = {
title: 'Components/SelectPanel/Dev',
@@ -187,3 +189,45 @@ export const WithSxAndCSS = () => {
</FormControl>
)
}

export const AllVariants = () => {
return (
<>
<Text fontSize={3} fontWeight="bold">
Showcase of all the SelectPanel variants
</Text>
<br />
<Text>
Test the different interactions below to see how the SelectPanel behaves in different selection and anchoring
modes. The size of the screen also affects how the user interacts with the SelectPanel.
</Text>
<br />
<br />

<Text fontWeight="bold">Single Select Panel</Text>
<br />
<Text>This panel allows selecting a single item from the list.</Text>
<SingleSelect />
<br />

<Text fontWeight="bold">Single Select Modal</Text>
<br />
<Text>This modal allows selecting a single item with a modal interface.</Text>
<SingleSelectModal />
<br />

<Text fontWeight="bold">Multi Select Panel</Text>
<br />
<Text>This panel allows selecting multiple items from the list.</Text>
<WithOnCancel />
<br />

<Text fontWeight="bold">Multi Select Modal</Text>
<Text>
<br />
This modal allows selecting multiple items with a modal interface.
</Text>
<MultiSelectModal />
</>
)
}
8 changes: 7 additions & 1 deletion packages/react/src/SelectPanel/SelectPanel.docs.json
Original file line number Diff line number Diff line change
@@ -130,7 +130,7 @@
{
"name": "onCancel",
"type": "() => void",
"description": "(Narrow screens) Callback when the user hits cancel or close",
"description": "(Narrow screens and variant=modal) Callback when the user hits cancel or close",
"defaultValue": ""
},
{
@@ -145,6 +145,12 @@
"defaultValue": "",
"description": "See [TextInput props](/react/TextInput#props)."
},
{
"name": "variant",
"type": "'anchored' | 'modal'",
"description": "Anchored by default, SelectPanel can be opened as a modal",
"defaultValue": "'anchored'"
},
{
"name": "footer",
"type": "string | React.ReactElement",
97 changes: 70 additions & 27 deletions packages/react/src/SelectPanel/SelectPanel.features.stories.tsx
Original file line number Diff line number Diff line change
@@ -167,12 +167,6 @@ export const SingleSelect = () => {
const [selected, setSelected] = useState<ItemInput | undefined>(items[0])
const [filter, setFilter] = useState('')
const filteredItems = items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase()))
// design guidelines say to sort selected items first
const selectedItemsSortedFirst = filteredItems.sort((a, b) => {
if (a.text === selected?.text) return -1
if (b.text === selected?.text) return 1
return 0
})
const [open, setOpen] = useState(false)

return (
@@ -187,12 +181,12 @@ export const SingleSelect = () => {
placeholder="Select labels" // button text when no items are selected
open={open}
onOpenChange={setOpen}
items={selectedItemsSortedFirst}
items={filteredItems}
selected={selected}
onSelectedChange={setSelected}
onFilterChange={setFilter}
width="medium"
message={selectedItemsSortedFirst.length === 0 ? NoResultsMessage(filter) : undefined}
message={filteredItems.length === 0 ? NoResultsMessage(filter) : undefined}
/>
</FormControl>
)
@@ -202,14 +196,6 @@ export const MultiSelect = () => {
const [selected, setSelected] = useState<ItemInput[]>(items.slice(1, 3))
const [filter, setFilter] = useState('')
const filteredItems = items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase()))
// design guidelines say to sort selected items first
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
if (!aIsSelected && bIsSelected) return 1
return 0
})
const [open, setOpen] = useState(false)

return (
@@ -226,12 +212,12 @@ export const MultiSelect = () => {
)}
open={open}
onOpenChange={setOpen}
items={selectedItemsSortedFirst}
items={filteredItems}
selected={selected}
onSelectedChange={setSelected}
onFilterChange={setFilter}
width="medium"
message={selectedItemsSortedFirst.length === 0 ? NoResultsMessage(filter) : undefined}
message={filteredItems.length === 0 ? NoResultsMessage(filter) : undefined}
/>
</FormControl>
)
@@ -756,14 +742,6 @@ export const WithOnCancel = () => {
const [selected, setSelected] = React.useState<ItemInput[]>(intialSelection)
const [filter, setFilter] = React.useState('')
const filteredItems = items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase()))
// design guidelines say to sort selected items first
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
if (!aIsSelected && bIsSelected) return 1
return 0
})

const [open, setOpen] = useState(false)
React.useEffect(() => {
@@ -784,7 +762,7 @@ export const WithOnCancel = () => {
)}
open={open}
onOpenChange={setOpen}
items={selectedItemsSortedFirst}
items={filteredItems}
selected={selected}
onSelectedChange={setSelected}
onCancel={() => setSelected(intialSelection)}
@@ -794,3 +772,68 @@ export const WithOnCancel = () => {
</FormControl>
)
}

export const MultiSelectModal = () => {
const [intialSelection, setInitialSelection] = React.useState<ItemInput[]>(items.slice(1, 3))

const [selected, setSelected] = React.useState<ItemInput[]>(intialSelection)
const [filter, setFilter] = React.useState('')
const [open, setOpen] = useState(false)

React.useEffect(() => {
if (!open) setInitialSelection(selected) // Save selection as initialSelection for next time
}, [open, selected])

const filteredItems = items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase()))

return (
<SelectPanel
variant="modal"
title="Select labels"
placeholder="Select labels"
subtitle="Use labels to organize issues and pull requests"
renderAnchor={({children, ...anchorProps}) => (
<Button trailingAction={TriangleDownIcon} {...anchorProps} aria-haspopup="dialog">
{children}
</Button>
)}
open={open}
onOpenChange={setOpen}
items={filteredItems}
selected={selected}
onSelectedChange={setSelected}
onCancel={() => setSelected(intialSelection)}
onFilterChange={setFilter}
width="medium"
/>
)
}

export const SingleSelectModal = () => {
const [selected, setSelected] = useState<ItemInput | undefined>(undefined)
const [filter, setFilter] = useState('')
const [open, setOpen] = useState(false)
const filteredItems = items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase()))

return (
<SelectPanel
variant="modal"
title="Select labels"
placeholder="Select labels"
subtitle="Use labels to organize issues and pull requests"
renderAnchor={({children, ...anchorProps}) => (
<Button trailingAction={TriangleDownIcon} {...anchorProps} aria-haspopup="dialog">
{children}
</Button>
)}
open={open}
onOpenChange={setOpen}
items={filteredItems}
selected={selected}
onSelectedChange={setSelected}
onCancel={() => {}}
onFilterChange={setFilter}
width="medium"
/>
)
}
Loading