diff --git a/.changeset/selectpanel-modal.md b/.changeset/selectpanel-modal.md new file mode 100644 index 00000000000..3fda37fdd3b --- /dev/null +++ b/.changeset/selectpanel-modal.md @@ -0,0 +1,5 @@ +--- +"@primer/react": minor +--- + +SelectPanel: Add variant="modal" diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-dark-colorblind-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-dark-colorblind-linux.png new file mode 100644 index 00000000000..05b179ed8b0 Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-dark-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-dark-colorblind-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-dark-colorblind-modern-action-list--true-linux.png new file mode 100644 index 00000000000..c848c770b13 Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-dark-colorblind-modern-action-list--true-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-dark-dimmed-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-dark-dimmed-linux.png new file mode 100644 index 00000000000..79c5f20d69f Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-dark-dimmed-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-dark-dimmed-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-dark-dimmed-modern-action-list--true-linux.png new file mode 100644 index 00000000000..5b1d94b373c Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-dark-dimmed-modern-action-list--true-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-dark-high-contrast-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-dark-high-contrast-linux.png new file mode 100644 index 00000000000..c27c45f41bd Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-dark-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-dark-high-contrast-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-dark-high-contrast-modern-action-list--true-linux.png new file mode 100644 index 00000000000..4c063aaea1c Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-dark-high-contrast-modern-action-list--true-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-dark-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-dark-linux.png new file mode 100644 index 00000000000..3a6990fd7e0 Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-dark-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-dark-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-dark-modern-action-list--true-linux.png new file mode 100644 index 00000000000..381685b6bb3 Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-dark-modern-action-list--true-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-dark-tritanopia-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-dark-tritanopia-linux.png new file mode 100644 index 00000000000..05b179ed8b0 Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-dark-tritanopia-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-dark-tritanopia-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-dark-tritanopia-modern-action-list--true-linux.png new file mode 100644 index 00000000000..72aafedde53 Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-dark-tritanopia-modern-action-list--true-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-light-colorblind-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-light-colorblind-linux.png new file mode 100644 index 00000000000..90acb8c943f Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-light-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-light-colorblind-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-light-colorblind-modern-action-list--true-linux.png new file mode 100644 index 00000000000..d39ebd8a625 Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-light-colorblind-modern-action-list--true-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-light-high-contrast-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-light-high-contrast-linux.png new file mode 100644 index 00000000000..77c8f213472 Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-light-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-light-high-contrast-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-light-high-contrast-modern-action-list--true-linux.png new file mode 100644 index 00000000000..4a8c01ff240 Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-light-high-contrast-modern-action-list--true-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-light-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-light-linux.png new file mode 100644 index 00000000000..273e98f76b3 Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-light-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-light-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-light-modern-action-list--true-linux.png new file mode 100644 index 00000000000..a467b022c14 Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-light-modern-action-list--true-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-light-tritanopia-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-light-tritanopia-linux.png new file mode 100644 index 00000000000..90acb8c943f Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-light-tritanopia-linux.png differ diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-light-tritanopia-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-light-tritanopia-modern-action-list--true-linux.png new file mode 100644 index 00000000000..d39ebd8a625 Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-As-Modal-light-tritanopia-modern-action-list--true-linux.png differ 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..147229162f0 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/.playwright/snapshots/components/drafts/SelectPanel.test.ts-snapshots/drafts-SelectPanel-With-Warning-dark-high-contrast-linux.png b/.playwright/snapshots/components/drafts/SelectPanel.test.ts-snapshots/drafts-SelectPanel-With-Warning-dark-high-contrast-linux.png index 71367098b97..fd34e444ee1 100644 Binary files a/.playwright/snapshots/components/drafts/SelectPanel.test.ts-snapshots/drafts-SelectPanel-With-Warning-dark-high-contrast-linux.png and b/.playwright/snapshots/components/drafts/SelectPanel.test.ts-snapshots/drafts-SelectPanel-With-Warning-dark-high-contrast-linux.png differ diff --git a/packages/react/src/ActionList/ActionList.module.css b/packages/react/src/ActionList/ActionList.module.css index 5e1d537708f..340460069ef 100644 --- a/packages/react/src/ActionList/ActionList.module.css +++ b/packages/react/src/ActionList/ActionList.module.css @@ -409,6 +409,10 @@ visibility: hidden; } +.SingleSelectRadio { + margin-right: var(--base-size-8); +} + /* button or a tag */ /* [ [spacer] [leadingAction] [leadingVisual] [content] ] */ diff --git a/packages/react/src/ActionList/Selection.tsx b/packages/react/src/ActionList/Selection.tsx index 1e4d11a2b37..6c8ff21ee6b 100644 --- a/packages/react/src/ActionList/Selection.tsx +++ b/packages/react/src/ActionList/Selection.tsx @@ -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 export const Selection: React.FC> = ({selected, className}) => { @@ -34,6 +35,17 @@ export const Selection: React.FC> = ({se } } + if (selectionVariant === 'radio') { + return ( + + {/* This is just a way to get the visuals from Radio, but it should be ignored in terms of accessibility */} + + + ) + } + if (selectionVariant === 'single' || listRole === 'menu') { if (enabled) { return ( diff --git a/packages/react/src/ActionList/shared.ts b/packages/react/src/ActionList/shared.ts index bae0b66c2a9..f3fd0cbb5a3 100644 --- a/packages/react/src/ActionList/shared.ts +++ b/packages/react/src/ActionList/shared.ts @@ -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`. */ diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index cb4de3af3a3..128eefc6af7 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -216,7 +216,7 @@ export const AnchoredOverlay: React.FC @@ -265,6 +268,7 @@ const Overlay = React.forwardRef( role = 'none', visibility = 'visible', width = 'auto', + responsiveVariant, ...props }, forwardedRef, @@ -322,6 +326,7 @@ const Overlay = React.forwardRef( right={right} height={height} visibility={visibility} + data-responsive={responsiveVariant} {...props} /> diff --git a/packages/react/src/SelectPanel/SelectPanel.docs.json b/packages/react/src/SelectPanel/SelectPanel.docs.json index 9bd5aea0acb..ec8bdcd7649 100644 --- a/packages/react/src/SelectPanel/SelectPanel.docs.json +++ b/packages/react/src/SelectPanel/SelectPanel.docs.json @@ -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", diff --git a/packages/react/src/SelectPanel/SelectPanel.features.stories.tsx b/packages/react/src/SelectPanel/SelectPanel.features.stories.tsx index c6c6cedb2d8..d670bcfbcd6 100644 --- a/packages/react/src/SelectPanel/SelectPanel.features.stories.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.features.stories.tsx @@ -857,3 +857,80 @@ export const WithOnCancel = () => { ) } + +export const AsMultiSelectModal = () => { + 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 ( + ( + + )} + open={open} + onOpenChange={setOpen} + items={selectedItemsSortedFirst} + selected={selected} + onSelectedChange={setSelected} + onCancel={() => setSelected(intialSelection)} + onFilterChange={setFilter} + width="medium" + /> + ) +} + +export const AsSingleSelectModal = () => { + const [selected, setSelected] = useState(undefined) + const [filter, setFilter] = useState('') + const [open, setOpen] = useState(false) + const filteredItems = items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase())) + + return ( + ( + + )} + open={open} + onOpenChange={setOpen} + items={filteredItems} + selected={selected} + onSelectedChange={setSelected} + onFilterChange={setFilter} + width="medium" + /> + ) +} diff --git a/packages/react/src/SelectPanel/SelectPanel.module.css b/packages/react/src/SelectPanel/SelectPanel.module.css index e737e17f13b..e3ff1221d0c 100644 --- a/packages/react/src/SelectPanel/SelectPanel.module.css +++ b/packages/react/src/SelectPanel/SelectPanel.module.css @@ -13,7 +13,7 @@ .Header { display: flex; justify-content: space-between; - align-items: center; + align-items: flex-start; padding-top: var(--base-size-8); padding-right: var(--base-size-8); padding-left: var(--base-size-8); @@ -124,15 +124,29 @@ @media screen and (--viewportRange-narrow) { display: inline-grid; } + + &:where([data-variant='modal'] &) { + display: inline-grid; + } } .ResponsiveFooter { display: none; padding: var(--base-size-16); + gap: var(--stack-gap-condensed); + justify-content: right; @media screen and (--viewportRange-narrow) { display: flex; - gap: var(--stack-gap-condensed); - justify-content: right; } + + &:where([data-variant='modal'] &) { + display: flex; + } +} + +.Backdrop { + position: absolute; + inset: 0; + background-color: var(--overlay-backdrop-bgColor); } diff --git a/packages/react/src/SelectPanel/SelectPanel.tsx b/packages/react/src/SelectPanel/SelectPanel.tsx index 5a223498d7d..5f7a6762cb9 100644 --- a/packages/react/src/SelectPanel/SelectPanel.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.tsx @@ -93,10 +93,11 @@ interface SelectPanelBaseProps { variant: 'empty' | 'error' | 'warning' } onCancel?: () => void + variant?: 'anchored' | 'modal' } export type SelectPanelProps = SelectPanelBaseProps & - Omit & + Omit & Pick & AnchoredOverlayWrapperAnchorProps & (SelectPanelSingleSelection | SelectPanelMultiSelection) @@ -157,6 +158,7 @@ export function SelectPanel({ message, notice, onCancel, + variant = 'anchored', ...listProps }: SelectPanelProps): JSX.Element { const titleId = useId() @@ -172,6 +174,13 @@ export function SelectPanel({ const [listContainerElement, setListContainerElement] = useState(null) const [needsNoItemsAnnouncement, setNeedsNoItemsAnnouncement] = useState(false) + // Single select modals work differently, they have an intermediate state where the user has selected an item but + // has not yet confirmed the selection. This is the only time the user can cancel the selection. + const isSingleSelectModal = variant === 'modal' && !isMultiSelectVariant(selected) + const [intermediateSelected, setIntermediateSelected] = useState( + isSingleSelectModal ? selected : undefined, + ) + const onListContainerRefChanged: FilteredActionListProps['onListContainerRefChanged'] = useCallback( (node: HTMLElement | null) => { setListContainerElement(node) @@ -308,9 +317,10 @@ export function SelectPanel({ ) const onClose = useCallback( (gesture: Parameters>[0] | 'selection' | 'escape') => { + if (variant === 'modal' && gesture === 'click-outside') onCancel?.() onOpenChange(false, gesture) }, - [onOpenChange], + [onOpenChange, variant, onCancel], ) const renderMenuAnchor = useMemo(() => { @@ -330,7 +340,11 @@ export function SelectPanel({ const itemsToRender = useMemo(() => { return items.map(item => { - const isItemSelected = isMultiSelectVariant(selected) ? doesItemsIncludeItem(selected, item) : selected === item + const isItemSelected = isMultiSelectVariant(selected) + ? doesItemsIncludeItem(selected, item) + : isSingleSelectModal + ? intermediateSelected?.id === item.id + : selected?.id === item.id return { ...item, @@ -354,14 +368,18 @@ export function SelectPanel({ return } - // single select + if (isSingleSelectModal) { + setIntermediateSelected(item) + return + } + // single select anchored, direct save on click const singleSelectOnChange = onSelectedChange as SelectPanelSingleSelection['onSelectedChange'] singleSelectOnChange(item === selected ? undefined : item) onClose('selection') }, } as ItemProps }) - }, [onClose, onSelectedChange, items, selected]) + }, [onClose, onSelectedChange, items, selected, intermediateSelected]) const focusTrapSettings = { initialFocusRef: inputRef || undefined, @@ -410,160 +428,186 @@ export function SelectPanel({ } } + // We add a save and cancel button on narrow screens when SelectPanel is full-screen + // Save and Cancel buttons are only useful for multiple selection, single selection instantly closes the panel + const showCancelSaveButtons = variant === 'modal' || (isMultiSelectVariant(selected) && usingFullScreenOnNarrow) + const showXCloseIcon = variant === 'modal' || usingFullScreenOnNarrow + return ( - - + -
- - {title} - - {subtitle ? ( - - {subtitle} - - ) : null} -
- {onCancel && usingFullScreenOnNarrow && ( - { - onCancel() - onClose('escape') - }} - /> - )} -
- {notice && ( -
- {iconForNoticeVariant[notice.variant]} -
{notice.text}
-
- )} - - {footer ? ( - {footer} +
+ + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} +
+ {showXCloseIcon && ( + { + onCancel && onCancel() + onClose('escape') + }} + /> + )}
- ) : isMultiSelectVariant(selected) && usingFullScreenOnNarrow ? ( - /* 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 && ( + {notice && ( +
+ {iconForNoticeVariant[notice.variant]} +
{notice.text}
+
+ )} + + {footer ? ( + + {footer} + + ) : showCancelSaveButtons ? ( +
- )} - -
- ) : null} - - + +
+ ) : null} +
+
+ {variant === 'modal' && open ?
: null} + ) } diff --git a/packages/react/src/__tests__/__snapshots__/AnchoredOverlay.test.tsx.snap b/packages/react/src/__tests__/__snapshots__/AnchoredOverlay.test.tsx.snap index ce4d66ef15a..eddd6d46b6e 100644 --- a/packages/react/src/__tests__/__snapshots__/AnchoredOverlay.test.tsx.snap +++ b/packages/react/src/__tests__/__snapshots__/AnchoredOverlay.test.tsx.snap @@ -30,21 +30,23 @@ exports[`AnchoredOverlay should render consistently when open 1`] = ` max-width: calc(100vw - 2rem); } -.c1:where([data-variant='fullscreen']) { - top: 0; - left: 0; - width: 100vw; - height: 100vh; - margin: 0; - border-radius: unset; -} - @media (forced-colors:active) { .c1 { outline: solid 1px transparent; } } +@media screen and (max-width:calc(768px - 0.02px)) { + .c1:where([data-responsive='fullscreen']) { + top: 0; + left: 0; + width: 100vw; + height: 100vh; + margin: 0; + border-radius: unset; + } +} +