Skip to content

Commit 94bc6e4

Browse files
authored
SelectPanel: Make panel full screen on narrow screens with Save and optional Cancel button (#5761)
Co-authored-by: siddharthkp <[email protected]>
1 parent edc824a commit 94bc6e4

File tree

6 files changed

+169
-26
lines changed

6 files changed

+169
-26
lines changed

.changeset/responsive-selectpanel.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@primer/react": patch
3+
---
4+
5+
SelectPanel: Make SelectPanel full screen on narrow devices with a Save button

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,12 @@
127127
"description": "Callback when the search input changes",
128128
"defaultValue": ""
129129
},
130+
{
131+
"name": "onCancel",
132+
"type": "() => void",
133+
"description": "(Narrow screens) Callback when the user hits cancel or close",
134+
"defaultValue": ""
135+
},
130136
{
131137
"name": "overlayProps",
132138
"type": "Partial<OverlayProps>",

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

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ export const SingleSelect = () => {
171171
selected={selected}
172172
onSelectedChange={setSelected}
173173
onFilterChange={setFilter}
174+
onCancel={() => setOpen(false)}
174175
width="medium"
175176
/>
176177
</FormControl>
@@ -560,3 +561,54 @@ export const AsyncFetch: StoryObj<SelectPanelProps> = {
560561
},
561562
},
562563
}
564+
565+
export const WithOnCancel = () => {
566+
const [intialSelection, setInitialSelection] = React.useState<ItemInput[]>(items.slice(1, 3))
567+
568+
const [selected, setSelected] = React.useState<ItemInput[]>(intialSelection)
569+
const [filter, setFilter] = React.useState('')
570+
const filteredItems = items.filter(
571+
item =>
572+
// design guidelines say to always show selected items in the list
573+
selected.some(selectedItem => selectedItem.text === item.text) ||
574+
// then filter the rest
575+
item.text.toLowerCase().startsWith(filter.toLowerCase()),
576+
)
577+
// design guidelines say to sort selected items first
578+
const selectedItemsSortedFirst = filteredItems.sort((a, b) => {
579+
const aIsSelected = selected.some(selectedItem => selectedItem.text === a.text)
580+
const bIsSelected = selected.some(selectedItem => selectedItem.text === b.text)
581+
if (aIsSelected && !bIsSelected) return -1
582+
if (!aIsSelected && bIsSelected) return 1
583+
return 0
584+
})
585+
586+
const [open, setOpen] = useState(false)
587+
React.useEffect(() => {
588+
if (!open) setInitialSelection(selected) // set initialSelection for next time
589+
}, [open, selected])
590+
591+
return (
592+
<FormControl>
593+
<FormControl.Label>Labels</FormControl.Label>
594+
<SelectPanel
595+
title="Select labels"
596+
placeholder="Select labels"
597+
subtitle="Use labels to organize issues and pull requests"
598+
renderAnchor={({children, ...anchorProps}) => (
599+
<Button trailingAction={TriangleDownIcon} {...anchorProps} aria-haspopup="dialog">
600+
{children}
601+
</Button>
602+
)}
603+
open={open}
604+
onOpenChange={setOpen}
605+
items={selectedItemsSortedFirst}
606+
selected={selected}
607+
onSelectedChange={setSelected}
608+
onCancel={() => setSelected(intialSelection)}
609+
onFilterChange={setFilter}
610+
width="medium"
611+
/>
612+
</FormControl>
613+
)
614+
}

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

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,22 @@
55
flex-direction: column;
66
}
77

8-
.Content {
8+
.Header {
9+
display: flex;
10+
justify-content: space-between;
11+
align-items: center;
912
padding-top: var(--base-size-8);
10-
padding-right: var(--base-size-16);
11-
padding-left: var(--base-size-16);
13+
padding-right: var(--base-size-8);
14+
padding-left: var(--base-size-8);
1215
}
1316

1417
.Title {
18+
margin-left: var(--base-size-8);
1519
font-size: var(--text-body-size-medium);
1620
}
1721

1822
.Subtitle {
23+
margin-left: var(--base-size-8);
1924
font-size: var(--text-body-size-small);
2025
color: var(--fgColor-muted);
2126
}
@@ -31,3 +36,22 @@
3136
height: inherit;
3237
max-height: inherit;
3338
}
39+
40+
.ResponsiveCloseButton {
41+
display: none;
42+
43+
@media screen and (--viewportRange-narrow) {
44+
display: inline-grid;
45+
}
46+
}
47+
48+
.ResponsiveFooter {
49+
display: none;
50+
padding: var(--base-size-16);
51+
52+
@media screen and (--viewportRange-narrow) {
53+
display: flex;
54+
gap: var(--stack-gap-condensed);
55+
justify-content: right;
56+
}
57+
}

packages/react/src/SelectPanel/SelectPanel.tsx

Lines changed: 79 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {SearchIcon, TriangleDownIcon} from '@primer/octicons-react'
1+
import {SearchIcon, TriangleDownIcon, XIcon} from '@primer/octicons-react'
22
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
33
import type {AnchoredOverlayProps} from '../AnchoredOverlay'
44
import {AnchoredOverlay} from '../AnchoredOverlay'
@@ -11,7 +11,7 @@ import type {OverlayProps} from '../Overlay'
1111
import type {TextInputProps} from '../TextInput'
1212
import type {ItemProps, ItemInput} from './types'
1313

14-
import {Button} from '../Button'
14+
import {Button, IconButton} from '../Button'
1515
import {useProvidedRefOrCreate} from '../hooks'
1616
import type {FocusZoneHookSettings} from '../hooks/useFocusZone'
1717
import {useId} from '../hooks/useId'
@@ -22,7 +22,6 @@ import type {FilteredActionListLoadingType} from '../FilteredActionList/Filtered
2222
import {FilteredActionListLoadingTypes} from '../FilteredActionList/FilteredActionListLoaders'
2323
import {useFeatureFlag} from '../FeatureFlags'
2424
import {announce} from '@primer/live-region-element'
25-
2625
import classes from './SelectPanel.module.css'
2726
import {clsx} from 'clsx'
2827

@@ -128,6 +127,7 @@ interface SelectPanelBaseProps {
128127
footer?: string | React.ReactElement
129128
initialLoadingType?: InitialLoadingType
130129
className?: string
130+
onCancel?: () => void
131131
}
132132

133133
export type SelectPanelProps = SelectPanelBaseProps &
@@ -189,6 +189,7 @@ export function SelectPanel({
189189
height,
190190
width,
191191
id,
192+
onCancel,
192193
...listProps
193194
}: SelectPanelProps): JSX.Element {
194195
const titleId = useId()
@@ -352,7 +353,7 @@ export function SelectPanel({
352353
[onOpenChange],
353354
)
354355
const onClose = useCallback(
355-
(gesture: Parameters<Exclude<AnchoredOverlayProps['onClose'], undefined>>[0] | 'selection') => {
356+
(gesture: Parameters<Exclude<AnchoredOverlayProps['onClose'], undefined>>[0] | 'selection' | 'escape') => {
356357
onOpenChange(false, gesture)
357358
},
358359
[onOpenChange],
@@ -454,6 +455,7 @@ export function SelectPanel({
454455
height={height}
455456
width={width}
456457
anchorId={id}
458+
variant={{regular: 'anchored', narrow: 'fullscreen'}}
457459
pinPosition={!height}
458460
>
459461
<LiveRegionOutlet />
@@ -472,24 +474,54 @@ export function SelectPanel({
472474
sx={enabled ? undefined : {display: 'flex', flexDirection: 'column', height: 'inherit', maxHeight: 'inherit'}}
473475
className={enabled ? classes.Wrapper : undefined}
474476
>
475-
<Box sx={enabled ? undefined : {pt: 2, px: 3}} className={enabled ? classes.Content : undefined}>
476-
<Heading
477-
as="h1"
478-
id={titleId}
479-
sx={enabled ? undefined : {fontSize: 1}}
480-
className={enabled ? classes.Title : undefined}
481-
>
482-
{title}
483-
</Heading>
484-
{subtitle ? (
485-
<Box
486-
id={subtitleId}
487-
sx={enabled ? undefined : {fontSize: 0, color: 'fg.muted'}}
488-
className={enabled ? classes.Subtitle : undefined}
477+
<Box
478+
sx={
479+
enabled
480+
? undefined
481+
: {
482+
display: 'flex',
483+
justifyContent: 'space-between',
484+
alignItems: 'center',
485+
paddingTop: 2,
486+
paddingRight: 2,
487+
paddingLeft: 2,
488+
}
489+
}
490+
className={enabled ? classes.Header : undefined}
491+
>
492+
<div>
493+
<Heading
494+
as="h1"
495+
id={titleId}
496+
sx={enabled ? undefined : {fontSize: 1, marginLeft: 2}}
497+
className={enabled ? classes.Title : undefined}
489498
>
490-
{subtitle}
491-
</Box>
492-
) : null}
499+
{title}
500+
</Heading>
501+
{subtitle ? (
502+
<Box
503+
id={subtitleId}
504+
sx={enabled ? undefined : {marginLeft: 2, fontSize: 0, color: 'fg.muted'}}
505+
className={enabled ? classes.Subtitle : undefined}
506+
>
507+
{subtitle}
508+
</Box>
509+
) : null}
510+
</div>
511+
{onCancel && (
512+
<IconButton
513+
type="button"
514+
variant="invisible"
515+
icon={XIcon}
516+
aria-label="Cancel and close"
517+
sx={enabled ? undefined : {display: ['inline-grid', 'inline-grid', 'none', 'none']}}
518+
className={enabled ? classes.ResponsiveCloseButton : undefined}
519+
onClick={() => {
520+
onCancel()
521+
onClose('escape')
522+
}}
523+
/>
524+
)}
493525
</Box>
494526
<FilteredActionList
495527
filterValue={filterValue}
@@ -514,7 +546,7 @@ export function SelectPanel({
514546
className={enabled ? clsx(className, classes.FilteredActionList) : className}
515547
announcementsEnabled={false}
516548
/>
517-
{footer && (
549+
{footer ? (
518550
<Box
519551
sx={
520552
enabled
@@ -530,7 +562,31 @@ export function SelectPanel({
530562
>
531563
{footer}
532564
</Box>
533-
)}
565+
) : isMultiSelectVariant(selected) ? (
566+
/* Save and Cancel buttons are only useful for multiple selection, single selection instantly closes the panel */
567+
<div className={clsx(classes.Footer, classes.ResponsiveFooter)}>
568+
{/* we add a save and cancel button on narrow screens when SelectPanel is full-screen */}
569+
{onCancel && (
570+
<Button
571+
size="medium"
572+
onClick={() => {
573+
onCancel()
574+
onClose('escape')
575+
}}
576+
>
577+
Cancel
578+
</Button>
579+
)}
580+
<Button
581+
variant="primary"
582+
size="medium"
583+
block={onCancel ? false : true}
584+
onClick={() => onClose('click-outside')}
585+
>
586+
Save
587+
</Button>
588+
</div>
589+
) : null}
534590
</Box>
535591
</AnchoredOverlay>
536592
</LiveRegion>

0 commit comments

Comments
 (0)