Skip to content

Commit e0410f1

Browse files
committed
ActionList.Item accepts a polymorphic 'as' prop
1 parent 7c23dbc commit e0410f1

File tree

7 files changed

+118
-11
lines changed

7 files changed

+118
-11
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: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,17 @@ An `ActionList` is a list of items which can be activated or selected. `ActionLi
6262
/>
6363
```
6464

65+
## Example with custom item renderer
66+
67+
```jsx live
68+
<ActionList
69+
items={[
70+
{
71+
key: '1',
72+
leadingVisual: TypographyIcon,
73+
74+
```
75+
6576
## Component props
6677
6778
| Name | Type | Default | Description |
@@ -70,3 +81,7 @@ An `ActionList` is a list of items which can be activated or selected. `ActionLi
7081
| 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. |
7182
| groupMetadata | `GroupProps[]` | `undefined` | Optional. If defined, `ActionList` will group `items` into `ActionList.Group`s separated by `ActionList.Divider` according to their `groupId` property. |
7283
| showItemDividers | `boolean` | `false` | Optional. If `true` dividers will be displayed above each `ActionList.Item` which does not follow an `ActionList.Header` or `ActionList.Divider` |
84+
85+
## Item props
86+
87+
TK props table

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.7.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+
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+
export {Item}

src/ActionList/List.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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})
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 = itemProps.id?.toString() ?? itemIndex.toString()
166168
return (
167169
<ItemComponent
168170
showDivider={props.showItemDividers}

src/stories/ActionList.stories.tsx

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
ArrowLeftIcon
1313
} from '@primer/octicons-react'
1414
import {Meta} from '@storybook/react'
15-
import React from 'react'
15+
import React, {forwardRef} from 'react'
1616
import styled from 'styled-components'
1717
import {Label, ThemeProvider} from '..'
1818
import {ActionList as _ActionList} from '../ActionList'
@@ -362,3 +362,59 @@ export function SizeStressTestingStory(): JSX.Element {
362362
)
363363
}
364364
SizeStressTestingStory.storyName = 'Size Stress Testing'
365+
366+
type ReactRouterLikeLinkProps = {to: string; children: React.ReactNode}
367+
const ReactRouterLikeLink = forwardRef<HTMLAnchorElement, ReactRouterLikeLinkProps>(
368+
({to, ...props}: {to: string; children: React.ReactNode}, ref) => {
369+
// eslint-disable-next-line jsx-a11y/anchor-has-content
370+
return <a ref={ref} href={to} {...props} />
371+
}
372+
)
373+
374+
const NextJSLikeLink = forwardRef(
375+
({href, children}: {href: string; children: React.ReactNode}, ref): React.ReactElement => {
376+
const child = React.Children.only(children)
377+
const childProps = {
378+
ref,
379+
href
380+
}
381+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
382+
// @ts-ignore
383+
return React.cloneElement(child, childProps)
384+
}
385+
)
386+
387+
export function LinkItemStory(): JSX.Element {
388+
return (
389+
<>
390+
<h1>Simple List</h1>
391+
<ErsatzOverlay>
392+
<ActionList
393+
items={[
394+
{
395+
text: 'A. Vanilla action',
396+
renderItem: props => <ActionList.Item onAction={() => alert('hi?')} {...props} />
397+
},
398+
{
399+
text: 'B. Vanilla link',
400+
renderItem: props => <ActionList.Item as="a" href="/about" {...props} />
401+
},
402+
{
403+
text: 'C. React Router link',
404+
renderItem: props => <ActionList.Item as={ReactRouterLikeLink} to="/about" {...props} />
405+
},
406+
{
407+
text: 'D. NextJS style',
408+
renderItem: props => (
409+
<NextJSLikeLink href="/about">
410+
<ActionList.Item as="a" {...props} />
411+
</NextJSLikeLink>
412+
)
413+
}
414+
]}
415+
/>
416+
</ErsatzOverlay>
417+
</>
418+
)
419+
}
420+
LinkItemStory.storyName = 'List with a link item'

0 commit comments

Comments
 (0)