Skip to content

Commit

Permalink
SelectPanel2: Responsive variants (#4277)
Browse files Browse the repository at this point in the history
* cleanup: outdated comment

* full screen based on media query

* automated responsive button size

* use ResponsiveVariants

* add responsive story

* add bottom-sheet styles

* Create proud-ears-travel.md

* add matchMedia mock to test-helpers

* Revert "add matchMedia mock to test-helpers"

This reverts commit d71e0f6.

* mock matchMedia for SelectPanel test
  • Loading branch information
siddharthkp committed Mar 19, 2024
1 parent 77bae96 commit 69915d9
Show file tree
Hide file tree
Showing 5 changed files with 249 additions and 17 deletions.
5 changes: 5 additions & 0 deletions .changeset/proud-ears-travel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/react": patch
---

experimental/SelectPanel: Add responsive variants
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import React from 'react'
import {SelectPanel} from './SelectPanel'
import {ActionList, Avatar, Box, Button, Link, Text, ToggleSwitch} from '../../index'
import {TagIcon, GearIcon} from '@primer/octicons-react'
import {
ActionList,
Avatar,
Box,
Button,
Link,
SegmentedControl,
Text,
ToggleSwitch,
useResponsiveValue,
} from '../../index'
import {TagIcon, GearIcon, ArrowBothIcon} from '@primer/octicons-react'
import data from './mock-story-data'

export default {
Expand Down Expand Up @@ -455,3 +465,166 @@ export const AsModal = () => {
</>
)
}

export const ResponsiveVariants = () => {
/* Selection */
const initialAssigneeIds = data.issue.assigneeIds // mock initial state
const [selectedAssigneeIds, setSelectedAssigneeIds] = React.useState<string[]>(initialAssigneeIds)

const onCollaboratorSelect = (colloratorId: string) => {
if (!selectedAssigneeIds.includes(colloratorId)) setSelectedAssigneeIds([...selectedAssigneeIds, colloratorId])
else setSelectedAssigneeIds(selectedAssigneeIds.filter(id => id !== colloratorId))
}

const onClearSelection = () => setSelectedAssigneeIds([])
const onSubmit = () => {
data.issue.assigneeIds = selectedAssigneeIds // pretending to persist changes
}

/* Filtering */
const [filteredUsers, setFilteredUsers] = React.useState(data.collaborators)
const [query, setQuery] = React.useState('')

const onSearchInputChange: React.ChangeEventHandler<HTMLInputElement> = event => {
const query = event.currentTarget.value
setQuery(query)

if (query === '') setFilteredUsers(data.collaborators)
else {
setFilteredUsers(
data.collaborators
.map(collaborator => {
if (collaborator.login.toLowerCase().startsWith(query)) return {priority: 1, collaborator}
else if (collaborator.name.startsWith(query)) return {priority: 2, collaborator}
else if (collaborator.login.toLowerCase().includes(query)) return {priority: 3, collaborator}
else if (collaborator.name.toLowerCase().includes(query)) return {priority: 4, collaborator}
else return {priority: -1, collaborator}
})
.filter(result => result.priority > 0)
.map(result => result.collaborator),
)
}
}

const sortingFn = (itemA: {id: string}, itemB: {id: string}) => {
const initialSelectedIds = data.issue.assigneeIds
if (initialSelectedIds.includes(itemA.id) && initialSelectedIds.includes(itemB.id)) return 1
else if (initialSelectedIds.includes(itemA.id)) return -1
else if (initialSelectedIds.includes(itemB.id)) return 1
else return 1
}

const itemsToShow = query ? filteredUsers : data.collaborators.sort(sortingFn)

/** Controls for story/example */
const {variant, Controls} = useResponsiveControlsForStory()

return (
<>
<h1>Responsive SelectPanel</h1>

{Controls}

<SelectPanel title="Set assignees" variant={variant} onSubmit={onSubmit} onClearSelection={onClearSelection}>
<SelectPanel.Button
variant="invisible"
trailingAction={GearIcon}
sx={{width: '200px', '[data-component=buttonContent]': {justifyContent: 'start'}}}
>
Assignees
</SelectPanel.Button>
<SelectPanel.Header>
<SelectPanel.SearchInput onChange={onSearchInputChange} />
</SelectPanel.Header>

{itemsToShow.length === 0 ? (
<SelectPanel.Message variant="empty" title={`No labels found for "${query}"`}>
Try a different search term
</SelectPanel.Message>
) : (
<ActionList>
{itemsToShow.map(collaborator => (
<ActionList.Item
key={collaborator.id}
onSelect={() => onCollaboratorSelect(collaborator.id)}
selected={selectedAssigneeIds.includes(collaborator.id)}
>
<ActionList.LeadingVisual>
<Avatar src={`https://github.com/${collaborator.login}.png`} />
</ActionList.LeadingVisual>
{collaborator.login}
<ActionList.Description>{collaborator.login}</ActionList.Description>
</ActionList.Item>
))}
</ActionList>
)}

<SelectPanel.Footer />
</SelectPanel>
</>
)
}

// pulling this out of story so that the docs look clean
const useResponsiveControlsForStory = () => {
const [variant, setVariant] = React.useState<{regular: 'anchored' | 'modal'; narrow: 'full-screen' | 'bottom-sheet'}>(
{regular: 'anchored', narrow: 'full-screen'},
)

const isNarrow = useResponsiveValue({narrow: true}, false)

const Controls = (
<Box sx={{display: 'flex', flexDirection: 'column', gap: 2, marginBottom: 4, maxWidth: 480, fontSize: 1}}>
<Box sx={{display: 'flex', minHeight: 42}}>
<Box sx={{flexGrow: 1}}>
<Text sx={{display: 'block'}}>Regular variant</Text>
{isNarrow ? (
<Text sx={{color: 'attention.fg'}}>
<ArrowBothIcon size={16} /> Resize screen to see regular variant
</Text>
) : null}
</Box>
<SegmentedControl aria-label="Regular variant" size="small">
<SegmentedControl.Button
selected={variant.regular === 'anchored'}
onClick={() => setVariant({...variant, regular: 'anchored'})}
>
Anchored
</SegmentedControl.Button>
<SegmentedControl.Button
selected={variant.regular === 'modal'}
onClick={() => setVariant({...variant, regular: 'modal'})}
>
Modal
</SegmentedControl.Button>
</SegmentedControl>
</Box>
<Box sx={{display: 'flex', minHeight: 42}}>
<Box sx={{flexGrow: 1}}>
<Text sx={{display: 'block'}}>Narrow variant</Text>
{isNarrow ? null : (
<Text sx={{color: 'attention.fg'}}>
<ArrowBothIcon size={16} /> Resize screen to see narrow variant
</Text>
)}
</Box>
<SegmentedControl aria-label="Narrow variant" size="small">
<SegmentedControl.Button
selected={variant.narrow === 'full-screen'}
onClick={() => setVariant({...variant, narrow: 'full-screen'})}
>
Full screen
</SegmentedControl.Button>
<SegmentedControl.Button
selected={variant.narrow === 'bottom-sheet'}
onClick={() => setVariant({...variant, narrow: 'bottom-sheet'})}
>
Bottom sheet
</SegmentedControl.Button>
</SegmentedControl>
</Box>
</Box>
)

return {variant, Controls}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export default {
title: 'Select labels',
selectionVariant: 'multiple',
secondaryActionVariant: 'button',
variant: {regular: 'anchored', narrow: 'full-screen'},
},
argTypes: {
secondaryActionVariant: {
Expand Down Expand Up @@ -93,6 +94,7 @@ export const Playground: StoryFn = args => {
<SelectPanel
title={args.title}
description={args.description}
variant={args.variant}
selectionVariant={args.selectionVariant}
onSubmit={onSubmit}
onCancel={onCancel}
Expand Down
16 changes: 16 additions & 0 deletions packages/react/src/drafts/SelectPanel2/SelectPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,22 @@ import data from './mock-story-data'
import type {SelectPanelProps} from './SelectPanel'
import {SelectPanel} from './SelectPanel'

// window.matchMedia() is not implemented by JSDOM so we have to create a mock:
// https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // deprecated
removeListener: jest.fn(), // deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
})

const Fixture = ({onSubmit, onCancel}: Pick<SelectPanelProps, 'onSubmit' | 'onCancel'>) => {
const initialSelectedLabels = data.issue.labelIds // mock initial state: has selected labels
const [selectedLabelIds, setSelectedLabelIds] = React.useState<string[]>(initialSelectedLabels)
Expand Down
66 changes: 51 additions & 15 deletions packages/react/src/drafts/SelectPanel2/SelectPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import type {OverlayProps} from '../../Overlay/Overlay'
import {StyledOverlay, heightMap} from '../../Overlay/Overlay'
import InputLabel from '../../internal/components/InputLabel'
import {invariant} from '../../utils/invariant'
import {useResponsiveValue} from '../../hooks/useResponsiveValue'
import type {ResponsiveValue} from '../../hooks/useResponsiveValue'

const SelectPanelContext = React.createContext<{
title: string
Expand All @@ -33,10 +35,12 @@ const SelectPanelContext = React.createContext<{
moveFocusToList: () => {},
})

const responsiveButtonSizes: ResponsiveValue<'small' | 'medium'> = {narrow: 'medium', regular: 'small'}

export type SelectPanelProps = {
title: string
description?: string
variant?: 'anchored' | 'modal'
variant?: 'anchored' | 'modal' | ResponsiveValue<'anchored' | 'modal', 'full-screen' | 'bottom-sheet'>
selectionVariant?: ActionListProps['selectionVariant'] | 'instant'
id?: string

Expand All @@ -59,7 +63,7 @@ export type SelectPanelProps = {
const Panel: React.FC<SelectPanelProps> = ({
title,
description,
variant = 'anchored',
variant: propsVariant,
selectionVariant = 'multiple',
id,

Expand All @@ -77,6 +81,12 @@ const Panel: React.FC<SelectPanelProps> = ({
}) => {
const [internalOpen, setInternalOpen] = React.useState(defaultOpen)

const responsiveVariants = Object.assign(
{regular: 'anchored', narrow: 'full-screen'}, // defaults
typeof propsVariant === 'string' ? {regular: propsVariant} : propsVariant,
)
const currentVariant = useResponsiveValue(responsiveVariants, 'anchored')

// sync open state with props
if (propsOpen !== undefined && internalOpen !== propsOpen) setInternalOpen(propsOpen)

Expand Down Expand Up @@ -220,22 +230,45 @@ const Panel: React.FC<SelectPanelProps> = ({
width={width}
height="fit-content"
maxHeight={maxHeight}
data-variant={currentVariant}
sx={{
'--max-height': heightMap[maxHeight],
// reset dialog default styles
border: 'none',
padding: 0,
'&[open]': {display: 'flex'}, // to fit children

...(variant === 'anchored' ? {margin: 0, top: position?.top, left: position?.left} : {}),
'::backdrop': {backgroundColor: variant === 'anchored' ? 'transparent' : 'primer.canvas.backdrop'},

'@keyframes selectpanel-gelatine': {
'0%': {transform: 'scale(1, 1)'},
'25%': {transform: 'scale(0.9, 1.1)'},
'50%': {transform: 'scale(1.1, 0.9)'},
'75%': {transform: 'scale(0.95, 1.05)'},
'100%': {transform: 'scale(1, 1)'},
'&[data-variant="anchored"], &[data-variant="full-screen"]': {
margin: 0,
top: position?.top,
left: position?.left,
'::backdrop': {backgroundColor: 'transparent'},
},
'&[data-variant="modal"]': {
'::backdrop': {backgroundColor: 'primer.canvas.backdrop'},
},
'&[data-variant="full-screen"]': {
margin: 0,
top: 0,
left: 0,
width: '100%',
maxWidth: '100vw',
height: '100%',
maxHeight: '100vh',
'--max-height': '100vh',
borderRadius: 'unset',
},
'&[data-variant="bottom-sheet"]': {
margin: 0,
top: 'auto',
bottom: 0,
left: 0,
width: '100%',
maxWidth: '100vw',
maxHeight: 'calc(100vh - 64px)',
'--max-height': 'calc(100vh - 64px)',
borderBottomRightRadius: 0,
borderBottomLeftRadius: 0,
},
}}
{...props}
Expand Down Expand Up @@ -443,6 +476,7 @@ const SelectPanelFooter = ({...props}) => {
const {onCancel, selectionVariant} = React.useContext(SelectPanelContext)

const hidePrimaryActions = selectionVariant === 'instant'
const buttonSize = useResponsiveValue(responsiveButtonSizes, 'small')

if (hidePrimaryActions && !props.children) {
// nothing to render
Expand All @@ -468,10 +502,10 @@ const SelectPanelFooter = ({...props}) => {

{hidePrimaryActions ? null : (
<Box sx={{display: 'flex', gap: 2}}>
<Button size="small" type="button" onClick={() => onCancel()}>
<Button type="button" size={buttonSize} onClick={() => onCancel()}>
Cancel
</Button>
<Button size="small" type="submit" variant="primary">
<Button type="submit" size={buttonSize} variant="primary">
Save
</Button>
</Box>
Expand All @@ -482,13 +516,15 @@ const SelectPanelFooter = ({...props}) => {
}

const SecondaryButton: React.FC<ButtonProps> = props => {
return <Button type="button" size="small" block {...props} />
const size = useResponsiveValue(responsiveButtonSizes, 'small')
return <Button type="button" size={size} block {...props} />
}

const SecondaryLink: React.FC<LinkProps> = props => {
const size = useResponsiveValue(responsiveButtonSizes, 'small')
return (
// @ts-ignore TODO: is as prop is not recognised by button?
<Button as={Link} size="small" variant="invisible" block {...props} sx={{fontSize: 0}}>
<Button as={Link} size={size} variant="invisible" block {...props} sx={{fontSize: 0}}>
{props.children}
</Button>
)
Expand Down

0 comments on commit 69915d9

Please sign in to comment.