Skip to content

Commit

Permalink
ActionList.Item accepts a polymorphic 'as' prop
Browse files Browse the repository at this point in the history
  • Loading branch information
jfuchs committed Sep 24, 2021
1 parent 7c23dbc commit e0410f1
Show file tree
Hide file tree
Showing 7 changed files with 118 additions and 11 deletions.
5 changes: 5 additions & 0 deletions .changeset/nine-days-own.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/components': minor
---

`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
15 changes: 15 additions & 0 deletions docs/content/ActionList.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,17 @@ An `ActionList` is a list of items which can be activated or selected. `ActionLi
/>
```

## Example with custom item renderer

```jsx live
<ActionList
items={[
{
key: '1',
leadingVisual: TypographyIcon,

```
## Component props
| Name | Type | Default | Description |
Expand All @@ -70,3 +81,7 @@ An `ActionList` is a list of items which can be activated or selected. `ActionLi
| 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. |
| groupMetadata | `GroupProps[]` | `undefined` | Optional. If defined, `ActionList` will group `items` into `ActionList.Group`s separated by `ActionList.Divider` according to their `groupId` property. |
| showItemDividers | `boolean` | `false` | Optional. If `true` dividers will be displayed above each `ActionList.Item` which does not follow an `ActionList.Header` or `ActionList.Divider` |
## Item props
TK props table
5 changes: 5 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"dependencies": {
"@primer/octicons-react": "^13.0.0",
"@primer/primitives": "4.7.1",
"@radix-ui/react-polymorphic": "0.0.14",
"@react-aria/ssr": "3.1.0",
"@styled-system/css": "5.1.5",
"@styled-system/props": "5.1.5",
Expand Down
33 changes: 28 additions & 5 deletions src/ActionList/Item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
isActiveDescendantAttribute
} from '../behaviors/focusZone'
import {useSSRSafeId} from '@react-aria/ssr'
import {ForwardRefComponent as PolymorphicForwardRefComponent} from '@radix-ui/react-polymorphic'
import {AriaRole} from '../utils/types'

/**
* These colors are not yet in our default theme. Need to remove this once they are added.
Expand Down Expand Up @@ -48,7 +50,7 @@ const customItemThemes = {
/**
* Contract for props passed to the `Item` component.
*/
export interface ItemProps extends Omit<React.ComponentPropsWithoutRef<'div'>, 'id'>, SxProp {
export interface ItemProps extends SxProp {
/**
* Primary text which names an `Item`.
*/
Expand Down Expand Up @@ -124,6 +126,21 @@ export interface ItemProps extends Omit<React.ComponentPropsWithoutRef<'div'>, '
* An id associated with this item. Should be unique between items
*/
id?: number | string

/**
* Node to be included inside the item before the text.
*/
children?: React.ReactNode

/**
* The ARIA role describing the function of `List` component. `option` is a common value.
*/
role?: AriaRole

/**
* An item to pass back in the `onAction` callback, meant as
*/
item?: ItemInput
}

const getItemVariant = (variant = 'default', disabled?: boolean) => {
Expand Down Expand Up @@ -191,6 +208,7 @@ const StyledItem = styled.div<
color: ${({variant, item}) => getItemVariant(variant, item?.disabled).color};
// 2 frames on a 60hz monitor
transition: background 33.333ms linear;
text-decoration: none;
@media (hover: hover) and (pointer: fine) {
:hover {
Expand Down Expand Up @@ -315,8 +333,9 @@ const MultiSelectInput = styled.input`
/**
* An actionable or selectable `Item` with an optional icon and description.
*/
export function Item(itemProps: Partial<ItemProps> & {item?: ItemInput}): JSX.Element {
const Item = React.forwardRef((itemProps, ref) => {
const {
as: Component,
text,
description,
descriptionVariant = 'inline',
Expand Down Expand Up @@ -352,7 +371,7 @@ export function Item(itemProps: Partial<ItemProps> & {item?: ItemInput}): JSX.El
}

if (!event.defaultPrevented && [' ', 'Enter'].includes(event.key)) {
onAction?.(itemProps as ItemProps, event)
onAction?.(itemProps, event)
}
},
[onAction, disabled, itemProps, onKeyPress]
Expand All @@ -365,7 +384,7 @@ export function Item(itemProps: Partial<ItemProps> & {item?: ItemInput}): JSX.El
}
onClick?.(event)
if (!event.defaultPrevented) {
onAction?.(itemProps as ItemProps, event)
onAction?.(itemProps, event)
}
},
[onAction, disabled, itemProps, onClick]
Expand All @@ -379,6 +398,8 @@ export function Item(itemProps: Partial<ItemProps> & {item?: ItemInput}): JSX.El

return (
<StyledItem
ref={ref}
as={Component}
tabIndex={disabled ? undefined : -1}
variant={variant}
showDivider={showDivider}
Expand Down Expand Up @@ -457,4 +478,6 @@ export function Item(itemProps: Partial<ItemProps> & {item?: ItemInput}): JSX.El
</DividedContent>
</StyledItem>
)
}
}) as PolymorphicForwardRefComponent<'div', ItemProps>

export {Item}
12 changes: 7 additions & 5 deletions src/ActionList/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import {get} from '../constants'
import {SystemCssProperties} from '@styled-system/css'
import {hasActiveDescendantAttribute} from '../behaviors/focusZone'

export type ItemInput = ItemProps | (Partial<ItemProps> & {renderItem: typeof Item})
type RenderItemFn = (props: ItemProps) => React.ReactElement

export type ItemInput = ItemProps | (Partial<ItemProps> & {renderItem: RenderItemFn})

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

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

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

/**
Expand Down Expand Up @@ -162,7 +164,7 @@ export const List = React.forwardRef<HTMLDivElement, ListProps>((props, forwarde
const renderItem = (itemProps: ItemInput, item: ItemInput, itemIndex: number) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const ItemComponent = ('renderItem' in itemProps && itemProps.renderItem) || props.renderItem || Item
const key = itemProps.key ?? itemProps.id?.toString() ?? itemIndex.toString()
const key = itemProps.id?.toString() ?? itemIndex.toString()
return (
<ItemComponent
showDivider={props.showItemDividers}
Expand Down
58 changes: 57 additions & 1 deletion src/stories/ActionList.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
ArrowLeftIcon
} from '@primer/octicons-react'
import {Meta} from '@storybook/react'
import React from 'react'
import React, {forwardRef} from 'react'
import styled from 'styled-components'
import {Label, ThemeProvider} from '..'
import {ActionList as _ActionList} from '../ActionList'
Expand Down Expand Up @@ -362,3 +362,59 @@ export function SizeStressTestingStory(): JSX.Element {
)
}
SizeStressTestingStory.storyName = 'Size Stress Testing'

type ReactRouterLikeLinkProps = {to: string; children: React.ReactNode}
const ReactRouterLikeLink = forwardRef<HTMLAnchorElement, ReactRouterLikeLinkProps>(
({to, ...props}: {to: string; children: React.ReactNode}, ref) => {
// eslint-disable-next-line jsx-a11y/anchor-has-content
return <a ref={ref} href={to} {...props} />
}
)

const NextJSLikeLink = forwardRef(
({href, children}: {href: string; children: React.ReactNode}, ref): React.ReactElement => {
const child = React.Children.only(children)
const childProps = {
ref,
href
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return React.cloneElement(child, childProps)
}
)

export function LinkItemStory(): JSX.Element {
return (
<>
<h1>Simple List</h1>
<ErsatzOverlay>
<ActionList
items={[
{
text: 'A. Vanilla action',
renderItem: props => <ActionList.Item onAction={() => alert('hi?')} {...props} />
},
{
text: 'B. Vanilla link',
renderItem: props => <ActionList.Item as="a" href="/about" {...props} />
},
{
text: 'C. React Router link',
renderItem: props => <ActionList.Item as={ReactRouterLikeLink} to="/about" {...props} />
},
{
text: 'D. NextJS style',
renderItem: props => (
<NextJSLikeLink href="/about">
<ActionList.Item as="a" {...props} />
</NextJSLikeLink>
)
}
]}
/>
</ErsatzOverlay>
</>
)
}
LinkItemStory.storyName = 'List with a link item'

0 comments on commit e0410f1

Please sign in to comment.