From c5eb570a62020c4cb49679105e1dda85ebb06efe Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Wed, 9 Feb 2022 15:04:07 +0100 Subject: [PATCH 01/14] merge docs --- docs/content/drafts/ActionMenu2.mdx | 45 +++- docs/content/drafts/DropdownMenu2.mdx | 364 -------------------------- src/ActionList2/Selection.tsx | 14 +- 3 files changed, 39 insertions(+), 384 deletions(-) delete mode 100644 docs/content/drafts/DropdownMenu2.mdx diff --git a/docs/content/drafts/ActionMenu2.mdx b/docs/content/drafts/ActionMenu2.mdx index 4a8a8250ebf..037a4b82e20 100644 --- a/docs/content/drafts/ActionMenu2.mdx +++ b/docs/content/drafts/ActionMenu2.mdx @@ -175,6 +175,44 @@ You can choose to have a different _anchor_ for the Menu dependending on the app ``` +### With Selection + +Use `selectionVariant` on `ActionList` to create a menu with single or multiple selection. + +```javascript live noinline drafts +const fieldTypes = [ + {icon: TypographyIcon, name: 'Text'}, + {icon: NumberIcon, name: 'Number'}, + {icon: CalendarIcon, name: 'Date'}, + {icon: SingleSelectIcon, name: 'Single select'}, + {icon: IterationsIcon, name: 'Iteration'} +] + +const Example = () => { + const [selectedIndex, setSelectedIndex] = React.useState(1) + const selectedType = fieldTypes[selectedIndex] + + return ( + + + {selectedType.name} + + + + {fieldTypes.map((type, index) => ( + setSelectedIndex(index)}> + {type.name} + + ))} + + + + ) +} + +render() +``` + ### With External Anchor To create an anchor outside of the menu, you need to switch to controlled mode for the menu and pass it as `anchorRef` to `ActionMenu`. Make sure you add `aria-expanded` and `aria-haspopup` to the external anchor: @@ -241,12 +279,6 @@ render( ) ``` - - -Use `ActionMenu` to choose an action from a list. If you’re looking for single or multiple selection, use [DropdownMenu](/DropdownMenu) or [SelectPanel](/SelectPanel) instead. - - - ## Props / API reference ### ActionMenu @@ -305,6 +337,5 @@ Use `ActionMenu` to choose an action from a list. If you’re looking for single ## Related components - [ActionList](/drafts/ActionList2) -- [DropdownMenu](/DropdownMenu) - [SelectPanel](/SelectPanel) - [Button](/drafts/Button2) diff --git a/docs/content/drafts/DropdownMenu2.mdx b/docs/content/drafts/DropdownMenu2.mdx deleted file mode 100644 index 68cd522aa1e..00000000000 --- a/docs/content/drafts/DropdownMenu2.mdx +++ /dev/null @@ -1,364 +0,0 @@ ---- -component_id: dropdown_menu -title: DropdownMenu v2 -status: Alpha -source: https://github.com/primer/react/tree/main/src/DropdownMenu2.tsx -storybook: '/react/storybook?path=/story/composite-components-dropdownmenu2' -description: Use DropdownMenu to select a single option from a list of menu options. ---- - -import {Box, Avatar} from '@primer/react' -import {DropdownMenu, ActionList} from '@primer/react/drafts' -import {Props} from '../../src/props' -import State from '../../components/State' -import {CalendarIcon, IterationsIcon, NumberIcon, SingleSelectIcon, TypographyIcon} from '@primer/octicons-react' - -
- - - {([selectedIndex, setSelectedIndex]) => { - const fieldTypes = [ - {icon: TypographyIcon, name: 'Text'}, - {icon: NumberIcon, name: 'Number'}, - {icon: CalendarIcon, name: 'Date'}, - {icon: SingleSelectIcon, name: 'Single select'}, - {icon: IterationsIcon, name: 'Iteration'} - ] - const selectedType = fieldTypes[selectedIndex] - return ( - - - - {selectedType.name} - - - - {fieldTypes.map(({icon: Icon, name}, index) => ( - setSelectedIndex(index)} - > - {name} - - ))} - - - - - ) - }} - - -
- -```js -import {DropdownMenu} from '@primer/react/drafts' -``` - -
- -## Examples - -### Minimal example - -`DropdownMenu` ships with `DropdownMenu.Button` which is an accessible trigger for the overlay. It's recommended to compose `ActionList` with `DropdownMenu.Overlay` - -  - -```javascript live noinline drafts -const fieldTypes = [ - {icon: TypographyIcon, name: 'Text'}, - {icon: NumberIcon, name: 'Number'}, - {icon: CalendarIcon, name: 'Date'}, - {icon: SingleSelectIcon, name: 'Single select'}, - {icon: IterationsIcon, name: 'Iteration'} -] - -const Example = () => { - const [selectedIndex, setSelectedIndex] = React.useState(1) - const selectedType = fieldTypes[selectedIndex] - - return ( - - - {selectedType.name} - - - - {fieldTypes.map((type, index) => ( - setSelectedIndex(index)}> - {type.name} - - ))} - - - - ) -} - -render() -``` - -### Customise Button - -`Dropdown.Button` uses `Button v2` so you can pass props like `variant` and `leadingIcon` that `Button v2` accepts. - -```javascript live noinline drafts -const Example = () => { - const [duration, setDuration] = React.useState(1) - - return ( - - - {duration} {duration > 1 ? 'weeks' : 'week'} - - - - {[1, 2, 3, 4, 5, 6].map(weeks => ( - setDuration(weeks)}> - {weeks} {weeks > 1 ? 'weeks' : 'week'} - - ))} - - - - ) -} - -render() -``` - -### With External Anchor - -To create an anchor outside of the menu, you need to switch to controlled mode for the menu and pass `open` and `onOpenChange` along with an `anchorRef` to `DropdownMenu`: - -```javascript live noinline drafts -const Example = () => { - const [open, setOpen] = React.useState(false) - const anchorRef = React.createRef() - - return ( - <> - - - - - - Text - Number - Date - Iteration - - - - - ) -} - -render() -``` - -### With Overlay Props - -```javascript live noinline drafts -const fieldTypes = [ - {icon: TypographyIcon, name: 'Text'}, - {icon: NumberIcon, name: 'Number'}, - {icon: CalendarIcon, name: 'Date'}, - {icon: SingleSelectIcon, name: 'Single select'}, - {icon: IterationsIcon, name: 'Iteration'} -] - -const Example = () => { - const handleEscape = () => alert('you hit escape!') - - const [selectedIndex, setSelectedIndex] = React.useState(1) - const selectedType = fieldTypes[selectedIndex] - - return ( - - - {selectedType.name} - - - - {fieldTypes.map((type, index) => ( - setSelectedIndex(index)}> - {type.name} - - ))} - - - - ) -} - -render() -``` - -### With a custom anchor - -You can choose to have a different _anchor_ for the Menu dependending on the application's context. - -  - -```javascript live noinline drafts -render( - - - - - - - - Text - Number - Date - Iteration - - - -) -``` - - - -Use `DropdownMenu` to select an option from a small list. If you’re looking for filters or multiple selection, use [SelectPanel](/SelectPanel) instead. - - - -## Props - -### DropdownMenu - - - - Recommended: DropdownMenu.Button or DropdownMenu.Anchor with{' '} - DropdownMenu.Overlay - - } - /> - - If defined, will control the open/closed state of the overlay. Must be used in conjuction with{' '} - onOpenChange - - } - /> - - If defined, will control the open/closed state of the overlay. Must be used in conjuction with{' '} - open - - } - /> - - - -### DropdownMenu.Button - - - - ButtonProps - - } - description={ - <> - You can pass all of the props that you would pass to a{' '} - - Button - {' '} - component like variant, leadingIcon,{' '} - sx, etc. - - } - /> - - -### DropdownMenu.Anchor - - - - - -### DropdownMenu.Overlay - - - - Recommended:{' '} - - ActionList - - - } - /> - - Props to be spread on the internal{' '} - - AnchoredOverlay - - - } - /> - - -## Status - - - -## Further reading - -[Interface guidelines: Action List + Menu](https://primer.style/design/components/action-list) - -## Related components - -- [ActionList](/drafts/ActionList2) -- [ActionMenu](/ActionMenu2) -- [SelectPanel](/SelectPanel) diff --git a/src/ActionList2/Selection.tsx b/src/ActionList2/Selection.tsx index bb938ffb83c..159b4d38e40 100644 --- a/src/ActionList2/Selection.tsx +++ b/src/ActionList2/Selection.tsx @@ -10,7 +10,7 @@ type SelectionProps = Pick export const Selection: React.FC = ({selected}) => { const {selectionVariant: listSelectionVariant} = React.useContext(ListContext) const {selectionVariant: groupSelectionVariant} = React.useContext(GroupContext) - const {container, selectionVariant: menuSelectionVariant} = React.useContext(ActionListContainerContext) + const {selectionVariant: menuSelectionVariant} = React.useContext(ActionListContainerContext) /** selectionVariant in Group can override the selectionVariant in List root */ /** fallback to selectionVariant from container menu if any (ActionMenu, DropdownMenu, SelectPanel ) */ @@ -28,18 +28,6 @@ export const Selection: React.FC = ({selected}) => { return null } - if (container === 'ActionMenu') { - throw new Error( - 'ActionList cannot have a selectionVariant inside ActionMenu, please use DropdownMenu or SelectPanel instead. More information: https://primer.style/design/components/action-list#application' - ) - } - - if (container === 'DropdownMenu' && selectionVariant === 'multiple') { - throw new Error( - 'selectionVariant multiple cannot be used in DropdownMenu, please use SelectPanel instead. More information: https://primer.style/design/components/action-list#application' - ) - } - if (selectionVariant === 'single') { return {selected && } } From 74fa5adedebb910125d8f70c9bb0c0548af4344e Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Wed, 9 Feb 2022 15:13:14 +0100 Subject: [PATCH 02/14] merge stories/examples --- src/stories/ActionMenu2/examples.stories.tsx | 229 +++++++++++++++- .../DropdownMenu2/examples.stories.tsx | 246 ------------------ 2 files changed, 223 insertions(+), 252 deletions(-) delete mode 100644 src/stories/DropdownMenu2/examples.stories.tsx diff --git a/src/stories/ActionMenu2/examples.stories.tsx b/src/stories/ActionMenu2/examples.stories.tsx index 0130751049a..04b27b58b22 100644 --- a/src/stories/ActionMenu2/examples.stories.tsx +++ b/src/stories/ActionMenu2/examples.stories.tsx @@ -1,9 +1,20 @@ import React from 'react' import {Meta} from '@storybook/react' -import {ThemeProvider} from '../..' -import BaseStyles from '../../BaseStyles' -import {ActionMenu} from '../../ActionMenu2' -import {ActionList} from '../../ActionList2' +import {ThemeProvider, BaseStyles, Box, Text} from '../..' +import {ActionMenu, ActionList} from '../../drafts' +import { + GearIcon, + MilestoneIcon, + CalendarIcon, + IterationsIcon, + NumberIcon, + SingleSelectIcon, + TypographyIcon, + IssueOpenedIcon, + TableIcon, + PeopleIcon, + XIcon +} from '@primer/octicons-react' const meta: Meta = { title: 'Composite components/ActionMenu2/examples', @@ -25,7 +36,7 @@ const meta: Meta = { } export default meta -export function SimpleListStory(): JSX.Element { +export function MenuWithActions(): JSX.Element { const [actionFired, fireAction] = React.useState('') const onSelect = (name: string) => fireAction(name) @@ -60,4 +71,210 @@ export function SimpleListStory(): JSX.Element { ) } -SimpleListStory.storyName = 'Simple Menu' + +const fieldTypes = [ + {icon: TypographyIcon, name: 'Text'}, + {icon: NumberIcon, name: 'Number'}, + {icon: CalendarIcon, name: 'Date'}, + {icon: SingleSelectIcon, name: 'Single select'}, + {icon: IterationsIcon, name: 'Iteration'} +] + +export function SingleSelection(): JSX.Element { + const [selectedIndex, setSelectedIndex] = React.useState(0) + const selectedType = fieldTypes[selectedIndex] + return ( + <> +

Single Selection

+ +

This pattern has a single section with the selected value shown in the button

+ + + + {selectedType.name} + + + + {fieldTypes.map((type, index) => ( + setSelectedIndex(index)}> + {type.name} + + ))} + + + + + ) +} + +export function SingleSelectionWithPlaceholder(): JSX.Element { + const [selectedIndex, setSelectedIndex] = React.useState(-1) + const selectedType = fieldTypes[selectedIndex] || {} + + return ( + <> +

With placeholder

+ +

This pattern has a placeholder in menu button when no value is selected yet

+ + + + {selectedType.name || 'Pick a field type'} + + + + {fieldTypes.map((type, index) => ( + setSelectedIndex(index)}> + {type.name} + + ))} + + + + + ) +} + +const milestones = [ + {name: 'FY21 - Q2', due: 'December 31, 2021', progress: 90}, + {name: 'FY22 - Q3', due: 'March 31, 2022', progress: 10}, + {name: 'FY23 - Q1', due: 'June 30, 2022', progress: 0}, + {name: 'FY23 - Q2', due: 'December 30, 2022', progress: 0} +] + +export function GroupsAndDescription(): JSX.Element { + const [selectedMilestone, setSelectedMilestone] = React.useState() + + return ( + <> +

Milestone selector

+ + + + Milestone + + + + + {milestones + .filter(milestone => !milestone.name.includes('21')) + .map((milestone, index) => ( + setSelectedMilestone(milestone)} + > + + + + {milestone.name} + Due by {milestone.due} + + ))} + + + {milestones + .filter(milestone => milestone.name.includes('21')) + .map((milestone, index) => ( + setSelectedMilestone(milestone)} + > + + + + {milestone.name} + Due by {milestone.due} + + ))} + + + + + {selectedMilestone ? ( + + {selectedMilestone.name} + + ) : ( + + No milestone + + )} + + + ) +} + +export function MixedSelection(): JSX.Element { + const [selectedIndex, setSelectedIndex] = React.useState(1) + + const options = [ + {text: 'Status', icon: IssueOpenedIcon}, + {text: 'Stage', icon: TableIcon}, + {text: 'Assignee', icon: PeopleIcon}, + {text: 'Team', icon: TypographyIcon}, + {text: 'Estimate', icon: NumberIcon}, + {text: 'Due Date', icon: CalendarIcon} + ] + + const selectedOption = selectedIndex !== null && options[selectedIndex] + + return ( + <> +

List with mixed selection

+ +

+ In this list, there is a ActionList.Group with single selection for picking one option, followed by a Item that + is an action. This pattern appears inside a ActionMenu for selection view options in Memex +

+ + + + {selectedOption ? `Group by ${selectedOption.text}` : 'Group items by'} + + + + + {options.map((option, index) => ( + setSelectedIndex(index)} + > + + + + {option.text} + + ))} + + {typeof selectedIndex === 'number' && ( + + + setSelectedIndex(null)} role="menuitem"> + + + + Clear Group by + + + )} + + + + + ) +} diff --git a/src/stories/DropdownMenu2/examples.stories.tsx b/src/stories/DropdownMenu2/examples.stories.tsx deleted file mode 100644 index a461dbbf47f..00000000000 --- a/src/stories/DropdownMenu2/examples.stories.tsx +++ /dev/null @@ -1,246 +0,0 @@ -import React from 'react' -import {Meta} from '@storybook/react' -import {ThemeProvider} from '../..' -import BaseStyles from '../../BaseStyles' -import {DropdownMenu} from '../../DropdownMenu2' -import {ActionList} from '../../ActionList2' -import Box from '../../Box' -import Text from '../../Text' -import { - GearIcon, - MilestoneIcon, - CalendarIcon, - IterationsIcon, - NumberIcon, - SingleSelectIcon, - TypographyIcon, - IssueOpenedIcon, - TableIcon, - PeopleIcon, - XIcon -} from '@primer/octicons-react' - -const meta: Meta = { - title: 'Composite components/DropdownMenu2/examples', - component: DropdownMenu, - decorators: [ - (Story: React.ComponentType): JSX.Element => ( - - - - - - ) - ], - parameters: { - controls: { - disabled: true - } - } -} -export default meta - -const fieldTypes = [ - {icon: TypographyIcon, name: 'Text'}, - {icon: NumberIcon, name: 'Number'}, - {icon: CalendarIcon, name: 'Date'}, - {icon: SingleSelectIcon, name: 'Single select'}, - {icon: IterationsIcon, name: 'Iteration'} -] - -export function SingleSelection(): JSX.Element { - const [selectedIndex, setSelectedIndex] = React.useState(0) - const selectedType = fieldTypes[selectedIndex] - return ( - <> -

Simple Dropdown Menu

- -

This pattern is the classic dropdown menu - single section with the selected value shown in the button

- - - - {selectedType.name} - - - - {fieldTypes.map((type, index) => ( - setSelectedIndex(index)}> - {type.name} - - ))} - - - - - ) -} - -export function WithPlaceholder(): JSX.Element { - const [selectedIndex, setSelectedIndex] = React.useState(-1) - const selectedType = fieldTypes[selectedIndex] || {} - - return ( - <> -

With placeholder

- -

This pattern is the starting state of the dropdown menu with a placeholder when there is default value

- - - - {selectedType.name || 'Pick a field type'} - - - - {fieldTypes.map((type, index) => ( - setSelectedIndex(index)}> - {type.name} - - ))} - - - - - ) -} - -const milestones = [ - {name: 'FY21 - Q2', due: 'December 31, 2021', progress: 90}, - {name: 'FY22 - Q3', due: 'March 31, 2022', progress: 10}, - {name: 'FY23 - Q1', due: 'June 30, 2022', progress: 0}, - {name: 'FY23 - Q2', due: 'December 30, 2022', progress: 0} -] - -export function GroupsAndDescription(): JSX.Element { - const [selectedMilestone, setSelectedMilestone] = React.useState() - - return ( - <> -

Milestone selector

- - - - Milestone - - - - - {milestones - .filter(milestone => !milestone.name.includes('21')) - .map((milestone, index) => ( - setSelectedMilestone(milestone)} - > - - - - {milestone.name} - Due by {milestone.due} - - ))} - - - {milestones - .filter(milestone => milestone.name.includes('21')) - .map((milestone, index) => ( - setSelectedMilestone(milestone)} - > - - - - {milestone.name} - Due by {milestone.due} - - ))} - - - - - {selectedMilestone ? ( - - {selectedMilestone.name} - - ) : ( - - No milestone - - )} - - - ) -} - -export function MixedSelection(): JSX.Element { - const [selectedIndex, setSelectedIndex] = React.useState(1) - - const options = [ - {text: 'Status', icon: IssueOpenedIcon}, - {text: 'Stage', icon: TableIcon}, - {text: 'Assignee', icon: PeopleIcon}, - {text: 'Team', icon: TypographyIcon}, - {text: 'Estimate', icon: NumberIcon}, - {text: 'Due Date', icon: CalendarIcon} - ] - - const selectedOption = selectedIndex && options[selectedIndex] - - return ( - <> -

List with mixed selection

- -

- In this list, there is a ActionList.Group with single selection for picking one option, followed by a Item that - is an action. This pattern appears inside a DropdownMenu for selection view options in Memex -

- - - - {selectedOption ? `Group by ${selectedOption.text}` : 'Group items by'} - - - - - {options.map((option, index) => ( - setSelectedIndex(index)} - > - {option.icon} - {option.text} - - ))} - - {typeof selectedIndex === 'number' && ( - - - setSelectedIndex(null)} role="menuitem"> - - - - Clear Group by - - - )} - - - - - ) -} From e2513ea7872ae7037fe442b4d87769d962c6ab94 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Wed, 9 Feb 2022 15:26:34 +0100 Subject: [PATCH 03/14] merge stories/fixtures --- src/stories/ActionMenu2/fixtures.stories.tsx | 170 ++++++--- .../DropdownMenu2/fixtures.stories.tsx | 335 ------------------ 2 files changed, 116 insertions(+), 389 deletions(-) delete mode 100644 src/stories/DropdownMenu2/fixtures.stories.tsx diff --git a/src/stories/ActionMenu2/fixtures.stories.tsx b/src/stories/ActionMenu2/fixtures.stories.tsx index 3fbf73013dc..0d1b8f3d303 100644 --- a/src/stories/ActionMenu2/fixtures.stories.tsx +++ b/src/stories/ActionMenu2/fixtures.stories.tsx @@ -1,16 +1,7 @@ import React from 'react' import {Meta} from '@storybook/react' -import {ThemeProvider} from '../..' -import BaseStyles from '../../BaseStyles' -import {ActionMenu} from '../../ActionMenu2' -import {ActionList} from '../../ActionList2' -import {Button} from '../../Button2' -import {IconButton} from '../../Button2/IconButton' -import Box from '../../Box' -import Text from '../../Text' -import TextInput from '../../TextInput' -import StyledOcticon from '../../StyledOcticon' -import FormGroup from '../../FormGroup' +import {ThemeProvider, BaseStyles, Box, Text, TextInput, StyledOcticon, FormGroup, ProgressBar} from '../..' +import {ActionMenu, ActionList, Button, IconButton} from '../../drafts' import { ServerIcon, PlusCircleIcon, @@ -25,6 +16,11 @@ import { SearchIcon, VersionsIcon, TableIcon, + CalendarIcon, + IterationsIcon, + NumberIcon, + SingleSelectIcon, + TypographyIcon, IconProps } from '@primer/octicons-react' @@ -86,7 +82,6 @@ export function ActionsStory(): JSX.Element { ) } -ActionsStory.storyName = 'Actions' export function ExternalAnchor(): JSX.Element { const [actionFired, fireAction] = React.useState('') @@ -133,7 +128,6 @@ export function ExternalAnchor(): JSX.Element { ) } -ExternalAnchor.storyName = 'External Anchor' export function ControlledMenu(): JSX.Element { const [actionFired, fireAction] = React.useState('') @@ -191,7 +185,6 @@ export function ControlledMenu(): JSX.Element { ) } -ControlledMenu.storyName = 'Controlled Menu' export function CustomAnchor(): JSX.Element { const [actionFired, fireAction] = React.useState('') @@ -233,7 +226,6 @@ export function CustomAnchor(): JSX.Element { ) } -CustomAnchor.storyName = 'Custom Anchor' export function MemexTableMenu(): JSX.Element { const [name, setName] = React.useState('Estimate') @@ -297,7 +289,6 @@ export function MemexTableMenu(): JSX.Element { ) } -MemexTableMenu.storyName = 'Memex Table Menu' /* copied from github/memex */ const LayoutToggleItem = ({ @@ -408,19 +399,15 @@ export function MemexViewOptionsMenu(): JSX.Element { React - - - + + + @@ -495,7 +482,105 @@ export function MemexViewOptionsMenu(): JSX.Element { ) } -MemexViewOptionsMenu.storyName = 'Memex View Options Menu' + +export function MemexIteration(): JSX.Element { + const [duration, setDuration] = React.useState(1) + + return ( + <> +

Memex Iteration Menu

+ + + + {duration} {duration > 1 ? 'weeks' : 'week'} + + + + {[1, 2, 3, 4, 5, 6].map(weeks => ( + setDuration(weeks)}> + {weeks} {weeks > 1 ? 'weeks' : 'week'} + + ))} + + + + + ) +} + +const fieldTypes = [ + {icon: TypographyIcon, name: 'Text'}, + {icon: NumberIcon, name: 'Number'}, + {icon: CalendarIcon, name: 'Date'}, + {icon: SingleSelectIcon, name: 'Single select'}, + {icon: IterationsIcon, name: 'Iteration'} +] + +export function MemexAddColumn(): JSX.Element { + const [selectedIndex, setSelectedIndex] = React.useState(0) + const selectedType = fieldTypes[selectedIndex] + + const [duration, setDuration] = React.useState(1) + + return ( + <> +

Memex Add column

+ + + + + + {selectedType.name} + + + + {fieldTypes.map((type, index) => ( + setSelectedIndex(index)} + > + {type.icon} {type.name} + + ))} + + + + Options + + + Duration: + + + {duration} {duration > 1 ? 'weeks' : 'week'} + + + + {[1, 2, 3, 4, 5, 6].map(weeks => ( + setDuration(weeks)}> + {weeks} {weeks > 1 ? 'weeks' : 'week'} + + ))} + + + + + + + ) +} export function OverlayProps(): JSX.Element { const [open, setOpen] = React.useState(false) @@ -534,30 +619,7 @@ export function OverlayProps(): JSX.Element {


- - - ) -} -OverlayProps.storyName = 'Overlay Props' - -export function UnexpectedSelectionVariant(): JSX.Element { - return ( - <> -

Expect error if selectionVariant is passed

- - - Menu - - - Copy link - Quote reply - Edit comment - - Delete file - - - + ) } -UnexpectedSelectionVariant.storyName = 'Unexpected selectionVariant' diff --git a/src/stories/DropdownMenu2/fixtures.stories.tsx b/src/stories/DropdownMenu2/fixtures.stories.tsx deleted file mode 100644 index d39782e46c1..00000000000 --- a/src/stories/DropdownMenu2/fixtures.stories.tsx +++ /dev/null @@ -1,335 +0,0 @@ -import React from 'react' -import {Meta} from '@storybook/react' -import {ThemeProvider} from '../..' -import BaseStyles from '../../BaseStyles' -import {DropdownMenu} from '../../DropdownMenu2' -import {ActionList} from '../../ActionList2' -import {Button} from '../../Button2' -import Box from '../../Box' -import Text from '../../Text' -import TextInput from '../../TextInput' -import ProgressBar from '../../ProgressBar' -import { - GearIcon, - MilestoneIcon, - CalendarIcon, - IterationsIcon, - NumberIcon, - SingleSelectIcon, - TypographyIcon -} from '@primer/octicons-react' - -const meta: Meta = { - title: 'Composite components/DropdownMenu2/fixtures', - component: DropdownMenu, - decorators: [ - (Story: React.ComponentType): JSX.Element => ( - - - - - - ) - ], - parameters: { - controls: { - disabled: true - } - } -} -export default meta - -const fieldTypes = [ - {icon: TypographyIcon, name: 'Text'}, - {icon: NumberIcon, name: 'Number'}, - {icon: CalendarIcon, name: 'Date'}, - {icon: SingleSelectIcon, name: 'Single select'}, - {icon: IterationsIcon, name: 'Iteration'} -] - -export function SimpleDropdownMenu(): JSX.Element { - const [selectedIndex, setSelectedIndex] = React.useState(0) - const selectedType = fieldTypes[selectedIndex] - return ( - <> -

Simple Dropdown Menu

- - - - {selectedType.name} - - - - {fieldTypes.map((type, index) => ( - setSelectedIndex(index)}> - {type.name} - - ))} - - - - - ) -} -SimpleDropdownMenu.storyName = 'Simple DropdownMenu' - -export function Placeholder(): JSX.Element { - const [selectedIndex, setSelectedIndex] = React.useState(-1) - const selectedType = fieldTypes[selectedIndex] || {} - - return ( - <> -

With placeholder

- - - - {selectedType.name || 'Pick a field type'} - - - - {fieldTypes.map((type, index) => ( - setSelectedIndex(index)}> - {type.name} - - ))} - - - - - ) -} -Placeholder.storyName = 'Placeholder' - -export function MemexIteration(): JSX.Element { - const [duration, setDuration] = React.useState(1) - - return ( - <> -

Memex Iteration Menu

- - - - {duration} {duration > 1 ? 'weeks' : 'week'} - - - - {[1, 2, 3, 4, 5, 6].map(weeks => ( - setDuration(weeks)}> - {weeks} {weeks > 1 ? 'weeks' : 'week'} - - ))} - - - - - ) -} -MemexIteration.storyName = 'Memex Iteration Menu' - -const milestones = [ - {name: 'v29.2.0', due: 'September 30, 2021', progress: 95}, - {name: 'v30.0.0', due: 'December 1, 2021', progress: 40}, - {name: 'FY22 - Q3', due: 'December 31, 2021', progress: 10} -] - -export function MemexAddColumn(): JSX.Element { - const [selectedIndex, setSelectedIndex] = React.useState(0) - const selectedType = fieldTypes[selectedIndex] - - const [duration, setDuration] = React.useState(1) - - return ( - <> -

Memex Add column

- - - - - - {selectedType.name} - - - - {fieldTypes.map((type, index) => ( - setSelectedIndex(index)} - > - {type.icon} {type.name} - - ))} - - - - Options - - - Duration: - - - {duration} {duration > 1 ? 'weeks' : 'week'} - - - - {[1, 2, 3, 4, 5, 6].map(weeks => ( - setDuration(weeks)}> - {weeks} {weeks > 1 ? 'weeks' : 'week'} - - ))} - - - - - - - ) -} -MemexAddColumn.storyName = 'Memex Add Column' - -export function MilestoneStory(): JSX.Element { - const [selectedIndex, setSelectedIndex] = React.useState(-1) - - const selectedMilestone = milestones[selectedIndex] as typeof milestones[0] | undefined - - return ( - <> -

Milestone selector

- - - - - - - - - - {milestones.map((milestone, index) => ( - setSelectedIndex(index)} - > - - - - {milestone.name} - Due by {milestone.due} - - ))} - - - - {selectedMilestone ? ( - - - {selectedMilestone.name} - - ) : ( - - No milestone - - )} - - - ) -} -MilestoneStory.storyName = 'Milestone selector' - -export function ControlledMenu(): JSX.Element { - const [open, setOpen] = React.useState(false) - const [selectedIndex, setSelectedIndex] = React.useState(0) - - return ( - <> -

Controlled Menu

- -

External Open State: {open ? 'Open' : 'Closed'}

-

selected Value: {fieldTypes[selectedIndex].name}

- - - setOpen(!open)}>{open ? 'Close Menu' : 'Open Menu'} - - - {fieldTypes.map((type, index) => ( - setSelectedIndex(index)}> - {type.name} - - ))} - - - - - ) -} -ControlledMenu.storyName = 'Controlled Menu' - -export function ExternalAnchor(): JSX.Element { - const [open, setOpen] = React.useState(false) - const triggerRef = React.createRef() - const anchorRef = React.createRef() - - const [selectedIndex, setSelectedIndex] = React.useState(0) - - return ( - <> -

External Anchor

- -

External Open State: {open ? 'Open' : 'Closed'}

-

selected Value: {fieldTypes[selectedIndex].name}

- - - - - Anchored on me! - - - - - - {fieldTypes.map((type, index) => ( - setSelectedIndex(index)}> - {type.name} - - ))} - - - - - ) -} -ExternalAnchor.storyName = 'External Anchor' From b53f87a20850ae5280d77bca7ddd44cf13cf0a52 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Wed, 9 Feb 2022 15:41:31 +0100 Subject: [PATCH 04/14] move itemRole to item and delete dropdonwmenu --- .../ActionListContainerContext.tsx | 2 - src/ActionList2/Item.tsx | 20 ++- src/ActionList2/Selection.tsx | 20 +-- src/ActionMenu2.tsx | 2 +- src/DropdownMenu2.tsx | 135 ------------------ src/drafts/index.ts | 1 - src/utils/types/AriaRole.ts | 2 +- 7 files changed, 29 insertions(+), 153 deletions(-) delete mode 100644 src/DropdownMenu2.tsx diff --git a/src/ActionList2/ActionListContainerContext.tsx b/src/ActionList2/ActionListContainerContext.tsx index 23cc31e9720..53e703d09d8 100644 --- a/src/ActionList2/ActionListContainerContext.tsx +++ b/src/ActionList2/ActionListContainerContext.tsx @@ -5,8 +5,6 @@ import React from 'react' type ContextProps = { container?: string listRole?: string - itemRole?: string - selectionVariant?: 'single' | 'multiple' selectionAttribute?: 'aria-selected' | 'aria-checked' listLabelledBy?: string // This can be any function, we don't know anything about the arguments diff --git a/src/ActionList2/Item.tsx b/src/ActionList2/Item.tsx index ed93839c053..d90c1b74331 100644 --- a/src/ActionList2/Item.tsx +++ b/src/ActionList2/Item.tsx @@ -7,7 +7,8 @@ import Box, {BoxProps} from '../Box' import sx, {SxProp, merge} from '../sx' import createSlots from '../utils/create-slots' import {AriaRole} from '../utils/types' -import {ListContext} from './List' +import {ListContext, ListProps} from './List' +import {GroupContext, GroupProps} from './Group' import {ActionListContainerContext} from './ActionListContainerContext' import {Selection} from './Selection' @@ -101,8 +102,21 @@ export const Item = React.forwardRef( }, forwardedRef ): JSX.Element => { - const {variant: listVariant, showDividers} = React.useContext(ListContext) - const {itemRole, afterSelect, selectionAttribute = 'aria-selected'} = React.useContext(ActionListContainerContext) + const {variant: listVariant, showDividers, selectionVariant: listSelectionVariant} = React.useContext(ListContext) + const {selectionVariant: groupSelectionVariant} = React.useContext(GroupContext) + const {container, afterSelect, selectionAttribute = 'aria-selected'} = React.useContext(ActionListContainerContext) + + let selectionVariant: ListProps['selectionVariant'] | GroupProps['selectionVariant'] + if (typeof groupSelectionVariant !== 'undefined') selectionVariant = groupSelectionVariant + else selectionVariant = listSelectionVariant + + /** Infer item role based on the container */ + let itemRole: ItemProps['role'] + if (container === 'ActionMenu') { + if (selectionVariant === 'single') itemRole = 'menuitemradio' + else if (selectionVariant === 'multiple') itemRole = 'menuitemcheckbox' + else itemRole = 'menuitem' + } const {theme} = useTheme() diff --git a/src/ActionList2/Selection.tsx b/src/ActionList2/Selection.tsx index 159b4d38e40..d7f3855b51f 100644 --- a/src/ActionList2/Selection.tsx +++ b/src/ActionList2/Selection.tsx @@ -1,8 +1,7 @@ import React from 'react' import {CheckIcon} from '@primer/octicons-react' -import {ListContext} from './List' -import {GroupContext} from './Group' -import {ActionListContainerContext} from './ActionListContainerContext' +import {ListContext, ListProps} from './List' +import {GroupContext, GroupProps} from './Group' import {ItemProps} from './Item' import {LeadingVisualContainer} from './Visuals' @@ -10,22 +9,23 @@ type SelectionProps = Pick export const Selection: React.FC = ({selected}) => { const {selectionVariant: listSelectionVariant} = React.useContext(ListContext) const {selectionVariant: groupSelectionVariant} = React.useContext(GroupContext) - const {selectionVariant: menuSelectionVariant} = React.useContext(ActionListContainerContext) /** selectionVariant in Group can override the selectionVariant in List root */ /** fallback to selectionVariant from container menu if any (ActionMenu, DropdownMenu, SelectPanel ) */ - let selectionVariant + let selectionVariant: ListProps['selectionVariant'] | GroupProps['selectionVariant'] if (typeof groupSelectionVariant !== 'undefined') selectionVariant = groupSelectionVariant - else selectionVariant = listSelectionVariant || menuSelectionVariant + else selectionVariant = listSelectionVariant - // if selectionVariant is not set on List, don't show selection if (!selectionVariant) { - // to avoid confusion, fail loudly instead of silently ignoring - if (selected) + // if selectionVariant is not set on List, but Item is selected + // fail loudly instead of silently ignoring + if (selected) { throw new Error( 'For Item to be selected, ActionList or ActionList.Group needs to have a selectionVariant defined' ) - return null + } else { + return null + } } if (selectionVariant === 'single') { diff --git a/src/ActionMenu2.tsx b/src/ActionMenu2.tsx index d01a04f7a13..5819eeca3d6 100644 --- a/src/ActionMenu2.tsx +++ b/src/ActionMenu2.tsx @@ -113,8 +113,8 @@ const Overlay: React.FC = ({children, ...overlayProps}) => { value={{ container: 'ActionMenu', listRole: 'menu', - itemRole: 'menuitem', listLabelledBy: anchorId, + selectionAttribute: 'aria-checked', // Should this be here? afterSelect: onClose }} > diff --git a/src/DropdownMenu2.tsx b/src/DropdownMenu2.tsx deleted file mode 100644 index 20f48e6e032..00000000000 --- a/src/DropdownMenu2.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import React from 'react' -import {useSSRSafeId} from '@react-aria/ssr' -import {TriangleDownIcon} from '@primer/octicons-react' -import {Button, ButtonProps} from './Button2' -import {AnchoredOverlay, AnchoredOverlayProps} from './AnchoredOverlay' -import {OverlayProps} from './Overlay' -import {useProvidedRefOrCreate, useProvidedStateOrCreate, useMenuInitialFocus} from './hooks' -import {Divider} from './ActionList2/Divider' -import {ActionListContainerContext} from './ActionList2/ActionListContainerContext' -import {MandateProps} from './utils/types' - -type MenuContextProps = Pick< - AnchoredOverlayProps, - 'anchorRef' | 'renderAnchor' | 'open' | 'onOpen' | 'onClose' | 'anchorId' -> -const MenuContext = React.createContext({renderAnchor: null, open: false}) - -export type DropdownMenuProps = { - /** - * Recommended: `ActionMenu.Button` or `ActionMenu.Anchor` with `ActionMenu.Overlay` - */ - children: React.ReactElement[] | React.ReactElement - - /** - * If defined, will control the open/closed state of the overlay. Must be used in conjuction with `onOpenChange`. - */ - open?: boolean - - /** - * If defined, will control the open/closed state of the overlay. Must be used in conjuction with `open`. - */ - onOpenChange?: (s: boolean) => void -} & Pick - -const Menu: React.FC = ({ - anchorRef: externalAnchorRef, - open, - onOpenChange, - children -}: DropdownMenuProps) => { - const [combinedOpenState, setCombinedOpenState] = useProvidedStateOrCreate(open, onOpenChange, false) - const onOpen = React.useCallback(() => setCombinedOpenState(true), [setCombinedOpenState]) - const onClose = React.useCallback(() => setCombinedOpenState(false), [setCombinedOpenState]) - - const anchorRef = useProvidedRefOrCreate(externalAnchorRef) - const anchorId = useSSRSafeId() - let renderAnchor: AnchoredOverlayProps['renderAnchor'] = null - - // 🚨 Hack for good API! - // we strip out Anchor from children and pass it to AnchoredOverlay to render - // with additional props for accessibility - const contents = React.Children.map(children, child => { - if (child.type === MenuButton || child.type === Anchor) { - renderAnchor = anchorProps => React.cloneElement(child, anchorProps) - return null - } - return child - }) - - return ( - - {contents} - - ) -} - -export type DropdownMenuAnchorProps = {children: React.ReactElement} -const Anchor = React.forwardRef( - ({children, ...anchorProps}, anchorRef) => { - return React.cloneElement(children, {...anchorProps, ref: anchorRef}) - } -) - -/** this component is syntactical sugar 🍭 */ -export type DropdownMenuButtonProps = ButtonProps -const MenuButton = React.forwardRef( - ({children, ...props}, anchorRef) => { - return ( - - - - ) - } -) - -type MenuOverlayProps = Partial & { - /** - * Recommended: `ActionList` - */ - children: React.ReactElement[] | React.ReactElement -} -const Overlay: React.FC = ({children, ...overlayProps}) => { - // we typecast anchorRef as required instead of optional - // because we know that we're setting it in context in Menu - const {anchorRef, renderAnchor, anchorId, open, onOpen, onClose} = React.useContext(MenuContext) as MandateProps< - MenuContextProps, - 'anchorRef' - > - - const {containerRef, openWithFocus} = useMenuInitialFocus(open, onOpen) - - return ( - -
- - {children} - -
-
- ) -} - -Menu.displayName = 'ActionMenu' -export const DropdownMenu = Object.assign(Menu, {Button: MenuButton, Anchor, Overlay, Divider}) diff --git a/src/drafts/index.ts b/src/drafts/index.ts index 291e56dafbb..cbef05e7eaa 100644 --- a/src/drafts/index.ts +++ b/src/drafts/index.ts @@ -9,6 +9,5 @@ export * from '../ActionList2' export * from '../Button2' export * from '../ActionMenu2' -export * from '../DropdownMenu2' export * from '../Label2' export * from '../PageLayout' diff --git a/src/utils/types/AriaRole.ts b/src/utils/types/AriaRole.ts index 87471c6c67f..b1d1d1f4914 100644 --- a/src/utils/types/AriaRole.ts +++ b/src/utils/types/AriaRole.ts @@ -35,7 +35,7 @@ export type AriaRole = | 'menu' | 'menubar' | 'menuitem' - | 'menuitemcheckbox ' + | 'menuitemcheckbox' | 'menuitemradio' | 'navigation' | 'none' From 24c1cbf29b1e0cbaf8436fbb11fc64f7f56c66c3 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Wed, 9 Feb 2022 16:31:38 +0100 Subject: [PATCH 05/14] add test for single selection --- src/ActionList2/Selection.tsx | 2 +- src/__tests__/ActionMenu2.test.tsx | 86 +++++++----- src/__tests__/DropdownMenu2.test.tsx | 131 ------------------- src/stories/ActionList2/examples.stories.tsx | 4 +- src/stories/ActionMenu2/examples.stories.tsx | 69 +++++++++- src/stories/ActionMenu2/fixtures.stories.tsx | 2 +- 6 files changed, 123 insertions(+), 171 deletions(-) delete mode 100644 src/__tests__/DropdownMenu2.test.tsx diff --git a/src/ActionList2/Selection.tsx b/src/ActionList2/Selection.tsx index d7f3855b51f..acc86509893 100644 --- a/src/ActionList2/Selection.tsx +++ b/src/ActionList2/Selection.tsx @@ -11,7 +11,7 @@ export const Selection: React.FC = ({selected}) => { const {selectionVariant: groupSelectionVariant} = React.useContext(GroupContext) /** selectionVariant in Group can override the selectionVariant in List root */ - /** fallback to selectionVariant from container menu if any (ActionMenu, DropdownMenu, SelectPanel ) */ + /** fallback to selectionVariant from container menu if any (ActionMenu, SelectPanel ) */ let selectionVariant: ListProps['selectionVariant'] | GroupProps['selectionVariant'] if (typeof groupSelectionVariant !== 'undefined') selectionVariant = groupSelectionVariant else selectionVariant = listSelectionVariant diff --git a/src/__tests__/ActionMenu2.test.tsx b/src/__tests__/ActionMenu2.test.tsx index 88f9d35b6d9..d4a51478f91 100644 --- a/src/__tests__/ActionMenu2.test.tsx +++ b/src/__tests__/ActionMenu2.test.tsx @@ -4,13 +4,13 @@ import {axe, toHaveNoViolations} from 'jest-axe' import React from 'react' import theme from '../theme' import {ActionMenu} from '../ActionMenu2' -import {ActionList} from '../ActionList2' +import {ActionList, ActionListProps} from '../ActionList2' import {behavesAsComponent, checkExports, checkStoriesForAxeViolations} from '../utils/testing' import {BaseStyles, ThemeProvider, SSRProvider} from '..' import '@testing-library/jest-dom' expect.extend(toHaveNoViolations) -function SimpleActionMenu(): JSX.Element { +function Example(): JSX.Element { return ( @@ -35,11 +35,42 @@ function SimpleActionMenu(): JSX.Element { ) } +function WithSelection({selectionVariant}: {selectionVariant?: ActionListProps['selectionVariant']}): JSX.Element { + const fieldTypes = ['Text', 'Number', 'Date', 'Single select', 'Iteration'] + const [selectedIndex, setSelectedIndex] = React.useState(0) + const selectedType = fieldTypes[selectedIndex] + + return ( + + + + + {selectedType} + + + {fieldTypes.map((type, index) => ( + setSelectedIndex(index)} + > + {selectedType} + + ))} + + + + + + + ) +} + describe('ActionMenu', () => { behavesAsComponent({ Component: ActionList, options: {skipAs: true, skipSx: true}, - toRender: () => + toRender: () => }) checkExports('ActionMenu2', { @@ -48,7 +79,7 @@ describe('ActionMenu', () => { }) it('should open Menu on MenuButton click', async () => { - const component = HTMLRender() + const component = HTMLRender() const button = component.getByText('Toggle Menu') fireEvent.click(button) expect(component.getByRole('menu')).toBeInTheDocument() @@ -56,7 +87,7 @@ describe('ActionMenu', () => { }) it('should open Menu on MenuButton keypress', async () => { - const component = HTMLRender() + const component = HTMLRender() const button = component.getByText('Toggle Menu') // We pass keycode here to navigate a implementation detail in react-testing-library @@ -67,7 +98,7 @@ describe('ActionMenu', () => { }) it('should close Menu on selecting an action with click', async () => { - const component = HTMLRender() + const component = HTMLRender() const button = component.getByText('Toggle Menu') fireEvent.click(button) @@ -79,7 +110,7 @@ describe('ActionMenu', () => { }) it('should close Menu on selecting an action with Enter', async () => { - const component = HTMLRender() + const component = HTMLRender() const button = component.getByText('Toggle Menu') fireEvent.click(button) @@ -91,7 +122,7 @@ describe('ActionMenu', () => { }) it('should not close Menu if event is prevented', async () => { - const component = HTMLRender() + const component = HTMLRender() const button = component.getByText('Toggle Menu') fireEvent.click(button) @@ -103,38 +134,23 @@ describe('ActionMenu', () => { cleanup() }) - it('should throw when selectionVariant is provided to ActionList within ActionMenu', async () => { - // we expect console.error to be called, so we suppress that in the test - const mockError = jest.spyOn(console, 'error').mockImplementation(() => jest.fn()) - - expect(() => { - const component = HTMLRender( - - - - - Toggle Menu - - - Primer React - - - - - - - ) - - const button = component.getByText('Toggle Menu') - fireEvent.click(button) - }).toThrow('ActionList cannot have a selectionVariant inside ActionMenu') + it('should be able to select an Item with selectionVariant', async () => { + const component = HTMLRender() + const button = component.getByLabelText('Select field type') + fireEvent.click(button) + + // select first item by role, that would close the menu + fireEvent.click(component.getAllByRole('menuitemradio')[0]) + expect(component.queryByRole('menu')).not.toBeInTheDocument() + // open menu again and check if the first option is checked + fireEvent.click(button) + expect(component.getAllByRole('menuitemradio')[0]).toHaveAttribute('aria-checked', 'true') cleanup() - mockError.mockRestore() }) it('should have no axe violations', async () => { - const {container} = HTMLRender() + const {container} = HTMLRender() const results = await axe(container) expect(results).toHaveNoViolations() cleanup() diff --git a/src/__tests__/DropdownMenu2.test.tsx b/src/__tests__/DropdownMenu2.test.tsx deleted file mode 100644 index 1379cecd6ed..00000000000 --- a/src/__tests__/DropdownMenu2.test.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import {cleanup, render as HTMLRender, waitFor, fireEvent} from '@testing-library/react' -import 'babel-polyfill' -import {toHaveNoViolations} from 'jest-axe' -import React from 'react' -import theme from '../theme' -import {DropdownMenu} from '../DropdownMenu2' -import {ActionList} from '../ActionList2' -import {behavesAsComponent, checkExports, checkStoriesForAxeViolations} from '../utils/testing' -import {BaseStyles, ThemeProvider, SSRProvider} from '..' -import '@testing-library/jest-dom' -expect.extend(toHaveNoViolations) - -function SimpleDropdownMenu(): JSX.Element { - const fieldTypes = [{name: 'Text'}, {name: 'Number'}, {name: 'Date'}, {name: 'Single select'}, {name: 'Iteration'}] - const [selectedIndex, setSelectedIndex] = React.useState(0) - const selectedType = fieldTypes[selectedIndex] - - return ( - - - - - {selectedType.name} - - - {fieldTypes.map((type, index) => ( - setSelectedIndex(index)} - > - {type.name} - - ))} - - - - - - - ) -} - -describe('DropdownMenu', () => { - behavesAsComponent({ - Component: ActionList, - options: {skipAs: true, skipSx: true}, - toRender: () => - }) - - checkExports('DropdownMenu2', { - default: undefined, - DropdownMenu - }) - - it('should open Menu on Button click', async () => { - const component = HTMLRender() - const button = component.getByLabelText('Select field type') - fireEvent.click(button) - expect(component.getByRole('menu')).toBeInTheDocument() - cleanup() - }) - - it('should open Menu on Button keypress', async () => { - const component = HTMLRender() - const button = component.getByLabelText('Select field type') - - // We pass keycode here to navigate a implementation detail in react-testing-library - // https://github.com/testing-library/react-testing-library/issues/269#issuecomment-455854112 - fireEvent.keyDown(button, {key: 'Enter', charCode: 13}) - expect(component.getByRole('menu')).toBeInTheDocument() - cleanup() - }) - - it('should close Menu on selecting an action with click', async () => { - const component = HTMLRender() - const button = component.getByLabelText('Select field type') - - fireEvent.click(button) - const menuItems = await waitFor(() => component.getAllByRole('menuitemradio')) - fireEvent.click(menuItems[0]) - expect(component.queryByRole('menu')).toBeNull() - - cleanup() - }) - - it('should close Menu on selecting an action with Enter', async () => { - const component = HTMLRender() - const button = component.getByLabelText('Select field type') - - fireEvent.click(button) - const menuItems = await waitFor(() => component.getAllByRole('menuitemradio')) - fireEvent.keyPress(menuItems[0], {key: 'Enter', charCode: 13}) - expect(component.queryByRole('menu')).toBeNull() - - cleanup() - }) - - it('should throw when selectionVariant=multiple is provided to ActionList within DropdownMenu', async () => { - // we expect console.error to be called, so we suppress that in the test - const mockError = jest.spyOn(console, 'error').mockImplementation(() => jest.fn()) - - expect(() => { - const component = HTMLRender( - - - - - Select a field - - - Primer React - - - - - - - ) - - const button = component.getByText('Select a field') - fireEvent.click(button) - }).toThrow('selectionVariant multiple cannot be used in DropdownMenu') - - cleanup() - mockError.mockRestore() - }) - - checkStoriesForAxeViolations('DropdownMenu2/fixtures') - checkStoriesForAxeViolations('DropdownMenu2/examples') -}) diff --git a/src/stories/ActionList2/examples.stories.tsx b/src/stories/ActionList2/examples.stories.tsx index 5f27efe6da5..3f8ed97b884 100644 --- a/src/stories/ActionList2/examples.stories.tsx +++ b/src/stories/ActionList2/examples.stories.tsx @@ -108,7 +108,7 @@ export function SingleSelection(): JSX.Element { <>

Single Selection

-

This pattern appears inside a nested DropdownMenu in Memex view options.

+

This pattern appears inside a nested menu in Memex view options.

{options.map((option, index) => ( @@ -266,7 +266,7 @@ export function MixedSelection(): JSX.Element {

In this list, there is a ActionList.Group with single selection for picking one option, followed by a Item that - is an action. This pattern appears inside a DropdownMenu for selection view options in Memex + is an action. This pattern appears inside a menu for selection view options in Memex

diff --git a/src/stories/ActionMenu2/examples.stories.tsx b/src/stories/ActionMenu2/examples.stories.tsx index 04b27b58b22..de5139c5463 100644 --- a/src/stories/ActionMenu2/examples.stories.tsx +++ b/src/stories/ActionMenu2/examples.stories.tsx @@ -1,6 +1,6 @@ import React from 'react' import {Meta} from '@storybook/react' -import {ThemeProvider, BaseStyles, Box, Text} from '../..' +import {ThemeProvider, BaseStyles, Box, Text, Avatar} from '../..' import {ActionMenu, ActionList} from '../../drafts' import { GearIcon, @@ -218,6 +218,73 @@ export function GroupsAndDescription(): JSX.Element { ) } +const users = [ + {login: 'pksjce', name: 'Pavithra Kodmad'}, + {login: 'jfuchs', name: 'Jonathan Fuchs'}, + {login: 'colebemis', name: 'Cole Bemis'}, + {login: 'mperrotti', name: 'Mike Perrotti'}, + {login: 'dgreif', name: 'Dusty Greif'}, + {login: 'smockle', name: 'Clay Miller'}, + {login: 'siddharthkp', name: 'Siddharth Kshetrapal'} +] + +export function MultipleSelection(): JSX.Element { + const [assignees, setAssignees] = React.useState(users.slice(0, 2)) + + const toggleAssignee = (assignee: typeof users[number]) => { + const assigneeIndex = assignees.findIndex(a => a.login === assignee.login) + + if (assigneeIndex === -1) setAssignees([...assignees, assignee]) + else setAssignees(assignees.filter((_, index) => index !== assigneeIndex)) + } + + return ( + <> +

Multi Select List

+ +

ActionMenu with multiple selection is not seen in production. You see SelectPanel used instead.

+ + + + + Assignees + + + + {users.map(user => ( + assignee.login === user.login))} + onSelect={() => toggleAssignee(user)} + > + + + + {user.login} + {user.name} + + ))} + + + + + + ) +} + export function MixedSelection(): JSX.Element { const [selectedIndex, setSelectedIndex] = React.useState(1) diff --git a/src/stories/ActionMenu2/fixtures.stories.tsx b/src/stories/ActionMenu2/fixtures.stories.tsx index 0d1b8f3d303..1a279b1d46b 100644 --- a/src/stories/ActionMenu2/fixtures.stories.tsx +++ b/src/stories/ActionMenu2/fixtures.stories.tsx @@ -1,6 +1,6 @@ import React from 'react' import {Meta} from '@storybook/react' -import {ThemeProvider, BaseStyles, Box, Text, TextInput, StyledOcticon, FormGroup, ProgressBar} from '../..' +import {ThemeProvider, BaseStyles, Box, Text, TextInput, StyledOcticon, FormGroup} from '../..' import {ActionMenu, ActionList, Button, IconButton} from '../../drafts' import { ServerIcon, From 25aede5877f5b02c059e0ccef2cf8e407c2f95b6 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Wed, 9 Feb 2022 17:13:56 +0100 Subject: [PATCH 06/14] Add tests for additional use cases --- src/__tests__/ActionMenu2.test.tsx | 55 ++++++++------------ src/stories/ActionMenu2/examples.stories.tsx | 12 ++++- 2 files changed, 32 insertions(+), 35 deletions(-) diff --git a/src/__tests__/ActionMenu2.test.tsx b/src/__tests__/ActionMenu2.test.tsx index d4a51478f91..20cfb9fcda2 100644 --- a/src/__tests__/ActionMenu2.test.tsx +++ b/src/__tests__/ActionMenu2.test.tsx @@ -4,9 +4,10 @@ import {axe, toHaveNoViolations} from 'jest-axe' import React from 'react' import theme from '../theme' import {ActionMenu} from '../ActionMenu2' -import {ActionList, ActionListProps} from '../ActionList2' +import {ActionList} from '../ActionList2' import {behavesAsComponent, checkExports, checkStoriesForAxeViolations} from '../utils/testing' import {BaseStyles, ThemeProvider, SSRProvider} from '..' +import {SingleSelection, MixedSelection} from '../../src/stories/ActionMenu2/examples.stories' import '@testing-library/jest-dom' expect.extend(toHaveNoViolations) @@ -35,37 +36,6 @@ function Example(): JSX.Element { ) } -function WithSelection({selectionVariant}: {selectionVariant?: ActionListProps['selectionVariant']}): JSX.Element { - const fieldTypes = ['Text', 'Number', 'Date', 'Single select', 'Iteration'] - const [selectedIndex, setSelectedIndex] = React.useState(0) - const selectedType = fieldTypes[selectedIndex] - - return ( - - - - - {selectedType} - - - {fieldTypes.map((type, index) => ( - setSelectedIndex(index)} - > - {selectedType} - - ))} - - - - - - - ) -} - describe('ActionMenu', () => { behavesAsComponent({ Component: ActionList, @@ -135,7 +105,11 @@ describe('ActionMenu', () => { }) it('should be able to select an Item with selectionVariant', async () => { - const component = HTMLRender() + const component = HTMLRender( + + + + ) const button = component.getByLabelText('Select field type') fireEvent.click(button) @@ -149,6 +123,21 @@ describe('ActionMenu', () => { cleanup() }) + it('should assign the right roles with groups & mixed selectionVariant', async () => { + const component = HTMLRender( + + + + ) + const button = component.getByLabelText('Select field type to group by') + fireEvent.click(button) + + expect(component.getByLabelText('Status')).toHaveAttribute('role', 'menuitemradio') + expect(component.getByLabelText('Clear Group by')).toHaveAttribute('role', 'menuitem') + + cleanup() + }) + it('should have no axe violations', async () => { const {container} = HTMLRender() const results = await axe(container) diff --git a/src/stories/ActionMenu2/examples.stories.tsx b/src/stories/ActionMenu2/examples.stories.tsx index de5139c5463..3412c22e6e7 100644 --- a/src/stories/ActionMenu2/examples.stories.tsx +++ b/src/stories/ActionMenu2/examples.stories.tsx @@ -96,7 +96,12 @@ export function SingleSelection(): JSX.Element { {fieldTypes.map((type, index) => ( - setSelectedIndex(index)}> + setSelectedIndex(index)} + disabled={index === 3} + > {type.name} ))} @@ -309,7 +314,10 @@ export function MixedSelection(): JSX.Element {

- + {selectedOption ? `Group by ${selectedOption.text}` : 'Group items by'} From ae67737f121205d9eccb3339f9cf789fc65125f8 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Wed, 9 Feb 2022 17:14:21 +0100 Subject: [PATCH 07/14] delete outdated snapshot --- .../__snapshots__/DropdownMenu2.test.tsx.snap | 145 ------------------ 1 file changed, 145 deletions(-) delete mode 100644 src/__tests__/__snapshots__/DropdownMenu2.test.tsx.snap diff --git a/src/__tests__/__snapshots__/DropdownMenu2.test.tsx.snap b/src/__tests__/__snapshots__/DropdownMenu2.test.tsx.snap deleted file mode 100644 index d559b06cbf5..00000000000 --- a/src/__tests__/__snapshots__/DropdownMenu2.test.tsx.snap +++ /dev/null @@ -1,145 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`DropdownMenu renders consistently 1`] = ` -.c0 { - font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"; - line-height: 1.5; - color: #24292f; -} - -.c2 { - display: inline-block; - margin-left: 8px; -} - -.c1 { - border-radius: 6px; - border: 1px solid; - border-color: rgba(27,31,36,0.15); - font-family: inherit; - font-weight: 600; - line-height: 20px; - white-space: nowrap; - vertical-align: middle; - cursor: pointer; - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - -webkit-text-decoration: none; - text-decoration: none; - text-align: center; - display: grid; - grid-template-areas: "leadingIcon text trailingIcon"; - padding-top: 5px; - padding-bottom: 5px; - padding-left: 16px; - padding-right: 16px; - font-size: 14px; - color: #24292f; - background-color: #f6f8fa; - box-shadow: 0 1px 0 rgba(27,31,36,0.04),inset 0 1px 0 rgba(255,255,255,0.25); -} - -.c1:focus { - outline: none; -} - -.c1:disabled { - cursor: default; - color: #8c959f; - background-color: btn.disabledBg; -} - -.c1:disabled svg { - opacity: 0.6; -} - -.c1 > :not(:last-child) { - margin-right: 8px; -} - -.c1 [data-component="leadingIcon"] { - grid-area: leadingIcon; -} - -.c1 [data-component="text"] { - grid-area: text; -} - -.c1 [data-component="trailingIcon"] { - grid-area: trailingIcon; -} - -.c1 [data-component="ButtonCounter"] { - font-size: 14px; -} - -.c1:hover:not([disabled]) { - background-color: #f3f4f6; -} - -.c1:focus:not([disabled]) { - box-shadow: 0 0 0 3px rgba(9,105,218,0.3); -} - -.c1:active:not([disabled]) { - background-color: hsla(220,14%,94%,1); - box-shadow: inset 0 0.15em 0.3em rgba(27,31,36,0.15); -} - -
- -
-`; From 766c2b1234f4b7e21dd21e112ca2511cc9568ff5 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Wed, 9 Feb 2022 17:37:59 +0100 Subject: [PATCH 08/14] add changeset --- .changeset/drafts-dropdownmenu2-merged-into-actionmenu2.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/drafts-dropdownmenu2-merged-into-actionmenu2.md diff --git a/.changeset/drafts-dropdownmenu2-merged-into-actionmenu2.md b/.changeset/drafts-dropdownmenu2-merged-into-actionmenu2.md new file mode 100644 index 00000000000..4bb977a8f0e --- /dev/null +++ b/.changeset/drafts-dropdownmenu2-merged-into-actionmenu2.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +Merges drafts/DropdownMenu2 into drafts/ActionMenu2 From 1e90bdaec2f9b5d969fa4821a49827a8af759345 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Wed, 9 Feb 2022 17:56:18 +0100 Subject: [PATCH 09/14] fix group roles based on latest discussion --- src/ActionList2/Group.tsx | 6 ++---- src/stories/ActionMenu2/examples.stories.tsx | 17 ++++++++--------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/ActionList2/Group.tsx b/src/ActionList2/Group.tsx index ff957aa7043..0f5ecbafb56 100644 --- a/src/ActionList2/Group.tsx +++ b/src/ActionList2/Group.tsx @@ -3,7 +3,6 @@ import {useSSRSafeId} from '@react-aria/ssr' import Box from '../Box' import {SxProp} from '../sx' import {ListContext, ListProps} from './List' -import {AriaRole} from '../utils/types' export type GroupProps = { /** @@ -24,7 +23,6 @@ export type GroupProps = { /** * The ARIA role describing the function of the list inside `Group` component. `listbox` or `menu` are a common values. */ - role?: AriaRole } & SxProp & { /** * Whether multiple Items or a single Item can be selected in the Group. Overrides value on ActionList root. @@ -40,7 +38,6 @@ export const Group: React.FC = ({ variant = 'subtle', auxiliaryText, selectionVariant, - role, sx = {}, ...props }) => { @@ -49,6 +46,7 @@ export const Group: React.FC = ({ return ( = ({ > {title &&
} - + {props.children} diff --git a/src/stories/ActionMenu2/examples.stories.tsx b/src/stories/ActionMenu2/examples.stories.tsx index 3412c22e6e7..c41634fc25d 100644 --- a/src/stories/ActionMenu2/examples.stories.tsx +++ b/src/stories/ActionMenu2/examples.stories.tsx @@ -171,8 +171,8 @@ export function GroupsAndDescription(): JSX.Element { Milestone - - + + {milestones .filter(milestone => !milestone.name.includes('21')) .map((milestone, index) => ( @@ -189,7 +189,7 @@ export function GroupsAndDescription(): JSX.Element { ))} - + {milestones .filter(milestone => milestone.name.includes('21')) .map((milestone, index) => ( @@ -270,7 +270,6 @@ export function MultipleSelection(): JSX.Element { {users.map(user => ( assignee.login === user.login))} onSelect={() => toggleAssignee(user)} @@ -313,7 +312,7 @@ export function MixedSelection(): JSX.Element { is an action. This pattern appears inside a ActionMenu for selection view options in Memex

- + - - + + {options.map((option, index) => ( {typeof selectedIndex === 'number' && ( - + - setSelectedIndex(null)} role="menuitem"> + setSelectedIndex(null)}> From 9a4706dbc8040f359edad09d0d3f5a5c56a29ea1 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Wed, 9 Feb 2022 18:03:24 +0100 Subject: [PATCH 10/14] Revert "fix group roles based on latest discussion" This reverts commit 1e90bdaec2f9b5d969fa4821a49827a8af759345. --- src/ActionList2/Group.tsx | 6 ++++-- src/stories/ActionMenu2/examples.stories.tsx | 17 +++++++++-------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/ActionList2/Group.tsx b/src/ActionList2/Group.tsx index 0f5ecbafb56..ff957aa7043 100644 --- a/src/ActionList2/Group.tsx +++ b/src/ActionList2/Group.tsx @@ -3,6 +3,7 @@ import {useSSRSafeId} from '@react-aria/ssr' import Box from '../Box' import {SxProp} from '../sx' import {ListContext, ListProps} from './List' +import {AriaRole} from '../utils/types' export type GroupProps = { /** @@ -23,6 +24,7 @@ export type GroupProps = { /** * The ARIA role describing the function of the list inside `Group` component. `listbox` or `menu` are a common values. */ + role?: AriaRole } & SxProp & { /** * Whether multiple Items or a single Item can be selected in the Group. Overrides value on ActionList root. @@ -38,6 +40,7 @@ export const Group: React.FC = ({ variant = 'subtle', auxiliaryText, selectionVariant, + role, sx = {}, ...props }) => { @@ -46,7 +49,6 @@ export const Group: React.FC = ({ return ( = ({ > {title &&
} - + {props.children} diff --git a/src/stories/ActionMenu2/examples.stories.tsx b/src/stories/ActionMenu2/examples.stories.tsx index c41634fc25d..3412c22e6e7 100644 --- a/src/stories/ActionMenu2/examples.stories.tsx +++ b/src/stories/ActionMenu2/examples.stories.tsx @@ -171,8 +171,8 @@ export function GroupsAndDescription(): JSX.Element { Milestone - - + + {milestones .filter(milestone => !milestone.name.includes('21')) .map((milestone, index) => ( @@ -189,7 +189,7 @@ export function GroupsAndDescription(): JSX.Element { ))} - + {milestones .filter(milestone => milestone.name.includes('21')) .map((milestone, index) => ( @@ -270,6 +270,7 @@ export function MultipleSelection(): JSX.Element { {users.map(user => ( assignee.login === user.login))} onSelect={() => toggleAssignee(user)} @@ -312,7 +313,7 @@ export function MixedSelection(): JSX.Element { is an action. This pattern appears inside a ActionMenu for selection view options in Memex

- + - - + + {options.map((option, index) => ( {typeof selectedIndex === 'number' && ( - + - setSelectedIndex(null)}> + setSelectedIndex(null)} role="menuitem"> From 0c15a1181eeea391b76fbf04eac7e64f2019915d Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Thu, 17 Feb 2022 13:49:35 +0100 Subject: [PATCH 11/14] Update docs/content/drafts/ActionMenu2.mdx Co-authored-by: Cole Bemis --- docs/content/drafts/ActionMenu2.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/drafts/ActionMenu2.mdx b/docs/content/drafts/ActionMenu2.mdx index 037a4b82e20..4ccc9b3c5a3 100644 --- a/docs/content/drafts/ActionMenu2.mdx +++ b/docs/content/drafts/ActionMenu2.mdx @@ -175,7 +175,7 @@ You can choose to have a different _anchor_ for the Menu dependending on the app ``` -### With Selection +### With selection Use `selectionVariant` on `ActionList` to create a menu with single or multiple selection. From 21b06c749e776a301ff3ea61e578ea3b1aad9651 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Thu, 17 Feb 2022 14:01:21 +0100 Subject: [PATCH 12/14] use merged ActionMenu instead of Dropdown in Overlay --- src/stories/Overlay.stories.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/stories/Overlay.stories.tsx b/src/stories/Overlay.stories.tsx index 5392d4c9a15..dd635c6338a 100644 --- a/src/stories/Overlay.stories.tsx +++ b/src/stories/Overlay.stories.tsx @@ -23,7 +23,7 @@ import { } from '..' import type {AnchorSide} from '@primer/behaviors' import {DropdownMenu, DropdownButton} from '../DropdownMenu' -import {DropdownMenu as DropdownMenu2, ActionList as ActionList2} from '../drafts' +import {ActionMenu, ActionList as ActionList2} from '../drafts' import {ItemInput} from '../ActionList/List' export default { @@ -362,20 +362,20 @@ export const MemexNestedOverlays = () => { Duration: - - + + {duration} - - - + + + {durations.map(item => ( setDuration(item)}> {item} ))} - - +
+ From d5d340138f2f0e6dbc22b21461d39f57dfd5c94d Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Thu, 17 Feb 2022 14:18:24 +0100 Subject: [PATCH 13/14] bring back DropdownMenu2 part 1/2 --- docs/content/drafts/DropdownMenu2.mdx | 364 ++++++++++++++++++ .../ActionListContainerContext.tsx | 1 + src/ActionList2/Item.tsx | 2 +- src/ActionList2/List.tsx | 12 +- src/DropdownMenu2.tsx | 134 +++++++ src/drafts/index.ts | 1 + 6 files changed, 511 insertions(+), 3 deletions(-) create mode 100644 docs/content/drafts/DropdownMenu2.mdx create mode 100644 src/DropdownMenu2.tsx diff --git a/docs/content/drafts/DropdownMenu2.mdx b/docs/content/drafts/DropdownMenu2.mdx new file mode 100644 index 00000000000..68cd522aa1e --- /dev/null +++ b/docs/content/drafts/DropdownMenu2.mdx @@ -0,0 +1,364 @@ +--- +component_id: dropdown_menu +title: DropdownMenu v2 +status: Alpha +source: https://github.com/primer/react/tree/main/src/DropdownMenu2.tsx +storybook: '/react/storybook?path=/story/composite-components-dropdownmenu2' +description: Use DropdownMenu to select a single option from a list of menu options. +--- + +import {Box, Avatar} from '@primer/react' +import {DropdownMenu, ActionList} from '@primer/react/drafts' +import {Props} from '../../src/props' +import State from '../../components/State' +import {CalendarIcon, IterationsIcon, NumberIcon, SingleSelectIcon, TypographyIcon} from '@primer/octicons-react' + +
+ + + {([selectedIndex, setSelectedIndex]) => { + const fieldTypes = [ + {icon: TypographyIcon, name: 'Text'}, + {icon: NumberIcon, name: 'Number'}, + {icon: CalendarIcon, name: 'Date'}, + {icon: SingleSelectIcon, name: 'Single select'}, + {icon: IterationsIcon, name: 'Iteration'} + ] + const selectedType = fieldTypes[selectedIndex] + return ( + + + + {selectedType.name} + + + + {fieldTypes.map(({icon: Icon, name}, index) => ( + setSelectedIndex(index)} + > + {name} + + ))} + + + + + ) + }} + + +
+ +```js +import {DropdownMenu} from '@primer/react/drafts' +``` + +
+ +## Examples + +### Minimal example + +`DropdownMenu` ships with `DropdownMenu.Button` which is an accessible trigger for the overlay. It's recommended to compose `ActionList` with `DropdownMenu.Overlay` + +  + +```javascript live noinline drafts +const fieldTypes = [ + {icon: TypographyIcon, name: 'Text'}, + {icon: NumberIcon, name: 'Number'}, + {icon: CalendarIcon, name: 'Date'}, + {icon: SingleSelectIcon, name: 'Single select'}, + {icon: IterationsIcon, name: 'Iteration'} +] + +const Example = () => { + const [selectedIndex, setSelectedIndex] = React.useState(1) + const selectedType = fieldTypes[selectedIndex] + + return ( + + + {selectedType.name} + + + + {fieldTypes.map((type, index) => ( + setSelectedIndex(index)}> + {type.name} + + ))} + + + + ) +} + +render() +``` + +### Customise Button + +`Dropdown.Button` uses `Button v2` so you can pass props like `variant` and `leadingIcon` that `Button v2` accepts. + +```javascript live noinline drafts +const Example = () => { + const [duration, setDuration] = React.useState(1) + + return ( + + + {duration} {duration > 1 ? 'weeks' : 'week'} + + + + {[1, 2, 3, 4, 5, 6].map(weeks => ( + setDuration(weeks)}> + {weeks} {weeks > 1 ? 'weeks' : 'week'} + + ))} + + + + ) +} + +render() +``` + +### With External Anchor + +To create an anchor outside of the menu, you need to switch to controlled mode for the menu and pass `open` and `onOpenChange` along with an `anchorRef` to `DropdownMenu`: + +```javascript live noinline drafts +const Example = () => { + const [open, setOpen] = React.useState(false) + const anchorRef = React.createRef() + + return ( + <> + + + + + + Text + Number + Date + Iteration + + + + + ) +} + +render() +``` + +### With Overlay Props + +```javascript live noinline drafts +const fieldTypes = [ + {icon: TypographyIcon, name: 'Text'}, + {icon: NumberIcon, name: 'Number'}, + {icon: CalendarIcon, name: 'Date'}, + {icon: SingleSelectIcon, name: 'Single select'}, + {icon: IterationsIcon, name: 'Iteration'} +] + +const Example = () => { + const handleEscape = () => alert('you hit escape!') + + const [selectedIndex, setSelectedIndex] = React.useState(1) + const selectedType = fieldTypes[selectedIndex] + + return ( + + + {selectedType.name} + + + + {fieldTypes.map((type, index) => ( + setSelectedIndex(index)}> + {type.name} + + ))} + + + + ) +} + +render() +``` + +### With a custom anchor + +You can choose to have a different _anchor_ for the Menu dependending on the application's context. + +  + +```javascript live noinline drafts +render( + + + + + + + + Text + Number + Date + Iteration + + + +) +``` + + + +Use `DropdownMenu` to select an option from a small list. If you’re looking for filters or multiple selection, use [SelectPanel](/SelectPanel) instead. + + + +## Props + +### DropdownMenu + + + + Recommended: DropdownMenu.Button or DropdownMenu.Anchor with{' '} + DropdownMenu.Overlay + + } + /> + + If defined, will control the open/closed state of the overlay. Must be used in conjuction with{' '} + onOpenChange + + } + /> + + If defined, will control the open/closed state of the overlay. Must be used in conjuction with{' '} + open + + } + /> + + + +### DropdownMenu.Button + + + + ButtonProps + + } + description={ + <> + You can pass all of the props that you would pass to a{' '} + + Button + {' '} + component like variant, leadingIcon,{' '} + sx, etc. + + } + /> + + +### DropdownMenu.Anchor + + + + + +### DropdownMenu.Overlay + + + + Recommended:{' '} + + ActionList + + + } + /> + + Props to be spread on the internal{' '} + + AnchoredOverlay + + + } + /> + + +## Status + + + +## Further reading + +[Interface guidelines: Action List + Menu](https://primer.style/design/components/action-list) + +## Related components + +- [ActionList](/drafts/ActionList2) +- [ActionMenu](/ActionMenu2) +- [SelectPanel](/SelectPanel) diff --git a/src/ActionList2/ActionListContainerContext.tsx b/src/ActionList2/ActionListContainerContext.tsx index 53e703d09d8..ddba7448833 100644 --- a/src/ActionList2/ActionListContainerContext.tsx +++ b/src/ActionList2/ActionListContainerContext.tsx @@ -5,6 +5,7 @@ import React from 'react' type ContextProps = { container?: string listRole?: string + selectionVariant?: 'single' | 'multiple' // TODO: Remove after DropdownMenu2 deprecation selectionAttribute?: 'aria-selected' | 'aria-checked' listLabelledBy?: string // This can be any function, we don't know anything about the arguments diff --git a/src/ActionList2/Item.tsx b/src/ActionList2/Item.tsx index d90c1b74331..be233836fa6 100644 --- a/src/ActionList2/Item.tsx +++ b/src/ActionList2/Item.tsx @@ -112,7 +112,7 @@ export const Item = React.forwardRef( /** Infer item role based on the container */ let itemRole: ItemProps['role'] - if (container === 'ActionMenu') { + if (container === 'ActionMenu' || container === 'DropdownMenu') { if (selectionVariant === 'single') itemRole = 'menuitemradio' else if (selectionVariant === 'multiple') itemRole = 'menuitemcheckbox' else itemRole = 'menuitem' diff --git a/src/ActionList2/List.tsx b/src/ActionList2/List.tsx index 1182b5107e9..de1363ed419 100644 --- a/src/ActionList2/List.tsx +++ b/src/ActionList2/List.tsx @@ -41,7 +41,11 @@ export const List = React.forwardRef( } /** if list is inside a Menu, it will get a role from the Menu */ - const {listRole, listLabelledBy} = React.useContext(ActionListContainerContext) + const { + listRole, + listLabelledBy, + selectionVariant: containerSelectionVariant // TODO: Remove after DropdownMenu2 deprecation + } = React.useContext(ActionListContainerContext) return ( ( {...props} ref={forwardedRef} > - {props.children} + + {props.children} + ) } diff --git a/src/DropdownMenu2.tsx b/src/DropdownMenu2.tsx new file mode 100644 index 00000000000..0e552bc5cef --- /dev/null +++ b/src/DropdownMenu2.tsx @@ -0,0 +1,134 @@ +import React from 'react' +import {useSSRSafeId} from '@react-aria/ssr' +import {TriangleDownIcon} from '@primer/octicons-react' +import {Button, ButtonProps} from './Button2' +import {AnchoredOverlay, AnchoredOverlayProps} from './AnchoredOverlay' +import {OverlayProps} from './Overlay' +import {useProvidedRefOrCreate, useProvidedStateOrCreate, useMenuInitialFocus} from './hooks' +import {Divider} from './ActionList2/Divider' +import {ActionListContainerContext} from './ActionList2/ActionListContainerContext' +import {MandateProps} from './utils/types' + +type MenuContextProps = Pick< + AnchoredOverlayProps, + 'anchorRef' | 'renderAnchor' | 'open' | 'onOpen' | 'onClose' | 'anchorId' +> +const MenuContext = React.createContext({renderAnchor: null, open: false}) + +export type DropdownMenuProps = { + /** + * Recommended: `ActionMenu.Button` or `ActionMenu.Anchor` with `ActionMenu.Overlay` + */ + children: React.ReactElement[] | React.ReactElement + + /** + * If defined, will control the open/closed state of the overlay. Must be used in conjuction with `onOpenChange`. + */ + open?: boolean + + /** + * If defined, will control the open/closed state of the overlay. Must be used in conjuction with `open`. + */ + onOpenChange?: (s: boolean) => void +} & Pick + +const Menu: React.FC = ({ + anchorRef: externalAnchorRef, + open, + onOpenChange, + children +}: DropdownMenuProps) => { + const [combinedOpenState, setCombinedOpenState] = useProvidedStateOrCreate(open, onOpenChange, false) + const onOpen = React.useCallback(() => setCombinedOpenState(true), [setCombinedOpenState]) + const onClose = React.useCallback(() => setCombinedOpenState(false), [setCombinedOpenState]) + + const anchorRef = useProvidedRefOrCreate(externalAnchorRef) + const anchorId = useSSRSafeId() + let renderAnchor: AnchoredOverlayProps['renderAnchor'] = null + + // 🚨 Hack for good API! + // we strip out Anchor from children and pass it to AnchoredOverlay to render + // with additional props for accessibility + const contents = React.Children.map(children, child => { + if (child.type === MenuButton || child.type === Anchor) { + renderAnchor = anchorProps => React.cloneElement(child, anchorProps) + return null + } + return child + }) + + return ( + + {contents} + + ) +} + +export type DropdownMenuAnchorProps = {children: React.ReactElement} +const Anchor = React.forwardRef( + ({children, ...anchorProps}, anchorRef) => { + return React.cloneElement(children, {...anchorProps, ref: anchorRef}) + } +) + +/** this component is syntactical sugar 🍭 */ +export type DropdownMenuButtonProps = ButtonProps +const MenuButton = React.forwardRef( + ({children, ...props}, anchorRef) => { + return ( + + + + ) + } +) + +type MenuOverlayProps = Partial & { + /** + * Recommended: `ActionList` + */ + children: React.ReactElement[] | React.ReactElement +} +const Overlay: React.FC = ({children, ...overlayProps}) => { + // we typecast anchorRef as required instead of optional + // because we know that we're setting it in context in Menu + const {anchorRef, renderAnchor, anchorId, open, onOpen, onClose} = React.useContext(MenuContext) as MandateProps< + MenuContextProps, + 'anchorRef' + > + + const {containerRef, openWithFocus} = useMenuInitialFocus(open, onOpen) + + return ( + +
+ + {children} + +
+
+ ) +} + +Menu.displayName = 'ActionMenu' +export const DropdownMenu = Object.assign(Menu, {Button: MenuButton, Anchor, Overlay, Divider}) diff --git a/src/drafts/index.ts b/src/drafts/index.ts index cbef05e7eaa..291e56dafbb 100644 --- a/src/drafts/index.ts +++ b/src/drafts/index.ts @@ -9,5 +9,6 @@ export * from '../ActionList2' export * from '../Button2' export * from '../ActionMenu2' +export * from '../DropdownMenu2' export * from '../Label2' export * from '../PageLayout' From 82535fb834f1c09a063bac7a76a6d5575686f3ed Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Thu, 17 Feb 2022 14:20:43 +0100 Subject: [PATCH 14/14] bring back DropdownMenu2 part 2/2 --- src/__tests__/DropdownMenu2.test.tsx | 101 ++++++ .../__snapshots__/DropdownMenu2.test.tsx.snap | 145 ++++++++ .../DropdownMenu2/examples.stories.tsx | 246 +++++++++++++ .../DropdownMenu2/fixtures.stories.tsx | 335 ++++++++++++++++++ 4 files changed, 827 insertions(+) create mode 100644 src/__tests__/DropdownMenu2.test.tsx create mode 100644 src/__tests__/__snapshots__/DropdownMenu2.test.tsx.snap create mode 100644 src/stories/DropdownMenu2/examples.stories.tsx create mode 100644 src/stories/DropdownMenu2/fixtures.stories.tsx diff --git a/src/__tests__/DropdownMenu2.test.tsx b/src/__tests__/DropdownMenu2.test.tsx new file mode 100644 index 00000000000..04917c6e3a4 --- /dev/null +++ b/src/__tests__/DropdownMenu2.test.tsx @@ -0,0 +1,101 @@ +import {cleanup, render as HTMLRender, waitFor, fireEvent} from '@testing-library/react' +import 'babel-polyfill' +import {toHaveNoViolations} from 'jest-axe' +import React from 'react' +import theme from '../theme' +import {DropdownMenu} from '../DropdownMenu2' +import {ActionList} from '../ActionList2' +import {behavesAsComponent, checkExports, checkStoriesForAxeViolations} from '../utils/testing' +import {BaseStyles, ThemeProvider, SSRProvider} from '..' +import '@testing-library/jest-dom' +expect.extend(toHaveNoViolations) + +function SimpleDropdownMenu(): JSX.Element { + const fieldTypes = [{name: 'Text'}, {name: 'Number'}, {name: 'Date'}, {name: 'Single select'}, {name: 'Iteration'}] + const [selectedIndex, setSelectedIndex] = React.useState(0) + const selectedType = fieldTypes[selectedIndex] + + return ( + + + + + {selectedType.name} + + + {fieldTypes.map((type, index) => ( + setSelectedIndex(index)} + > + {type.name} + + ))} + + + + + + + ) +} + +describe('DropdownMenu', () => { + behavesAsComponent({ + Component: ActionList, + options: {skipAs: true, skipSx: true}, + toRender: () => + }) + + checkExports('DropdownMenu2', { + default: undefined, + DropdownMenu + }) + + it('should open Menu on Button click', async () => { + const component = HTMLRender() + const button = component.getByLabelText('Select field type') + fireEvent.click(button) + expect(component.getByRole('menu')).toBeInTheDocument() + cleanup() + }) + + it('should open Menu on Button keypress', async () => { + const component = HTMLRender() + const button = component.getByLabelText('Select field type') + + // We pass keycode here to navigate a implementation detail in react-testing-library + // https://github.com/testing-library/react-testing-library/issues/269#issuecomment-455854112 + fireEvent.keyDown(button, {key: 'Enter', charCode: 13}) + expect(component.getByRole('menu')).toBeInTheDocument() + cleanup() + }) + + it('should close Menu on selecting an action with click', async () => { + const component = HTMLRender() + const button = component.getByLabelText('Select field type') + + fireEvent.click(button) + const menuItems = await waitFor(() => component.getAllByRole('menuitemradio')) + fireEvent.click(menuItems[0]) + expect(component.queryByRole('menu')).toBeNull() + + cleanup() + }) + + it('should close Menu on selecting an action with Enter', async () => { + const component = HTMLRender() + const button = component.getByLabelText('Select field type') + + fireEvent.click(button) + const menuItems = await waitFor(() => component.getAllByRole('menuitemradio')) + fireEvent.keyPress(menuItems[0], {key: 'Enter', charCode: 13}) + expect(component.queryByRole('menu')).toBeNull() + + cleanup() + }) + + checkStoriesForAxeViolations('DropdownMenu2/fixtures') + checkStoriesForAxeViolations('DropdownMenu2/examples') +}) diff --git a/src/__tests__/__snapshots__/DropdownMenu2.test.tsx.snap b/src/__tests__/__snapshots__/DropdownMenu2.test.tsx.snap new file mode 100644 index 00000000000..d559b06cbf5 --- /dev/null +++ b/src/__tests__/__snapshots__/DropdownMenu2.test.tsx.snap @@ -0,0 +1,145 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DropdownMenu renders consistently 1`] = ` +.c0 { + font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"; + line-height: 1.5; + color: #24292f; +} + +.c2 { + display: inline-block; + margin-left: 8px; +} + +.c1 { + border-radius: 6px; + border: 1px solid; + border-color: rgba(27,31,36,0.15); + font-family: inherit; + font-weight: 600; + line-height: 20px; + white-space: nowrap; + vertical-align: middle; + cursor: pointer; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + -webkit-text-decoration: none; + text-decoration: none; + text-align: center; + display: grid; + grid-template-areas: "leadingIcon text trailingIcon"; + padding-top: 5px; + padding-bottom: 5px; + padding-left: 16px; + padding-right: 16px; + font-size: 14px; + color: #24292f; + background-color: #f6f8fa; + box-shadow: 0 1px 0 rgba(27,31,36,0.04),inset 0 1px 0 rgba(255,255,255,0.25); +} + +.c1:focus { + outline: none; +} + +.c1:disabled { + cursor: default; + color: #8c959f; + background-color: btn.disabledBg; +} + +.c1:disabled svg { + opacity: 0.6; +} + +.c1 > :not(:last-child) { + margin-right: 8px; +} + +.c1 [data-component="leadingIcon"] { + grid-area: leadingIcon; +} + +.c1 [data-component="text"] { + grid-area: text; +} + +.c1 [data-component="trailingIcon"] { + grid-area: trailingIcon; +} + +.c1 [data-component="ButtonCounter"] { + font-size: 14px; +} + +.c1:hover:not([disabled]) { + background-color: #f3f4f6; +} + +.c1:focus:not([disabled]) { + box-shadow: 0 0 0 3px rgba(9,105,218,0.3); +} + +.c1:active:not([disabled]) { + background-color: hsla(220,14%,94%,1); + box-shadow: inset 0 0.15em 0.3em rgba(27,31,36,0.15); +} + +
+ +
+`; diff --git a/src/stories/DropdownMenu2/examples.stories.tsx b/src/stories/DropdownMenu2/examples.stories.tsx new file mode 100644 index 00000000000..a461dbbf47f --- /dev/null +++ b/src/stories/DropdownMenu2/examples.stories.tsx @@ -0,0 +1,246 @@ +import React from 'react' +import {Meta} from '@storybook/react' +import {ThemeProvider} from '../..' +import BaseStyles from '../../BaseStyles' +import {DropdownMenu} from '../../DropdownMenu2' +import {ActionList} from '../../ActionList2' +import Box from '../../Box' +import Text from '../../Text' +import { + GearIcon, + MilestoneIcon, + CalendarIcon, + IterationsIcon, + NumberIcon, + SingleSelectIcon, + TypographyIcon, + IssueOpenedIcon, + TableIcon, + PeopleIcon, + XIcon +} from '@primer/octicons-react' + +const meta: Meta = { + title: 'Composite components/DropdownMenu2/examples', + component: DropdownMenu, + decorators: [ + (Story: React.ComponentType): JSX.Element => ( + + + + + + ) + ], + parameters: { + controls: { + disabled: true + } + } +} +export default meta + +const fieldTypes = [ + {icon: TypographyIcon, name: 'Text'}, + {icon: NumberIcon, name: 'Number'}, + {icon: CalendarIcon, name: 'Date'}, + {icon: SingleSelectIcon, name: 'Single select'}, + {icon: IterationsIcon, name: 'Iteration'} +] + +export function SingleSelection(): JSX.Element { + const [selectedIndex, setSelectedIndex] = React.useState(0) + const selectedType = fieldTypes[selectedIndex] + return ( + <> +

Simple Dropdown Menu

+ +

This pattern is the classic dropdown menu - single section with the selected value shown in the button

+ + + + {selectedType.name} + + + + {fieldTypes.map((type, index) => ( + setSelectedIndex(index)}> + {type.name} + + ))} + + + + + ) +} + +export function WithPlaceholder(): JSX.Element { + const [selectedIndex, setSelectedIndex] = React.useState(-1) + const selectedType = fieldTypes[selectedIndex] || {} + + return ( + <> +

With placeholder

+ +

This pattern is the starting state of the dropdown menu with a placeholder when there is default value

+ + + + {selectedType.name || 'Pick a field type'} + + + + {fieldTypes.map((type, index) => ( + setSelectedIndex(index)}> + {type.name} + + ))} + + + + + ) +} + +const milestones = [ + {name: 'FY21 - Q2', due: 'December 31, 2021', progress: 90}, + {name: 'FY22 - Q3', due: 'March 31, 2022', progress: 10}, + {name: 'FY23 - Q1', due: 'June 30, 2022', progress: 0}, + {name: 'FY23 - Q2', due: 'December 30, 2022', progress: 0} +] + +export function GroupsAndDescription(): JSX.Element { + const [selectedMilestone, setSelectedMilestone] = React.useState() + + return ( + <> +

Milestone selector

+ + + + Milestone + + + + + {milestones + .filter(milestone => !milestone.name.includes('21')) + .map((milestone, index) => ( + setSelectedMilestone(milestone)} + > + + + + {milestone.name} + Due by {milestone.due} + + ))} + + + {milestones + .filter(milestone => milestone.name.includes('21')) + .map((milestone, index) => ( + setSelectedMilestone(milestone)} + > + + + + {milestone.name} + Due by {milestone.due} + + ))} + + + + + {selectedMilestone ? ( + + {selectedMilestone.name} + + ) : ( + + No milestone + + )} + + + ) +} + +export function MixedSelection(): JSX.Element { + const [selectedIndex, setSelectedIndex] = React.useState(1) + + const options = [ + {text: 'Status', icon: IssueOpenedIcon}, + {text: 'Stage', icon: TableIcon}, + {text: 'Assignee', icon: PeopleIcon}, + {text: 'Team', icon: TypographyIcon}, + {text: 'Estimate', icon: NumberIcon}, + {text: 'Due Date', icon: CalendarIcon} + ] + + const selectedOption = selectedIndex && options[selectedIndex] + + return ( + <> +

List with mixed selection

+ +

+ In this list, there is a ActionList.Group with single selection for picking one option, followed by a Item that + is an action. This pattern appears inside a DropdownMenu for selection view options in Memex +

+ + + + {selectedOption ? `Group by ${selectedOption.text}` : 'Group items by'} + + + + + {options.map((option, index) => ( + setSelectedIndex(index)} + > + {option.icon} + {option.text} + + ))} + + {typeof selectedIndex === 'number' && ( + + + setSelectedIndex(null)} role="menuitem"> + + + + Clear Group by + + + )} + + + + + ) +} diff --git a/src/stories/DropdownMenu2/fixtures.stories.tsx b/src/stories/DropdownMenu2/fixtures.stories.tsx new file mode 100644 index 00000000000..d39782e46c1 --- /dev/null +++ b/src/stories/DropdownMenu2/fixtures.stories.tsx @@ -0,0 +1,335 @@ +import React from 'react' +import {Meta} from '@storybook/react' +import {ThemeProvider} from '../..' +import BaseStyles from '../../BaseStyles' +import {DropdownMenu} from '../../DropdownMenu2' +import {ActionList} from '../../ActionList2' +import {Button} from '../../Button2' +import Box from '../../Box' +import Text from '../../Text' +import TextInput from '../../TextInput' +import ProgressBar from '../../ProgressBar' +import { + GearIcon, + MilestoneIcon, + CalendarIcon, + IterationsIcon, + NumberIcon, + SingleSelectIcon, + TypographyIcon +} from '@primer/octicons-react' + +const meta: Meta = { + title: 'Composite components/DropdownMenu2/fixtures', + component: DropdownMenu, + decorators: [ + (Story: React.ComponentType): JSX.Element => ( + + + + + + ) + ], + parameters: { + controls: { + disabled: true + } + } +} +export default meta + +const fieldTypes = [ + {icon: TypographyIcon, name: 'Text'}, + {icon: NumberIcon, name: 'Number'}, + {icon: CalendarIcon, name: 'Date'}, + {icon: SingleSelectIcon, name: 'Single select'}, + {icon: IterationsIcon, name: 'Iteration'} +] + +export function SimpleDropdownMenu(): JSX.Element { + const [selectedIndex, setSelectedIndex] = React.useState(0) + const selectedType = fieldTypes[selectedIndex] + return ( + <> +

Simple Dropdown Menu

+ + + + {selectedType.name} + + + + {fieldTypes.map((type, index) => ( + setSelectedIndex(index)}> + {type.name} + + ))} + + + + + ) +} +SimpleDropdownMenu.storyName = 'Simple DropdownMenu' + +export function Placeholder(): JSX.Element { + const [selectedIndex, setSelectedIndex] = React.useState(-1) + const selectedType = fieldTypes[selectedIndex] || {} + + return ( + <> +

With placeholder

+ + + + {selectedType.name || 'Pick a field type'} + + + + {fieldTypes.map((type, index) => ( + setSelectedIndex(index)}> + {type.name} + + ))} + + + + + ) +} +Placeholder.storyName = 'Placeholder' + +export function MemexIteration(): JSX.Element { + const [duration, setDuration] = React.useState(1) + + return ( + <> +

Memex Iteration Menu

+ + + + {duration} {duration > 1 ? 'weeks' : 'week'} + + + + {[1, 2, 3, 4, 5, 6].map(weeks => ( + setDuration(weeks)}> + {weeks} {weeks > 1 ? 'weeks' : 'week'} + + ))} + + + + + ) +} +MemexIteration.storyName = 'Memex Iteration Menu' + +const milestones = [ + {name: 'v29.2.0', due: 'September 30, 2021', progress: 95}, + {name: 'v30.0.0', due: 'December 1, 2021', progress: 40}, + {name: 'FY22 - Q3', due: 'December 31, 2021', progress: 10} +] + +export function MemexAddColumn(): JSX.Element { + const [selectedIndex, setSelectedIndex] = React.useState(0) + const selectedType = fieldTypes[selectedIndex] + + const [duration, setDuration] = React.useState(1) + + return ( + <> +

Memex Add column

+ + + + + + {selectedType.name} + + + + {fieldTypes.map((type, index) => ( + setSelectedIndex(index)} + > + {type.icon} {type.name} + + ))} + + + + Options + + + Duration: + + + {duration} {duration > 1 ? 'weeks' : 'week'} + + + + {[1, 2, 3, 4, 5, 6].map(weeks => ( + setDuration(weeks)}> + {weeks} {weeks > 1 ? 'weeks' : 'week'} + + ))} + + + + + + + ) +} +MemexAddColumn.storyName = 'Memex Add Column' + +export function MilestoneStory(): JSX.Element { + const [selectedIndex, setSelectedIndex] = React.useState(-1) + + const selectedMilestone = milestones[selectedIndex] as typeof milestones[0] | undefined + + return ( + <> +

Milestone selector

+ + + + + + + + + + {milestones.map((milestone, index) => ( + setSelectedIndex(index)} + > + + + + {milestone.name} + Due by {milestone.due} + + ))} + + + + {selectedMilestone ? ( + + + {selectedMilestone.name} + + ) : ( + + No milestone + + )} + + + ) +} +MilestoneStory.storyName = 'Milestone selector' + +export function ControlledMenu(): JSX.Element { + const [open, setOpen] = React.useState(false) + const [selectedIndex, setSelectedIndex] = React.useState(0) + + return ( + <> +

Controlled Menu

+ +

External Open State: {open ? 'Open' : 'Closed'}

+

selected Value: {fieldTypes[selectedIndex].name}

+ + + setOpen(!open)}>{open ? 'Close Menu' : 'Open Menu'} + + + {fieldTypes.map((type, index) => ( + setSelectedIndex(index)}> + {type.name} + + ))} + + + + + ) +} +ControlledMenu.storyName = 'Controlled Menu' + +export function ExternalAnchor(): JSX.Element { + const [open, setOpen] = React.useState(false) + const triggerRef = React.createRef() + const anchorRef = React.createRef() + + const [selectedIndex, setSelectedIndex] = React.useState(0) + + return ( + <> +

External Anchor

+ +

External Open State: {open ? 'Open' : 'Closed'}

+

selected Value: {fieldTypes[selectedIndex].name}

+ + + + + Anchored on me! + + + + + + {fieldTypes.map((type, index) => ( + setSelectedIndex(index)}> + {type.name} + + ))} + + + + + ) +} +ExternalAnchor.storyName = 'External Anchor'