Skip to content

Commit 08d7d5d

Browse files
radglobgr2msiddharthkp
authored
Refactor(ActionList): ActionList.Item should render content as a button if parent is not interactive. (#3284)
* Using button as the underlying tag for ActionList.Item content. * Fix subnav layout. * Changing ActionList content to button if the top-level is not a button. ActionMenu is busted. * Fix issue where ActionList.Item content would render as a button inside ActionMenu, breaking keyboard navigation. * Create purple-crabs-matter.md * test(vrt): update snapshots * Fix bug with MenuContext not being exported properly. * Fix color violation in ActionList.Item. * Formatting, update snapshots. * test(vrt): update snapshots * Fix a11y violation in ActionList story. * Formatting. * Fix ts-ignore comment. * Adjust line-height when button is rendered. * Revert "test(vrt): update snapshots" This reverts commit ba6d863. * Revert "test(vrt): update snapshots" This reverts commit 31d5358. * Update snapshots. * Remove flexGrow from ActionList.Item to remove extra 1px vertical height. * Set padding to 0 and put flexGrow back to fix padding issue for ActionList buttons. * Fix ActionList text wrap story. * Fix underlinenav story by checking ActionList.Item if as prop is a. * Update snapshots and formatting. * Update snapshots. * If ActionList.Item content is rendered as a button, remove tabIndex from li and fix padding. * Fix various layout edge cases. * ts-ignore event handlers on ItemWrapper. * Update snapshots. * Revert fontWeight config that differed from production docs. * Pass selectionVariant prop illegally in UnderlineNav story to fix issue with Selection spans showing up. * fix: prevent closing menu when `event.preventDefault()` is called on `ActionList.Item`'s `onSelect` handler by @gr2m (#3346) * fix: prevent closing menu when `event.preventDefault()` is called on `ActionList.Item`'s `onSelect` handler (#3163) * test: prevent closing menu when `event.preventDefault()` is called on `ActionList.Item`'s `onSelect` handler Failing test for #3162 * fix: prevent closing menu when `event.preventDefault()` is called on `ActionList.Item`ߴs `onSelect` handler * add storybook example: Delayed Menu Close * update docs * docs: changeset * Update changelog --------- Co-authored-by: Siddharth Kshetrapal <[email protected]> * Update generated/components.json --------- Co-authored-by: Gregor Martynus <[email protected]> Co-authored-by: siddharthkp <[email protected]> * Check for tabIndex value in isTopLevelInteractive. * Reference styles in updated menuProps. * Updated snapshots. * Fix padding setting instead of using values from styles, which are inverted. * Update snapshots. --------- Co-authored-by: radglob <[email protected]> Co-authored-by: Gregor Martynus <[email protected]> Co-authored-by: siddharthkp <[email protected]>
1 parent 786013e commit 08d7d5d

File tree

14 files changed

+297
-280
lines changed

14 files changed

+297
-280
lines changed

.changeset/purple-crabs-matter.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@primer/react": patch
3+
---
4+
5+
Refactor(ActionList): ActionList.Item should render content as a button if parent is not interactive.

src/ActionList/Description.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ export const Description: React.FC<React.PropsWithChildren<ActionListDescription
4444
sx={merge({...styles, color: disabled ? 'fg.disabled' : 'fg.muted'}, sx as SxProp)}
4545
title={props.children as string}
4646
inline={true}
47-
maxWidth="100%"
4847
>
4948
{props.children}
5049
</Truncate>

src/ActionList/Item.tsx

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@ import {defaultSxProp} from '../utils/defaultSxProp'
99
import {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic'
1010
import {ActionListContainerContext} from './ActionListContainerContext'
1111
import {Description} from './Description'
12-
import {ActionListProps, ListContext} from './List'
12+
import {ListContext} from './List'
1313
import {Selection} from './Selection'
1414
import {ActionListItemProps, getVariantStyles, ItemContext, TEXT_ROW_HEIGHT} from './shared'
1515
import {LeadingVisual, TrailingVisual} from './Visuals'
16+
import {MenuContext} from '../ActionMenu/ActionMenu'
1617
import {GroupContext} from './Group'
1718

1819
const LiBox = styled.li<SxProp>(sx)
@@ -29,6 +30,8 @@ export const Item = React.forwardRef<HTMLLIElement, ActionListItemProps>(
2930
id,
3031
role,
3132
_PrivateItemWrapper,
33+
// @ts-ignore tabIndex is sometimes passed as a prop in dotcom.
34+
tabIndex,
3235
...props
3336
},
3437
forwardedRef,
@@ -38,10 +41,13 @@ export const Item = React.forwardRef<HTMLLIElement, ActionListItemProps>(
3841
trailingVisual: TrailingVisual,
3942
description: Description,
4043
})
44+
4145
const {variant: listVariant, showDividers, selectionVariant: listSelectionVariant} = React.useContext(ListContext)
4246
const {container, afterSelect, selectionAttribute} = React.useContext(ActionListContainerContext)
47+
const menuContext = React.useContext(MenuContext)
4348
const {selectionVariant: groupSelectionVariant} = React.useContext(GroupContext)
4449

50+
const selectionVariant = groupSelectionVariant ?? listSelectionVariant
4551
const onSelect = React.useCallback(
4652
(
4753
event: React.MouseEvent<HTMLLIElement> | React.KeyboardEvent<HTMLLIElement>,
@@ -55,10 +61,6 @@ export const Item = React.forwardRef<HTMLLIElement, ActionListItemProps>(
5561
[onSelectUser],
5662
)
5763

58-
const selectionVariant: ActionListProps['selectionVariant'] = groupSelectionVariant
59-
? groupSelectionVariant
60-
: listSelectionVariant
61-
6264
/** Infer item role based on the container */
6365
let itemRole: ActionListItemProps['role']
6466
if (container === 'ActionMenu' || container === 'DropdownMenu') {
@@ -84,12 +86,22 @@ export const Item = React.forwardRef<HTMLLIElement, ActionListItemProps>(
8486
},
8587
}
8688

89+
const isTopLevelInteractive = () =>
90+
_PrivateItemWrapper !== undefined ||
91+
// @ts-ignore props.as may be defined, may not.
92+
props.as === 'button' ||
93+
// @ts-ignore props.as may be defined, may not.
94+
props.as === 'a' ||
95+
menuContext.anchorId !== undefined ||
96+
role?.match(/menuitem/) ||
97+
tabIndex !== undefined
98+
8799
const styles = {
88100
position: 'relative',
89101
display: 'flex',
90-
paddingX: 2,
102+
paddingX: isTopLevelInteractive() ? 2 : 0,
91103
fontSize: 1,
92-
paddingY: '6px', // custom value off the scale
104+
paddingY: isTopLevelInteractive() ? '6px' : 0, // custom value off the scale
93105
lineHeight: TEXT_ROW_HEIGHT,
94106
minHeight: 5,
95107
marginX: listVariant === 'inset' ? 2 : 0,
@@ -145,6 +157,10 @@ export const Item = React.forwardRef<HTMLLIElement, ActionListItemProps>(
145157
borderTopWidth: showDividers ? `1px` : '0',
146158
borderColor: 'var(--divider-color, transparent)',
147159
},
160+
'button[data-component="ActionList.Item--DividerContainer"]': {
161+
textAlign: 'left',
162+
padding: 0,
163+
},
148164
// show between 2 items
149165
':not(:first-of-type)': {'--divider-color': theme?.colors.actionListItem.inlineDivider},
150166
// hide divider after dividers & group header, with higher importance!
@@ -182,13 +198,13 @@ export const Item = React.forwardRef<HTMLLIElement, ActionListItemProps>(
182198
const inlineDescriptionId = useId(id && `${id}--inline-description`)
183199
const blockDescriptionId = useId(id && `${id}--block-description`)
184200

185-
const ItemWrapper = _PrivateItemWrapper || React.Fragment
201+
const ItemWrapper = _PrivateItemWrapper || Box
186202

187203
const menuItemProps = {
188204
onClick: clickHandler,
189205
onKeyPress: keyPressHandler,
190206
'aria-disabled': disabled ? true : undefined,
191-
tabIndex: disabled ? undefined : 0,
207+
tabIndex: disabled || !isTopLevelInteractive() ? undefined : 0,
192208
'aria-labelledby': `${labelId} ${
193209
slots.description && slots.description.props.variant !== 'block' ? inlineDescriptionId : ''
194210
}`,
@@ -199,17 +215,41 @@ export const Item = React.forwardRef<HTMLLIElement, ActionListItemProps>(
199215

200216
const containerProps = _PrivateItemWrapper ? {role: role || itemRole ? 'none' : undefined} : menuItemProps
201217

202-
const wrapperProps = _PrivateItemWrapper ? menuItemProps : {}
218+
const wrapperProps = _PrivateItemWrapper
219+
? menuItemProps
220+
: {
221+
sx: {
222+
display: 'flex',
223+
paddingX: isTopLevelInteractive() ? 0 : 2,
224+
paddingY: isTopLevelInteractive() ? 0 : '6px', // custom value off the scale
225+
flexGrow: 1,
226+
},
227+
}
203228

204229
return (
205230
<ItemContext.Provider value={{variant, disabled, inlineDescriptionId, blockDescriptionId}}>
206231
<LiBox ref={forwardedRef} sx={merge<BetterSystemStyleObject>(styles, sxProp)} {...containerProps} {...props}>
232+
{/* @ts-ignore onClick prop is only passed when _PrivateItemWrapper is set by ActionList.LinkItem. */}
207233
<ItemWrapper {...wrapperProps}>
208234
<Selection selected={selected} />
209235
{slots.leadingVisual}
210236
<Box
211237
data-component="ActionList.Item--DividerContainer"
212-
sx={{display: 'flex', flexDirection: 'column', flexGrow: 1, minWidth: 0}}
238+
sx={{
239+
display: 'flex',
240+
flexDirection: 'column',
241+
flexGrow: 1,
242+
minWidth: 0,
243+
borderStyle: 'none',
244+
backgroundColor: 'transparent',
245+
cursor: 'inherit',
246+
fontSize: 'inherit',
247+
color: getVariantStyles(variant, disabled).color,
248+
lineHeight: '20px',
249+
}}
250+
// @ts-ignore `as` prop may be passed to ActionList.Item, even if it isn't defined in ActionListItemProps.
251+
// If this item is inside an ActionMenu, don't render an interactive button.
252+
as={isTopLevelInteractive() ? 'div' : 'button'}
213253
>
214254
<ConditionalBox if={Boolean(slots.trailingVisual)} sx={{display: 'flex', flexGrow: 1}}>
215255
<ConditionalBox

src/ActionMenu.tsx

Lines changed: 0 additions & 138 deletions
This file was deleted.

src/__tests__/ActionMenu.test.tsx renamed to src/ActionMenu/ActionMenu.test.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'
33
import {axe, toHaveNoViolations} from 'jest-axe'
44
import React from 'react'
55
import theme from '../theme'
6-
import {ActionMenu, ActionList, BaseStyles, ThemeProvider, SSRProvider} from '..'
6+
import {ActionMenu, MenuContext, ActionList, BaseStyles, ThemeProvider, SSRProvider} from '..'
77
import {behavesAsComponent, checkExports} from '../utils/testing'
88
import {SingleSelect} from '../ActionMenu/ActionMenu.features.stories'
99
import {MixedSelection} from '../ActionMenu/ActionMenu.examples.stories'
@@ -47,6 +47,7 @@ describe('ActionMenu', () => {
4747
checkExports('ActionMenu', {
4848
default: undefined,
4949
ActionMenu,
50+
MenuContext,
5051
})
5152

5253
it('should open Menu on MenuButton click', async () => {

src/ActionMenu/ActionMenu.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ export type MenuContextProps = Pick<
1616
> & {
1717
onClose?: (gesture: 'anchor-click' | 'click-outside' | 'escape' | 'tab') => void
1818
}
19-
const MenuContext = React.createContext<MenuContextProps>({renderAnchor: null, open: false})
19+
20+
export const MenuContext = React.createContext<MenuContextProps>({renderAnchor: null, open: false})
2021

2122
export type ActionMenuProps = {
2223
/**

src/NavList/NavList.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ describe('NavList.Item with NavList.SubNav', () => {
211211
</ThemeProvider>,
212212
)
213213

214-
const button = getByRole('button')
214+
const button = getByRole('button', {name: 'Item'})
215215

216216
// Starts open
217217
expect(queryByRole('list', {name: 'Item'})).toBeVisible()

0 commit comments

Comments
 (0)