Skip to content

Commit bde3a03

Browse files
jfuchscolebemis
andauthored
ActionList.Item accepts a polymorphic 'as' prop (#1463)
* ActionList.Item accepts a polymorphic 'as' prop * Add back key * fixes * Update docs/content/ActionList.mdx Co-authored-by: Cole Bemis <[email protected]> * Update docs/content/ActionList.mdx Co-authored-by: Cole Bemis <[email protected]> * Apply suggestions from code review Co-authored-by: Cole Bemis <[email protected]> Co-authored-by: Cole Bemis <[email protected]>
1 parent 065bd3c commit bde3a03

File tree

9 files changed

+338
-19
lines changed

9 files changed

+338
-19
lines changed

.changeset/nine-days-own.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/components': minor
3+
---
4+
5+
`ActionList.item` accepts an `as` prop, allowing it to be a link, or (in combination with the renderItem prop) a Next.js or React Router link

docs/content/ActionList.mdx

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
---
22
title: ActionList
3+
status: Alpha
4+
source: https://github.com/primer/react/tree/main/src/ActionList
35
---
46

57
An `ActionList` is a list of items which can be activated or selected. `ActionList` is the base component for many of our menu-type components, including `DropdownMenu` and `ActionMenu`.
@@ -62,11 +64,36 @@ An `ActionList` is a list of items which can be activated or selected. `ActionLi
6264
/>
6365
```
6466

65-
## Component props
67+
## Example with custom item renderer
6668

67-
| Name | Type | Default | Description |
68-
| :--------------- | :---------------------------------- | :---------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------ |
69-
| items | `ItemProps[]` | `undefined` | Required. A list of item objects conforming to the `ActionList.Item` props interface. |
70-
| renderItem | `(props: ItemProps) => JSX.Element` | `ActionList.Item` | Optional. If defined, each item in `items` will be passed to this function, allowing for `ActionList`-wide custom item rendering. |
71-
| groupMetadata | `GroupProps[]` | `undefined` | Optional. If defined, `ActionList` will group `items` into `ActionList.Group`s separated by `ActionList.Divider` according to their `groupId` property. |
72-
| showItemDividers | `boolean` | `false` | Optional. If `true` dividers will be displayed above each `ActionList.Item` which does not follow an `ActionList.Header` or `ActionList.Divider` |
69+
```jsx
70+
<ActionList
71+
items={[
72+
{
73+
text: 'Vanilla link',
74+
renderItem: props => <ActionList.Item as="a" href="/about" {...props} />
75+
},
76+
{
77+
text: 'React Router link',
78+
renderItem: props => <ActionList.Item as={ReactRouterLikeLink} to="/about" {...props} />
79+
},
80+
{
81+
text: 'NextJS style',
82+
renderItem: props => (
83+
<NextJSLikeLink href="/about">
84+
<ActionList.Item as="a" {...props} />
85+
</NextJSLikeLink>
86+
)
87+
}
88+
]}
89+
/>
90+
```
91+
92+
## Props
93+
94+
| Name | Type | Default | Description |
95+
| :--------------- | :------------------------------------------------------ | :---------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------ |
96+
| items | `Array<ItemProps \| (props: ItemProps) => JSX.Element>` | `undefined` | Required. A list of item objects conforming to the `ActionList.Item` props interface. |
97+
| renderItem | `(props: ItemProps) => JSX.Element` | `ActionList.Item` | Optional. If defined, each item in `items` will be passed to this function, allowing for `ActionList`-wide custom item rendering. |
98+
| groupMetadata | `GroupProps[]` | `undefined` | Optional. If defined, `ActionList` will group `items` into `ActionList.Group`s separated by `ActionList.Divider` according to their `groupId` property. |
99+
| showItemDividers | `boolean` | `false` | Optional. If `true` dividers will be displayed above each `ActionList.Item` which does not follow an `ActionList.Header` or `ActionList.Divider` |

package-lock.json

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"dependencies": {
4646
"@primer/octicons-react": "^13.0.0",
4747
"@primer/primitives": "4.8.1",
48+
"@radix-ui/react-polymorphic": "0.0.14",
4849
"@react-aria/ssr": "3.1.0",
4950
"@styled-system/css": "5.1.5",
5051
"@styled-system/props": "5.1.5",

src/ActionList/Item.tsx

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {
1414
isActiveDescendantAttribute
1515
} from '../behaviors/focusZone'
1616
import {useSSRSafeId} from '@react-aria/ssr'
17+
import {ForwardRefComponent as PolymorphicForwardRefComponent} from '@radix-ui/react-polymorphic'
18+
import {AriaRole} from '../utils/types'
1719

1820
/**
1921
* These colors are not yet in our default theme. Need to remove this once they are added.
@@ -48,7 +50,7 @@ const customItemThemes = {
4850
/**
4951
* Contract for props passed to the `Item` component.
5052
*/
51-
export interface ItemProps extends Omit<React.ComponentPropsWithoutRef<'div'>, 'id'>, SxProp {
53+
export interface ItemProps extends SxProp {
5254
/**
5355
* Primary text which names an `Item`.
5456
*/
@@ -124,6 +126,21 @@ export interface ItemProps extends Omit<React.ComponentPropsWithoutRef<'div'>, '
124126
* An id associated with this item. Should be unique between items
125127
*/
126128
id?: number | string
129+
130+
/**
131+
* Node to be included inside the item before the text.
132+
*/
133+
children?: React.ReactNode
134+
135+
/**
136+
* The ARIA role describing the function of `List` component. `option` is a common value.
137+
*/
138+
role?: AriaRole
139+
140+
/**
141+
* An item to pass back in the `onAction` callback, meant as
142+
*/
143+
item?: ItemInput
127144
}
128145

129146
const getItemVariant = (variant = 'default', disabled?: boolean) => {
@@ -191,6 +208,7 @@ const StyledItem = styled.div<
191208
color: ${({variant, item}) => getItemVariant(variant, item?.disabled).color};
192209
// 2 frames on a 60hz monitor
193210
transition: background 33.333ms linear;
211+
text-decoration: none;
194212
195213
@media (hover: hover) and (pointer: fine) {
196214
:hover {
@@ -315,8 +333,9 @@ const MultiSelectInput = styled.input`
315333
/**
316334
* An actionable or selectable `Item` with an optional icon and description.
317335
*/
318-
export function Item(itemProps: Partial<ItemProps> & {item?: ItemInput}): JSX.Element {
336+
export const Item = React.forwardRef((itemProps, ref) => {
319337
const {
338+
as: Component,
320339
text,
321340
description,
322341
descriptionVariant = 'inline',
@@ -352,7 +371,7 @@ export function Item(itemProps: Partial<ItemProps> & {item?: ItemInput}): JSX.El
352371
}
353372

354373
if (!event.defaultPrevented && [' ', 'Enter'].includes(event.key)) {
355-
onAction?.(itemProps as ItemProps, event)
374+
onAction?.(itemProps, event)
356375
}
357376
},
358377
[onAction, disabled, itemProps, onKeyPress]
@@ -365,7 +384,7 @@ export function Item(itemProps: Partial<ItemProps> & {item?: ItemInput}): JSX.El
365384
}
366385
onClick?.(event)
367386
if (!event.defaultPrevented) {
368-
onAction?.(itemProps as ItemProps, event)
387+
onAction?.(itemProps, event)
369388
}
370389
},
371390
[onAction, disabled, itemProps, onClick]
@@ -379,6 +398,8 @@ export function Item(itemProps: Partial<ItemProps> & {item?: ItemInput}): JSX.El
379398

380399
return (
381400
<StyledItem
401+
ref={ref}
402+
as={Component}
382403
tabIndex={disabled ? undefined : -1}
383404
variant={variant}
384405
showDivider={showDivider}
@@ -457,4 +478,6 @@ export function Item(itemProps: Partial<ItemProps> & {item?: ItemInput}): JSX.El
457478
</DividedContent>
458479
</StyledItem>
459480
)
460-
}
481+
}) as PolymorphicForwardRefComponent<'div', ItemProps>
482+
483+
Item.displayName = 'ActionList.Item'

src/ActionList/List.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from 'react'
1+
import React, {Key} from 'react'
22
import type {AriaRole} from '../utils/types'
33
import {Group, GroupProps} from './Group'
44
import {Item, ItemProps} from './Item'
@@ -8,7 +8,9 @@ import {get} from '../constants'
88
import {SystemCssProperties} from '@styled-system/css'
99
import {hasActiveDescendantAttribute} from '../behaviors/focusZone'
1010

11-
export type ItemInput = ItemProps | (Partial<ItemProps> & {renderItem: typeof Item})
11+
type RenderItemFn = (props: ItemProps) => React.ReactElement
12+
13+
export type ItemInput = ItemProps | ((Partial<ItemProps> & {renderItem: RenderItemFn}) & {key?: Key})
1214

1315
/**
1416
* Contract for props passed to the `List` component.
@@ -34,7 +36,7 @@ export interface ListPropsBase {
3436
* without a `Group`-level or `Item`-level custom `Item` renderer will be
3537
* rendered using this function component.
3638
*/
37-
renderItem?: typeof Item
39+
renderItem?: RenderItemFn
3840

3941
/**
4042
* A `List`-level custom `Group` renderer. Every `Group` within this `List`
@@ -72,14 +74,14 @@ export interface GroupedListProps extends ListPropsBase {
7274
*/
7375
groupMetadata: ((
7476
| Omit<GroupProps, 'items'>
75-
| Omit<Partial<GroupProps> & {renderItem?: typeof Item; renderGroup?: typeof Group}, 'items'>
77+
| Omit<Partial<GroupProps> & {renderItem?: RenderItemFn; renderGroup?: typeof Group}, 'items'>
7678
) & {groupId: string})[]
7779

7880
/**
7981
* A collection of `Item` props, plus associated group identifiers
8082
* and `Item`-level custom `Item` renderers.
8183
*/
82-
items: ((ItemProps | (Partial<ItemProps> & {renderItem: typeof Item})) & {groupId: string})[]
84+
items: ((ItemProps | (Partial<ItemProps> & {renderItem: RenderItemFn})) & {groupId: string})[]
8385
}
8486

8587
/**
@@ -162,7 +164,7 @@ export const List = React.forwardRef<HTMLDivElement, ListProps>((props, forwarde
162164
const renderItem = (itemProps: ItemInput, item: ItemInput, itemIndex: number) => {
163165
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
164166
const ItemComponent = ('renderItem' in itemProps && itemProps.renderItem) || props.renderItem || Item
165-
const key = itemProps.key ?? itemProps.id?.toString() ?? itemIndex.toString()
167+
const key = ('key' in itemProps ? itemProps.key : undefined) ?? itemProps.id?.toString() ?? itemIndex.toString()
166168
return (
167169
<ItemComponent
168170
showDivider={props.showItemDividers}

src/__tests__/ActionList.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,9 @@ describe('ActionList', () => {
4545
cleanup()
4646
})
4747
})
48+
49+
describe('ActionList.Item', () => {
50+
behavesAsComponent({
51+
Component: ActionList.Item
52+
})
53+
})

0 commit comments

Comments
 (0)