Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ActionList.Item accepts a polymorphic 'as' prop #1463

Merged
merged 7 commits into from
Sep 30, 2021
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
41 changes: 34 additions & 7 deletions docs/content/ActionList.mdx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
---
title: ActionList
status: Alpha
source: https://github.com/primer/react/tree/main/src/ActionList
---

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`.
Expand Down Expand Up @@ -62,11 +64,36 @@ An `ActionList` is a list of items which can be activated or selected. `ActionLi
/>
```

## Component props
## Example with custom item renderer

| Name | Type | Default | Description |
| :--------------- | :---------------------------------- | :---------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------ |
| items | `ItemProps[]` | `undefined` | Required. A list of item objects conforming to the `ActionList.Item` props interface. |
| 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` |
```jsx
<ActionList
items={[
{
text: 'Vanilla link',
renderItem: props => <ActionList.Item as="a" href="/about" {...props} />
},
{
text: 'React Router link',
renderItem: props => <ActionList.Item as={ReactRouterLikeLink} to="/about" {...props} />
},
{
text: 'NextJS style',
renderItem: props => (
<NextJSLikeLink href="/about">
<ActionList.Item as="a" {...props} />
</NextJSLikeLink>
)
}
]}
/>
```

## ActionList props
jfuchs marked this conversation as resolved.
Show resolved Hide resolved

| Name | Type | Default | Description |
| :--------------- | :------------------------------------------------------ | :---------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------ |
| items | `Array<ItemProps \| (props: ItemProps) => JSX.Element>` | `undefined` | Required. A list of item objects conforming to the `ActionList.Item` props interface. |
| 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` |
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.8.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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain why we needed to add these props?

Copy link
Contributor Author

@jfuchs jfuchs Sep 29, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These three fields were because tsc told me to :-) ... but also, they all make sense and I wonder if they only worked before because of gaps in the type coverage

}

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 {
export 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>

Item.displayName = 'Item'
jfuchs marked this conversation as resolved.
Show resolved Hide resolved
14 changes: 8 additions & 6 deletions src/ActionList/List.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react'
import React, {Key} from 'react'
import type {AriaRole} from '../utils/types'
import {Group, GroupProps} from './Group'
import {Item, ItemProps} from './Item'
Expand All @@ -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}) & {key?: Key})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious, what's the reason for including key here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same thing as the new props for item — added because tsc caught issues that it wasn't catching before. in this case, it's something we allow in an ItemInput but remove before the ItemInput becomes ItemProps.


/**
* 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 = ('key' in itemProps ? itemProps.key : undefined) ?? itemProps.id?.toString() ?? itemIndex.toString()
return (
<ItemComponent
showDivider={props.showItemDividers}
Expand Down
6 changes: 6 additions & 0 deletions src/__tests__/ActionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,9 @@ describe('ActionList', () => {
cleanup()
})
})

describe('ActionList.Item', () => {
behavesAsComponent({
Component: ActionList.Item
})
})
Loading