diff --git a/packages/react/src/ActionList/ActionListContainerContext.tsx b/packages/react/src/ActionList/ActionListContainerContext.tsx index dee24bd8df7..1127042aa56 100644 --- a/packages/react/src/ActionList/ActionListContainerContext.tsx +++ b/packages/react/src/ActionList/ActionListContainerContext.tsx @@ -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({}) diff --git a/packages/react/src/ActionList/List.tsx b/packages/react/src/ActionList/List.tsx index 980425fd6b1..c6dc26d415a 100644 --- a/packages/react/src/ActionList/List.tsx +++ b/packages/react/src/ActionList/List.tsx @@ -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(sx) @@ -34,10 +36,18 @@ export const List = React.forwardRef( 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) + useFocusZone({ + disabled: !enableFocusZone, + containerRef: listRef, + bindKeys: FocusKeys.ArrowVertical | FocusKeys.HomeAndEnd | FocusKeys.PageUpDown, + }) + return ( ( role={role || listRole} aria-labelledby={ariaLabelledBy} {...props} - ref={forwardedRef} + ref={listRef} > {childrenWithoutSlots} diff --git a/packages/react/src/drafts/SelectPanel2/SelectPanel.examples.stories.tsx b/packages/react/src/drafts/SelectPanel2/SelectPanel.examples.stories.tsx index fff5fa47d0e..8af24311ad5 100644 --- a/packages/react/src/drafts/SelectPanel2/SelectPanel.examples.stories.tsx +++ b/packages/react/src/drafts/SelectPanel2/SelectPanel.examples.stories.tsx @@ -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, @@ -12,6 +12,7 @@ import { GitPullRequestIcon, GitMergeIcon, GitPullRequestDraftIcon, + PlusCircleIcon, } from '@primer/octicons-react' import data from './mock-story-data' @@ -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(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 = 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 ( + <> +

Create new item from panel

+ + setPanelOpen(false)} + onClearSelection={onClearSelection} + > + setPanelOpen(true)}>Assign label + + + + + + {itemsToShow.length === 0 ? ( + + Select the button below to create this label + + + ) : ( + <> + + {itemsToShow.map(label => ( + onLabelSelect(label.id)} + selected={selectedLabelIds.includes(label.id)} + data-id={label.id} + > + + + + {label.name} + {label.description} + + ))} + + {query && ( + + + + )} + + )} + + + Edit labels + + + + {newLabelDialogOpen && ( + { + setNewLabelDialogOpen(false) + setPanelOpen(true) + }} + /> + )} + + ) +} + +const CreateNewLabelDialog = ({ + initialValue, + onSave, + onCancel, +}: { + initialValue: string + onSave: (id: string) => void + onCancel: () => void +}) => { + const formSubmitRef = React.useRef(null) + + const onSubmit = (event: React.FormEvent) => { + event.preventDefault() + + const formData = new FormData(event.target as HTMLFormElement) + const {name, color, description} = Object.fromEntries(formData) as Record + + // 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 ( + formSubmitRef.current?.click()}, + ]} + > + + Note this Dialog is not accessible. Do not copy this. + +
+ + Name + + + + Color + + + + Description + + + +
+
+ ) +} + // ----- Suspense implementation details ---- const cache = new Map() diff --git a/packages/react/src/drafts/SelectPanel2/SelectPanel.tsx b/packages/react/src/drafts/SelectPanel2/SelectPanel.tsx index 20b1fc67002..cab12410749 100644 --- a/packages/react/src/drafts/SelectPanel2/SelectPanel.tsx +++ b/packages/react/src/drafts/SelectPanel2/SelectPanel.tsx @@ -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' @@ -100,7 +98,7 @@ const Panel: React.FC = ({ Anchor = React.cloneElement(child, { // @ts-ignore TODO ref: anchorRef, - onClick: onAnchorClick, + onClick: child.props.onClick || onAnchorClick, 'aria-haspopup': true, 'aria-expanded': internalOpen, }) @@ -144,15 +142,6 @@ const Panel: React.FC = ({ 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])' @@ -279,12 +268,10 @@ const Panel: React.FC = ({ } sx={{ flexShrink: 1, flexGrow: 1, overflow: 'hidden', - display: 'flex', flexDirection: 'column', justifyContent: 'space-between', @@ -299,6 +286,7 @@ const Panel: React.FC = ({ selectionVariant: selectionVariant === 'instant' ? 'single' : selectionVariant, afterSelect: internalAfterSelect, listLabelledBy: `${panelId}--title`, + enableFocusZone: true, // Arrow keys navigation for list items }} > {childrenInBody} @@ -604,7 +592,11 @@ const SelectPanelMessage: React.FC = ({ ) : null} {title} - {children} + + {children} + ) } else {