Skip to content
Merged
Show file tree
Hide file tree
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
Expand Up @@ -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',
Expand Down
10 changes: 10 additions & 0 deletions packages/react/src/ActionList/Selection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}) => {
Expand All @@ -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 (
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/ActionList/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
*/
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
7 changes: 7 additions & 0 deletions packages/react/src/Overlay/Overlay.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": []
Expand Down
18 changes: 10 additions & 8 deletions packages/react/src/Overlay/Overlay.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
21 changes: 13 additions & 8 deletions packages/react/src/Overlay/Overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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>
Expand Down Expand Up @@ -266,6 +269,7 @@ const Overlay = React.forwardRef<HTMLDivElement, internalOverlayProps>(
role = 'none',
visibility = 'visible',
width = 'auto',
responsiveVariant,
...props
},
forwardedRef,
Expand Down Expand Up @@ -323,6 +327,7 @@ const Overlay = React.forwardRef<HTMLDivElement, internalOverlayProps>(
right={right}
height={height}
visibility={visibility}
data-responsive={responsiveVariant}
{...props}
/>
</Portal>
Expand Down
4 changes: 3 additions & 1 deletion packages/react/src/Radio/Radio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const Radio = React.forwardRef<HTMLInputElement, RadioProps>(
required,
value,
className,
'aria-hidden': ariaHidden = false,
...rest
}: RadioProps,
ref,
Expand All @@ -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',
Expand All @@ -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}
/>
)
Expand Down
44 changes: 44 additions & 0 deletions packages/react/src/SelectPanel/SelectPanel.dev.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Up @@ -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": ""
},
{
Expand All @@ -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",
Expand Down
97 changes: 70 additions & 27 deletions packages/react/src/SelectPanel/SelectPanel.features.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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>
)
Expand All @@ -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 (
Expand All @@ -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>
)
Expand Down Expand Up @@ -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(() => {
Expand All @@ -784,7 +762,7 @@ export const WithOnCancel = () => {
)}
open={open}
onOpenChange={setOpen}
items={selectedItemsSortedFirst}
items={filteredItems}
selected={selected}
onSelectedChange={setSelected}
onCancel={() => setSelected(intialSelection)}
Expand All @@ -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
Loading