Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ type ContextProps = {
// to be more specific here, this is as good as (...args: any[]) => unknown
// eslint-disable-next-line @typescript-eslint/ban-types
afterSelect?: Function
enableFocusZone?: boolean
}

export const ActionListContainerContext = React.createContext<ContextProps>({})
12 changes: 11 additions & 1 deletion packages/react/src/ActionList/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {useSlots} from '../hooks/useSlots'
import {Heading} from './Heading'
import {useId} from '../hooks/useId'
import {ListContext, type ActionListProps} from './shared'
import {useProvidedRefOrCreate} from '../hooks'
import {FocusKeys, useFocusZone} from '../hooks/useFocusZone'

const ListBox = styled.ul<SxProp>(sx)

Expand All @@ -34,10 +36,18 @@ export const List = React.forwardRef<HTMLUListElement, ActionListProps>(
listRole,
listLabelledBy,
selectionVariant: containerSelectionVariant, // TODO: Remove after DropdownMenu2 deprecation
enableFocusZone,
} = React.useContext(ActionListContainerContext)

const ariaLabelledBy = slots.heading ? slots.heading.props.id ?? headingId : listLabelledBy

const listRef = useProvidedRefOrCreate(forwardedRef as React.RefObject<HTMLUListElement>)
useFocusZone({
disabled: !enableFocusZone,
containerRef: listRef,
bindKeys: FocusKeys.ArrowVertical | FocusKeys.HomeAndEnd | FocusKeys.PageUpDown,
})

return (
<ListContext.Provider
value={{
Expand All @@ -54,7 +64,7 @@ export const List = React.forwardRef<HTMLUListElement, ActionListProps>(
role={role || listRole}
aria-labelledby={ariaLabelledBy}
{...props}
ref={forwardedRef}
ref={listRef}
>
{childrenWithoutSlots}
</ListBox>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react'
import {SelectPanel} from './SelectPanel'
import {ActionList, ActionMenu, Avatar, Box, Button, Text, Octicon, Flash} from '../../index'
import {ActionList, ActionMenu, Avatar, Box, Button, Text, Octicon, Flash, FormControl, TextInput} from '../../index'
import {Dialog} from '../../drafts'
import {
ArrowRightIcon,
Expand All @@ -12,6 +12,7 @@ import {
GitPullRequestIcon,
GitMergeIcon,
GitPullRequestDraftIcon,
PlusCircleIcon,
} from '@primer/octicons-react'
import data from './mock-story-data'

Expand Down Expand Up @@ -846,6 +847,219 @@ export const NestedSelection = () => {
)
}

export const CreateNewRow = () => {
const initialSelectedLabels = data.issue.labelIds // mock initial state: has selected labels
const [selectedLabelIds, setSelectedLabelIds] = React.useState<string[]>(initialSelectedLabels)

/* Selection */
const onLabelSelect = (labelId: string) => {
if (!selectedLabelIds.includes(labelId)) setSelectedLabelIds([...selectedLabelIds, labelId])
else setSelectedLabelIds(selectedLabelIds.filter(id => id !== labelId))
}
const onClearSelection = () => {
setSelectedLabelIds([])
}

const onSubmit = () => {
data.issue.labelIds = selectedLabelIds // pretending to persist changes
}

/* Filtering */
const [filteredLabels, setFilteredLabels] = React.useState(data.labels)
const [query, setQuery] = React.useState('')

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

if (query === '') setFilteredLabels(data.labels)
else {
setFilteredLabels(
data.labels
.map(label => {
if (label.name.toLowerCase().startsWith(query)) return {priority: 1, label}
else if (label.name.toLowerCase().includes(query)) return {priority: 2, label}
else if (label.description?.toLowerCase().includes(query)) return {priority: 3, label}
else return {priority: -1, label}
})
.filter(result => result.priority > 0)
.map(result => result.label),
)
}
}

const sortingFn = (itemA: {id: string}, itemB: {id: string}) => {
const initialSelectedIds = data.issue.labelIds
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 ? filteredLabels : data.labels.sort(sortingFn)

/*
Controlled state + Create new label Dialog
We only have to do this until https://github.com/primer/react/pull/3840 is merged
*/
const [panelOpen, setPanelOpen] = React.useState(false)
const [newLabelDialogOpen, setNewLabelDialogOpen] = React.useState(false)

const openCreateLabelDialog = () => {
setPanelOpen(false)
Copy link
Member Author

@siddharthkp siddharthkp Mar 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note for reviewer:

This is a bit unfortunate, you have to control the open state of SelectPanel because SelectPanel uses <dialog> (top layer) but Dialog uses portal not <dialog> so Dialog would render under the panel 🤦

This would change once we pick up #3840 again

setNewLabelDialogOpen(true)
}

const onNewLabelDialogSave = (id: string) => {
setNewLabelDialogOpen(false)

setQuery('') // clear search input
onLabelSelect(id) // select newly created label

setPanelOpen(true)

// focus newly created label once it renders
window.requestAnimationFrame(() => {
const newLabelElement = document.querySelector(`[data-id=${id}]`) as HTMLLIElement
newLabelElement.focus()
})
}

return (
<>
<h1>Create new item from panel</h1>

<SelectPanel
title="Select labels"
open={panelOpen}
onSubmit={onSubmit}
onCancel={() => setPanelOpen(false)}
onClearSelection={onClearSelection}
>
<SelectPanel.Button onClick={() => setPanelOpen(true)}>Assign label</SelectPanel.Button>

<SelectPanel.Header>
<SelectPanel.SearchInput value={query} onChange={onSearchInputChange} />
</SelectPanel.Header>

{itemsToShow.length === 0 ? (
<SelectPanel.Message variant="empty" title={`No labels found for "${query}"`}>
<Text>Select the button below to create this label</Text>
<Button onClick={openCreateLabelDialog}>Create &quot;{query}&quot;</Button>
</SelectPanel.Message>
) : (
<>
<ActionList>
{itemsToShow.map(label => (
<ActionList.Item
key={label.id}
onSelect={() => onLabelSelect(label.id)}
selected={selectedLabelIds.includes(label.id)}
data-id={label.id}
>
<ActionList.LeadingVisual>
<Box
sx={{width: 14, height: 14, borderRadius: '100%'}}
style={{backgroundColor: `#${label.color}`}}
/>
</ActionList.LeadingVisual>
{label.name}
<ActionList.Description variant="block">{label.description}</ActionList.Description>
</ActionList.Item>
))}
</ActionList>
{query && (
<Box sx={{padding: 2, borderTop: '1px solid', borderColor: 'border.default', flexShrink: 0}}>
<Button
variant="invisible"
leadingVisual={PlusCircleIcon}
block
alignContent="start"
sx={{'[data-component=text]': {fontWeight: 'normal'}}}
onClick={openCreateLabelDialog}
>
Create new label &quot;{query}&quot;...
</Button>
</Box>
)}
</>
)}

<SelectPanel.Footer>
<SelectPanel.SecondaryAction variant="button">Edit labels</SelectPanel.SecondaryAction>
</SelectPanel.Footer>
</SelectPanel>

{newLabelDialogOpen && (
<CreateNewLabelDialog
initialValue={query}
onSave={onNewLabelDialogSave}
onCancel={() => {
setNewLabelDialogOpen(false)
setPanelOpen(true)
}}
/>
)}
</>
)
}

const CreateNewLabelDialog = ({
Copy link
Member Author

@siddharthkp siddharthkp Mar 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note for reviewer: The code here isn't ideal, I tried to make it a little more accessible in the story but the underlying component needs work 😢

This isn't the focus of the PR/epic though, so I just added a Flash banner telling people not to copy this part of code 😅

initialValue,
onSave,
onCancel,
}: {
initialValue: string
onSave: (id: string) => void
onCancel: () => void
}) => {
const formSubmitRef = React.useRef<HTMLButtonElement>(null)

const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()

const formData = new FormData(event.target as HTMLFormElement)
const {name, color, description} = Object.fromEntries(formData) as Record<string, string>

// pretending to persist changes
const id = Math.random().toString(26).slice(6)
const createdAt = new Date().toISOString()
data.labels.unshift({id, name, color, description, createdAt})
onSave(id)
}

return (
<Dialog
title="Create new Label"
onClose={onCancel}
width="medium"
footerButtons={[
{buttonType: 'default', content: 'Cancel', onClick: onCancel},
{type: 'submit', buttonType: 'primary', content: 'Save', onClick: () => formSubmitRef.current?.click()},
]}
>
<Flash sx={{marginBottom: 2}} variant="warning">
Note this Dialog is not accessible. Do not copy this.
</Flash>
<form onSubmit={onSubmit}>
<FormControl sx={{marginBottom: 2}}>
<FormControl.Label>Name</FormControl.Label>
<TextInput name="name" block defaultValue={initialValue} autoFocus />
</FormControl>
<FormControl sx={{marginBottom: 2}}>
<FormControl.Label>Color</FormControl.Label>
<TextInput name="color" block defaultValue="fae17d" leadingVisual="#" />
</FormControl>
<FormControl>
<FormControl.Label>Description</FormControl.Label>
<TextInput name="description" block placeholder="Good first issues" />
</FormControl>
<button type="submit" hidden ref={formSubmitRef}></button>
</form>
</Dialog>
)
}

// ----- Suspense implementation details ----

const cache = new Map()
Expand Down
22 changes: 7 additions & 15 deletions packages/react/src/drafts/SelectPanel2/SelectPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import React from 'react'
import {SearchIcon, XCircleFillIcon, XIcon, FilterRemoveIcon, AlertIcon, ArrowLeftIcon} from '@primer/octicons-react'
import {FocusKeys} from '@primer/behaviors'

import type {ButtonProps, TextInputProps, ActionListProps, LinkProps, CheckboxProps} from '../../index'
import {Button, IconButton, Heading, Box, Tooltip, TextInput, Spinner, Text, Octicon, Link, Checkbox} from '../../index'
import {ActionListContainerContext} from '../../ActionList/ActionListContainerContext'
import {useSlots} from '../../hooks/useSlots'
import {useProvidedRefOrCreate, useId, useAnchoredPosition} from '../../hooks'
import {useFocusZone} from '../../hooks/useFocusZone'
import type {OverlayProps} from '../../Overlay/Overlay'
import {StyledOverlay, heightMap} from '../../Overlay/Overlay'
import InputLabel from '../../internal/components/InputLabel'
Expand Down Expand Up @@ -100,7 +98,7 @@ const Panel: React.FC<SelectPanelProps> = ({
Anchor = React.cloneElement(child, {
// @ts-ignore TODO
ref: anchorRef,
onClick: onAnchorClick,
onClick: child.props.onClick || onAnchorClick,
'aria-haspopup': true,
'aria-expanded': internalOpen,
})
Expand Down Expand Up @@ -144,15 +142,6 @@ const Panel: React.FC<SelectPanelProps> = ({
const panelId = useId(id)
const [slots, childrenInBody] = useSlots(contents, {header: SelectPanelHeader, footer: SelectPanelFooter})

/* Arrow keys navigation for list items */
const {containerRef: listContainerRef} = useFocusZone(
{
bindKeys: FocusKeys.ArrowVertical | FocusKeys.HomeAndEnd | FocusKeys.PageUpDown,
focusableElementFilter: element => element.tagName === 'LI',
},
[internalOpen],
)

// used in SelectPanel.SearchInput
const moveFocusToList = () => {
const selector = 'ul[role=listbox] li:not([role=none])'
Expand Down Expand Up @@ -279,12 +268,10 @@ const Panel: React.FC<SelectPanelProps> = ({

<Box
as="div"
ref={listContainerRef as React.RefObject<HTMLDivElement>}
sx={{
flexShrink: 1,
flexGrow: 1,
overflow: 'hidden',

display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
Expand All @@ -299,6 +286,7 @@ const Panel: React.FC<SelectPanelProps> = ({
selectionVariant: selectionVariant === 'instant' ? 'single' : selectionVariant,
afterSelect: internalAfterSelect,
listLabelledBy: `${panelId}--title`,
enableFocusZone: true, // Arrow keys navigation for list items
}}
>
{childrenInBody}
Expand Down Expand Up @@ -604,7 +592,11 @@ const SelectPanelMessage: React.FC<SelectPanelMessageProps> = ({
<Octicon icon={AlertIcon} sx={{color: variant === 'error' ? 'danger.fg' : 'attention.fg', marginBottom: 2}} />
) : null}
<Text sx={{fontSize: 1, fontWeight: 'semibold'}}>{title}</Text>
<Text sx={{fontSize: 1, color: 'fg.muted'}}>{children}</Text>
<Text
sx={{fontSize: 1, color: 'fg.muted', display: 'flex', flexDirection: 'column', gap: 2, alignItems: 'center'}}
>
{children}
</Text>
</Box>
)
} else {
Expand Down