Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SelectPanel2: Responsive variants #4277

Merged
merged 12 commits into from
Mar 19, 2024
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 @@ -12,6 +12,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 @@ -35,10 +37,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 @@ -61,7 +65,7 @@ export type SelectPanelProps = {
const Panel: React.FC<SelectPanelProps> = ({
title,
description,
variant = 'anchored',
variant: propsVariant,
selectionVariant = 'multiple',
id,

Expand All @@ -79,6 +83,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 @@ -231,22 +241,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 @@ -452,6 +485,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 @@ -477,10 +511,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 @@ -491,13 +525,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
Loading