Skip to content
Closed
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
1 change: 1 addition & 0 deletions src/ActionList/ActionListContainerContext.tsx
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 src/ActionList/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {defaultSxProp} from '../utils/defaultSxProp'
import {useSlots} from '../hooks/useSlots'
import {Heading} from './Heading'
import {useId} from '../hooks/useId'
import {useFocusZone, FocusKeys} from '../hooks/useFocusZone'
import {useProvidedRefOrCreate} from '../hooks'

export type ActionListProps = React.PropsWithChildren<{
/**
Expand Down Expand Up @@ -59,10 +61,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 @@ -79,7 +89,7 @@ export const List = React.forwardRef<HTMLUListElement, ActionListProps>(
role={role || listRole}
aria-labelledby={ariaLabelledBy}
{...props}
ref={forwardedRef}
ref={listRef}
>
{childrenWithoutSlots}
</ListBox>
Expand Down
24 changes: 9 additions & 15 deletions src/drafts/SelectPanel2/SelectPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import React from 'react'
import {SearchIcon, XCircleFillIcon, XIcon, FilterRemoveIcon, AlertIcon} from '@primer/octicons-react'
import {FocusKeys} from '@primer/behaviors'

import {
Button,
Expand All @@ -23,7 +22,6 @@ import {
import {ActionListContainerContext} from '../../ActionList/ActionListContainerContext'
import {useSlots} from '../../hooks/useSlots'
import {useProvidedRefOrCreate, useId, useAnchoredPosition} from '../../hooks'
import {useFocusZone} from '../../hooks/useFocusZone'
import {StyledOverlay, OverlayProps} from '../../Overlay/Overlay'
import InputLabel from '../../internal/components/InputLabel'
import {invariant} from '../../utils/invariant'
Expand Down Expand Up @@ -112,7 +110,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 @@ -152,15 +150,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],
)

/* Dialog */
const dialogRef = React.useRef<HTMLDialogElement>(null)

Expand Down Expand Up @@ -276,7 +265,6 @@ const Panel: React.FC<SelectPanelProps> = ({

<Box
as="div"
ref={listContainerRef as React.RefObject<HTMLDivElement>}
sx={{
flexShrink: 1,
flexGrow: 1,
Expand All @@ -292,6 +280,7 @@ const Panel: React.FC<SelectPanelProps> = ({
value={{
container: 'SelectPanel',
listRole: 'listbox',
enableFocusZone: true, // Arrow keys navigation for list items
selectionAttribute: 'aria-selected',
selectionVariant: selectionVariant === 'instant' ? 'single' : selectionVariant,
afterSelect: internalAfterSelect,
Expand Down Expand Up @@ -429,6 +418,7 @@ const SelectPanelFooter = ({...props}) => {
<FooterContext.Provider value={true}>
<Box
sx={{
flexShrink: 0,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
Expand Down Expand Up @@ -556,7 +546,7 @@ const SelectPanelMessage: React.FC<SelectPanelMessageProps> = ({
flexGrow: 1,
height: '100%',
gap: 1,
paddingX: 4,
paddingX: 3,
textAlign: 'center',
a: {color: 'inherit', textDecoration: 'underline'},
}}
Expand All @@ -565,7 +555,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
227 changes: 224 additions & 3 deletions src/drafts/SelectPanel2/stories/SelectPanel.examples.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import React from 'react'
import React, {FormEvent} from 'react'
import {SelectPanel} from '../SelectPanel'
import {ActionList, ActionMenu, Avatar, Box, Button, Text} from '../../../index'
import {ArrowRightIcon, EyeIcon, GitBranchIcon, TriangleDownIcon, GearIcon} from '@primer/octicons-react'
import {ActionList, ActionMenu, Avatar, Box, Button, Flash, FormControl, Text, TextInput} from '../../../index'
import {Dialog} from '../../../drafts'
import {
ArrowRightIcon,
EyeIcon,
GitBranchIcon,
TriangleDownIcon,
GearIcon,
PlusCircleIcon,
} from '@primer/octicons-react'
import data from './mock-data'

export default {
Expand Down Expand Up @@ -640,6 +648,219 @@ export const ShortSelectPanel = () => {
)
}

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)
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>
<Flash sx={{marginBottom: 2}} variant="warning">
Note this example is not yet ready, do not copy it. It is blocked by{' '}
<a href="https://github.com/primer/react/pull/3840">primer/react/pull/3840</a>
</Flash>

<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)}
/>
)}
</>
)
}

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

const onSubmit = (event: 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)
data.labels.unshift({id, name, color, description})
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
Loading