diff --git a/.changeset/responsive-selectpanel.md b/.changeset/responsive-selectpanel.md new file mode 100644 index 00000000000..85bed4b926a --- /dev/null +++ b/.changeset/responsive-selectpanel.md @@ -0,0 +1,5 @@ +--- +"@primer/react": patch +--- + +SelectPanel: Make SelectPanel full screen on narrow devices with a Save button diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Default-responsive-width-light-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Default-responsive-width-light-modern-action-list--true-linux.png index 1b994c3baf1..95d51676103 100644 Binary files a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Default-responsive-width-light-modern-action-list--true-linux.png and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-Default-responsive-width-light-modern-action-list--true-linux.png differ diff --git a/packages/react/src/SelectPanel/SelectPanel.docs.json b/packages/react/src/SelectPanel/SelectPanel.docs.json index 1a3677f2a6c..d28e8c92ea5 100644 --- a/packages/react/src/SelectPanel/SelectPanel.docs.json +++ b/packages/react/src/SelectPanel/SelectPanel.docs.json @@ -127,6 +127,12 @@ "description": "Callback when the search input changes", "defaultValue": "" }, + { + "name": "onCancel", + "type": "() => void", + "description": "(Narrow screens) Callback when the user hits cancel or close", + "defaultValue": "" + }, { "name": "overlayProps", "type": "Partial", diff --git a/packages/react/src/SelectPanel/SelectPanel.features.stories.tsx b/packages/react/src/SelectPanel/SelectPanel.features.stories.tsx index 1c8e5719a6a..d1dfc3751ce 100644 --- a/packages/react/src/SelectPanel/SelectPanel.features.stories.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.features.stories.tsx @@ -171,6 +171,7 @@ export const SingleSelect = () => { selected={selected} onSelectedChange={setSelected} onFilterChange={setFilter} + onCancel={() => setOpen(false)} width="medium" /> @@ -560,3 +561,54 @@ export const AsyncFetch: StoryObj = { }, }, } + +export const WithOnCancel = () => { + const [intialSelection, setInitialSelection] = React.useState(items.slice(1, 3)) + + const [selected, setSelected] = React.useState(intialSelection) + const [filter, setFilter] = React.useState('') + const filteredItems = items.filter( + item => + // design guidelines say to always show selected items in the list + selected.some(selectedItem => selectedItem.text === item.text) || + // then filter the rest + 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(() => { + if (!open) setInitialSelection(selected) // set initialSelection for next time + }, [open, selected]) + + return ( + + Labels + ( + + )} + open={open} + onOpenChange={setOpen} + items={selectedItemsSortedFirst} + selected={selected} + onSelectedChange={setSelected} + onCancel={() => setSelected(intialSelection)} + onFilterChange={setFilter} + width="medium" + /> + + ) +} diff --git a/packages/react/src/SelectPanel/SelectPanel.module.css b/packages/react/src/SelectPanel/SelectPanel.module.css index e607edba2a1..fef33c5de77 100644 --- a/packages/react/src/SelectPanel/SelectPanel.module.css +++ b/packages/react/src/SelectPanel/SelectPanel.module.css @@ -5,17 +5,22 @@ flex-direction: column; } -.Content { +.Header { + display: flex; + justify-content: space-between; + align-items: center; padding-top: var(--base-size-8); - padding-right: var(--base-size-16); - padding-left: var(--base-size-16); + padding-right: var(--base-size-8); + padding-left: var(--base-size-8); } .Title { + margin-left: var(--base-size-8); font-size: var(--text-body-size-medium); } .Subtitle { + margin-left: var(--base-size-8); font-size: var(--text-body-size-small); color: var(--fgColor-muted); } @@ -31,3 +36,22 @@ height: inherit; max-height: inherit; } + +.ResponsiveCloseButton { + display: none; + + @media screen and (--viewportRange-narrow) { + display: inline-grid; + } +} + +.ResponsiveFooter { + display: none; + padding: var(--base-size-16); + + @media screen and (--viewportRange-narrow) { + display: flex; + gap: var(--stack-gap-condensed); + justify-content: right; + } +} diff --git a/packages/react/src/SelectPanel/SelectPanel.tsx b/packages/react/src/SelectPanel/SelectPanel.tsx index 396bb717309..bc729f2c7ae 100644 --- a/packages/react/src/SelectPanel/SelectPanel.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.tsx @@ -1,4 +1,4 @@ -import {SearchIcon, TriangleDownIcon} from '@primer/octicons-react' +import {SearchIcon, TriangleDownIcon, XIcon} from '@primer/octicons-react' import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react' import type {AnchoredOverlayProps} from '../AnchoredOverlay' import {AnchoredOverlay} from '../AnchoredOverlay' @@ -11,7 +11,7 @@ import type {OverlayProps} from '../Overlay' import type {TextInputProps} from '../TextInput' import type {ItemProps, ItemInput} from './types' -import {Button} from '../Button' +import {Button, IconButton} from '../Button' import {useProvidedRefOrCreate} from '../hooks' import type {FocusZoneHookSettings} from '../hooks/useFocusZone' import {useId} from '../hooks/useId' @@ -22,7 +22,6 @@ import type {FilteredActionListLoadingType} from '../FilteredActionList/Filtered import {FilteredActionListLoadingTypes} from '../FilteredActionList/FilteredActionListLoaders' import {useFeatureFlag} from '../FeatureFlags' import {announce} from '@primer/live-region-element' - import classes from './SelectPanel.module.css' import {clsx} from 'clsx' @@ -128,6 +127,7 @@ interface SelectPanelBaseProps { footer?: string | React.ReactElement initialLoadingType?: InitialLoadingType className?: string + onCancel?: () => void } export type SelectPanelProps = SelectPanelBaseProps & @@ -189,6 +189,7 @@ export function SelectPanel({ height, width, id, + onCancel, ...listProps }: SelectPanelProps): JSX.Element { const titleId = useId() @@ -352,7 +353,7 @@ export function SelectPanel({ [onOpenChange], ) const onClose = useCallback( - (gesture: Parameters>[0] | 'selection') => { + (gesture: Parameters>[0] | 'selection' | 'escape') => { onOpenChange(false, gesture) }, [onOpenChange], @@ -454,6 +455,7 @@ export function SelectPanel({ height={height} width={width} anchorId={id} + variant={{regular: 'anchored', narrow: 'fullscreen'}} pinPosition={!height} > @@ -472,24 +474,54 @@ export function SelectPanel({ sx={enabled ? undefined : {display: 'flex', flexDirection: 'column', height: 'inherit', maxHeight: 'inherit'}} className={enabled ? classes.Wrapper : undefined} > - - - {title} - - {subtitle ? ( - +
+ - {subtitle} - - ) : null} + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} +
+ {onCancel && ( + { + onCancel() + onClose('escape') + }} + /> + )}
- {footer && ( + {footer ? ( {footer} - )} + ) : isMultiSelectVariant(selected) ? ( + /* Save and Cancel buttons are only useful for multiple selection, single selection instantly closes the panel */ +
+ {/* we add a save and cancel button on narrow screens when SelectPanel is full-screen */} + {onCancel && ( + + )} + +
+ ) : null}