diff --git a/src/ActionList/ActionListContainerContext.tsx b/src/ActionList/ActionListContainerContext.tsx index bf5c797bff0..594b15ea4e9 100644 --- a/src/ActionList/ActionListContainerContext.tsx +++ b/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/src/ActionList/List.tsx b/src/ActionList/List.tsx index ba2178d3efa..ea0156ac433 100644 --- a/src/ActionList/List.tsx +++ b/src/ActionList/List.tsx @@ -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<{ /** @@ -59,10 +61,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/src/drafts/SelectPanel2/SelectPanel.tsx b/src/drafts/SelectPanel2/SelectPanel.tsx index 82e122a4181..9350d007131 100644 --- a/src/drafts/SelectPanel2/SelectPanel.tsx +++ b/src/drafts/SelectPanel2/SelectPanel.tsx @@ -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, @@ -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' @@ -112,7 +110,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, }) @@ -152,15 +150,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], - ) - /* Dialog */ const dialogRef = React.useRef(null) @@ -276,7 +265,6 @@ const Panel: React.FC = ({ } sx={{ flexShrink: 1, flexGrow: 1, @@ -292,6 +280,7 @@ const Panel: React.FC = ({ value={{ container: 'SelectPanel', listRole: 'listbox', + enableFocusZone: true, // Arrow keys navigation for list items selectionAttribute: 'aria-selected', selectionVariant: selectionVariant === 'instant' ? 'single' : selectionVariant, afterSelect: internalAfterSelect, @@ -429,6 +418,7 @@ const SelectPanelFooter = ({...props}) => { = ({ flexGrow: 1, height: '100%', gap: 1, - paddingX: 4, + paddingX: 3, textAlign: 'center', a: {color: 'inherit', textDecoration: 'underline'}, }} @@ -565,7 +555,11 @@ const SelectPanelMessage: React.FC = ({ ) : null} {title} - {children} + + {children} + ) } else { diff --git a/src/drafts/SelectPanel2/stories/SelectPanel.examples.stories.tsx b/src/drafts/SelectPanel2/stories/SelectPanel.examples.stories.tsx index 3eaf75e9060..11564313574 100644 --- a/src/drafts/SelectPanel2/stories/SelectPanel.examples.stories.tsx +++ b/src/drafts/SelectPanel2/stories/SelectPanel.examples.stories.tsx @@ -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 { @@ -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(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

+ + Note this example is not yet ready, do not copy it. It is blocked by{' '} + primer/react/pull/3840 + + + 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)} + /> + )} + + ) +} + +const CreateNewLabelDialog = ({ + initialValue, + onSave, + onCancel, +}: { + initialValue: string + onSave: (id: string) => void + onCancel: () => void +}) => { + const formSubmitRef = React.useRef(null) + + const onSubmit = (event: 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) + data.labels.unshift({id, name, color, description}) + 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/src/drafts/SelectPanel2/stories/mock-data.ts b/src/drafts/SelectPanel2/stories/mock-data.ts index 75f86fd3c77..e10ad1f868d 100644 --- a/src/drafts/SelectPanel2/stories/mock-data.ts +++ b/src/drafts/SelectPanel2/stories/mock-data.ts @@ -50,315 +50,246 @@ const data = { id: 'MDU6TGFiZWw4Mzk2MzgxMTU=', name: 'bug', description: "Something isn't working", - createdAt: '2018-02-17T00:09:05Z', }, { color: 'cfd3d7', id: 'MDU6TGFiZWw4Mzk2MzgxMTY=', name: 'duplicate', description: 'This issue or pull request already exists', - createdAt: '2018-02-17T00:09:05Z', }, { color: 'a2eeef', id: 'MDU6TGFiZWw4Mzk2MzgxMTc=', name: 'enhancement', description: 'New feature or request', - createdAt: '2018-02-17T00:09:05Z', }, { color: 'd876e3', id: 'MDU6TGFiZWw4Mzk2MzgxMjE=', name: 'futher info needed', description: 'Further information is requested', - createdAt: '2018-02-17T00:09:05Z', }, { color: '7057ff', id: 'MDU6TGFiZWw4Mzk2MzgxMTk=', name: 'good first issue', description: 'Good for newcomers', - createdAt: '2018-02-17T00:09:05Z', }, { color: '008672', id: 'MDU6TGFiZWw4Mzk2MzgxMTg=', name: 'up for grabs', description: "anyone can take on this work, it's ready to go", - createdAt: '2018-02-17T00:09:05Z', }, { color: 'ffffff', id: 'MDU6TGFiZWw4Mzk2MzgxMjI=', name: 'wontfix', description: 'This will not be worked on', - createdAt: '2018-02-17T00:09:05Z', }, { color: 'FFD2ED', id: 'MDU6TGFiZWw4ODkxNjUwNzU=', name: '💓collab', description: 'a vibrant hub of collaboration', - createdAt: '2018-04-03T21:03:53Z', }, { color: '9eea90', id: 'MDU6TGFiZWw5NDAyMzcyOTU=', name: 'status: wip', - description: '', - createdAt: '2018-05-21T19:11:15Z', }, { color: 'eae658', id: 'MDU6TGFiZWw5NDAyMzc1NzY=', name: 'status: review needed', - description: '', - createdAt: '2018-05-21T19:11:36Z', }, { color: 'a9abe8', id: 'MDU6TGFiZWw5NDA1MDc5NzQ=', name: 'type: discussion', - description: '', - createdAt: '2018-05-22T02:06:49Z', }, { color: '86181d', id: 'MDU6TGFiZWw5NDg2NDM1MzI=', name: '🚧 blocked', description: 'Someone or something is preventing this from moving forward', - createdAt: '2018-05-29T22:02:54Z', }, { color: '1d76db', id: 'MDU6TGFiZWwxMDI2NDE1MTYw', name: 'docs', description: 'Documentation', - createdAt: '2018-08-16T16:35:43Z', }, { color: 'fcc0dc', id: 'MDU6TGFiZWwxMDU3MjcwNzA4', name: 'patch release', description: 'bug fixes, docs, housekeeping', - createdAt: '2018-09-14T20:27:34Z', }, { color: 'ff73b4', id: 'MDU6TGFiZWwxMDU3MjcxMDUz', name: 'minor release', description: 'new features', - createdAt: '2018-09-14T20:28:01Z', }, { color: 'e3006a', id: 'MDU6TGFiZWwxMDU3MjcxNDE5', name: 'major release', description: 'breaking changes', - createdAt: '2018-09-14T20:28:30Z', }, { color: '107d93', id: 'MDU6TGFiZWwxMDU3MjczMjkz', name: 'deployment', - description: '', - createdAt: '2018-09-14T20:31:10Z', }, { color: 'd4c5f9', id: 'MDU6TGFiZWwxMzgyNTYyNjQ0', name: 'accessibility', - description: '', - createdAt: '2019-05-29T19:56:35Z', }, { color: '0366d6', id: 'MDU6TGFiZWwxNjE2MTQ5MDE2', name: 'dependencies', description: 'Pull requests that update a dependency file', - createdAt: '2019-10-14T18:19:49Z', }, { color: 'e1e4e8', id: 'MDU6TGFiZWwxNjU3MjI0NTcw', name: 'fr-skip', description: 'Remove this from the Design Systems first responder list', - createdAt: '2019-11-04T18:18:03Z', }, { color: 'fcba03', id: 'MDU6TGFiZWwxNzkwMDY4Mzk5', name: 'developer experience', - description: '', - createdAt: '2020-01-15T22:30:35Z', }, { color: '68f9cc', id: 'MDU6TGFiZWwxNzkwMDY4NDg1', name: 'contributor experience', - description: '', - createdAt: '2020-01-15T22:30:41Z', }, { color: 'a1b220', id: 'MDU6TGFiZWwxNzkwMDcyODY5', name: 'API', - description: '', - createdAt: '2020-01-15T22:34:09Z', }, { color: '6494f4', id: 'MDU6TGFiZWwxNzkxNjM5MzM1', name: 'new component', - description: '', - createdAt: '2020-01-16T16:13:35Z', }, { color: 'f48b96', id: 'MDU6TGFiZWwxOTg5MDAwNjk0', name: 'experimental', - description: '', - createdAt: '2020-04-15T20:52:13Z', }, { color: 'db9360', id: 'MDU6TGFiZWwxOTkzNTcwNzUx', name: 'design', - description: '', - createdAt: '2020-04-17T15:08:45Z', }, { color: 'F9D0C4', id: 'MDU6TGFiZWwyNjkzNTE0OTQw', name: 'coverage', - description: '', - createdAt: '2021-01-27T22:25:23Z', }, { color: '077CA4', id: 'MDU6TGFiZWwyNjkzNTE5NTc1', name: 'epic', - description: '', - createdAt: '2021-01-27T22:27:57Z', }, { color: 'F9D0C4', id: 'MDU6TGFiZWwyNjkzNTMwODUy', name: 'behaviors', - description: '', - createdAt: '2021-01-27T22:34:49Z', }, { color: '1d76db', id: 'MDU6TGFiZWwyNzkzMjYwMTgz', name: 'typescript', - description: '', - createdAt: '2021-03-04T20:17:59Z', }, { color: 'DBEDFF', id: 'MDU6TGFiZWwzMzA5MjY0Nzcz', name: 'size: sand', - description: '', - createdAt: '2021-08-31T02:08:34Z', }, { color: 'DBEDFF', id: 'MDU6TGFiZWwzMzA5MjY1MDM2', name: 'size: pebble', - description: '', - createdAt: '2021-08-31T02:08:42Z', }, { color: 'DBEDFF', id: 'MDU6TGFiZWwzMzA5MjY1MTc4', name: 'size: rock', - description: '', - createdAt: '2021-08-31T02:08:49Z', }, { color: 'DBEDFF', id: 'MDU6TGFiZWwzMzA5MjY1NTg3', name: 'size: boulder', - description: '', - createdAt: '2021-08-31T02:09:00Z', }, { color: 'ededed', id: 'MDU6TGFiZWwzMzE4NjA1ODgy', name: 'Stale', description: null, - createdAt: '2021-09-02T22:04:15Z', }, { color: '0052CC', id: 'LA_kwDOB0K8ws7Oq_eD', name: 'react', - description: '', - createdAt: '2021-10-19T16:48:08Z', }, { color: 'eeeeee', id: 'LA_kwDOB0K8ws7O4h4u', name: 'skip changeset', - description: '', - createdAt: '2021-10-20T16:15:21Z', }, { color: 'D93F0B', id: 'LA_kwDOB0K8ws7WuYLd', name: 'do not merge', - description: '', - createdAt: '2021-12-01T19:02:44Z', }, { color: 'e5534b', id: 'LA_kwDOB0K8ws73bq-W', name: 'needs triage', - description: '', - createdAt: '2022-05-20T13:41:47Z', }, { color: 'B60205', id: 'LA_kwDOB0K8ws8AAAABA3IfBw', name: 'a11y-eng-secondary', - description: '', - createdAt: '2022-07-22T10:04:29Z', }, { color: 'ffffff', id: 'LA_kwDOB0K8ws8AAAABBfG7EQ', name: 'support', description: 'Tasks where the team is supporting and helping other teams', - createdAt: '2022-08-04T15:33:56Z', }, { color: 'DDF4FF', id: 'LA_kwDOB0K8ws8AAAABG14Q1Q', name: 'component: tree view', description: 'Issues related to the TreeView component', - createdAt: '2022-11-02T18:49:40Z', }, { color: 'DDF4FF', id: 'LA_kwDOB0K8ws8AAAABHLXdxw', name: 'component: SplitPageLayout', description: 'Issues related to the PageLayout and SplitPageLayout components', - createdAt: '2022-11-08T18:57:47Z', }, { color: 'DDF4FF', id: 'LA_kwDOB0K8ws8AAAABHLXucw', name: 'component: nav list', description: 'Issues related to the NavList component', - createdAt: '2022-11-08T18:58:49Z', }, { color: 'DDF4FF', id: 'LA_kwDOB0K8ws8AAAABHLYCfQ', name: 'component: button', description: 'Issues related to the Button component', - createdAt: '2022-11-08T19:00:13Z', }, ], users: [