Skip to content

Commit 4a5b3bb

Browse files
authored
Merge pull request #1078 from primer/action-list
ActionList
2 parents 6f1e7af + e065a54 commit 4a5b3bb

File tree

13 files changed

+845
-1
lines changed

13 files changed

+845
-1
lines changed

.eslintrc.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@
6262
"rules": {
6363
"@typescript-eslint/no-explicit-any": 2,
6464
"@typescript-eslint/explicit-module-boundary-types": 0,
65-
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
65+
"@typescript-eslint/no-unused-vars": ["error", {"argsIgnorePattern": "^_"}]
6666
}
6767
}
6868
]

docs/content/ActionList.mdx

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
---
2+
title: ActionList
3+
---
4+
5+
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`.
6+
7+
## Minimal example
8+
9+
```jsx live
10+
<ActionList
11+
items={[
12+
{text: 'New file'},
13+
ActionList.Divider,
14+
{text: 'Copy link'},
15+
{text: 'Edit file'},
16+
{text: 'Delete file', variant: 'danger'}
17+
]}
18+
/>
19+
```
20+
21+
## Example with grouped items
22+
23+
```jsx live
24+
<ActionList
25+
groupMetadata={[
26+
{groupId: '0'},
27+
{groupId: '1', header: {title: 'Live query', variant: 'subtle'}},
28+
{groupId: '2', header: {title: 'Layout', variant: 'subtle'}},
29+
{groupId: '3'},
30+
{groupId: '4'}
31+
]}
32+
items={[
33+
{leadingVisual: TypographyIcon, text: 'Rename', groupId: '0'},
34+
{leadingVisual: VersionsIcon, text: 'Duplicate', groupId: '0'},
35+
{leadingVisual: SearchIcon, text: 'repo:github/memex,github/github', groupId: '1'},
36+
{
37+
leadingVisual: NoteIcon,
38+
text: 'Table',
39+
description: 'Information-dense table optimized for operations across teams',
40+
descriptionVariant: 'block',
41+
groupId: '2'
42+
},
43+
{
44+
leadingVisual: ProjectIcon,
45+
text: 'Board',
46+
description: 'Kanban-style board focused on visual states',
47+
descriptionVariant: 'block',
48+
groupId: '2'
49+
},
50+
{
51+
leadingVisual: FilterIcon,
52+
text: 'Save sort and filters to current view',
53+
groupId: '3'
54+
},
55+
{leadingVisual: FilterIcon, text: 'Save sort and filters to new view', groupId: '3'},
56+
{leadingVisual: GearIcon, text: 'View settings', groupId: '4'}
57+
]}
58+
/>
59+
```
60+
61+
## Component props
62+
63+
| Name | Type | Default | Description |
64+
| :------------ | :---------------------------------- | :---------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------ |
65+
| items | `ItemProps[]` | `undefined` | Required. A list of item objects conforming to the `ActionList.Item` props interface. |
66+
| renderItems | `(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. |
67+
| groupMetadata | `GroupProps[]` | `undefined` | Optional. If defined, `ActionList` will group `items` into `ActionList.Group`s separated by `ActionList.Divider` according to their `groupId` property. |

src/ActionList/Divider.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import React from 'react'
2+
import styled from 'styled-components'
3+
import {get} from '../constants'
4+
5+
const StyledDivider = styled.div`
6+
height: 1px;
7+
background: ${get('colors.selectMenu.borderSecondary')};
8+
margin-top: calc(${get('space.2')} - 1px);
9+
margin-bottom: ${get('space.2')};
10+
`
11+
12+
/**
13+
* Visually separates `Item`s or `Group`s in an `ActionList`.
14+
*/
15+
export function Divider(): JSX.Element {
16+
return <StyledDivider />
17+
}
18+
19+
/**
20+
* `Divider` fulfills the `ItemPropsWithCustomRenderer` contract,
21+
* so it can be used inline in an `ActionList`’s `items` prop.
22+
* In other words, `items={[ActionList.Divider]}` is supported as a concise
23+
* alternative to `items={[{renderItem: () => <ActionList.Divider />}]}`.
24+
*/
25+
Divider.renderItem = Divider

src/ActionList/Group.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import React from 'react'
2+
import styled from 'styled-components'
3+
import sx, {SxProp} from '../sx'
4+
import {Header, HeaderProps} from './Header'
5+
6+
/**
7+
* Contract for props passed to the `Group` component.
8+
*/
9+
export interface GroupProps extends React.ComponentPropsWithoutRef<'div'>, SxProp {
10+
/**
11+
* Props for a `Header` to render in the `Group`.
12+
*/
13+
header?: HeaderProps
14+
15+
/**
16+
* `Items` to render in the `Group`.
17+
*/
18+
items?: JSX.Element[]
19+
}
20+
21+
const StyledGroup = styled.div`
22+
${sx}
23+
`
24+
25+
/**
26+
* Collects related `Items` in an `ActionList`.
27+
*/
28+
export function Group({header, items, ...props}: GroupProps): JSX.Element {
29+
return (
30+
<StyledGroup {...props}>
31+
{header && <Header {...header} />}
32+
{items}
33+
</StyledGroup>
34+
)
35+
}

src/ActionList/Header.tsx

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import React from 'react'
2+
import styled, {css} from 'styled-components'
3+
import {get} from '../constants'
4+
import sx, {SxProp} from '../sx'
5+
6+
/**
7+
* Contract for props passed to the `Header` component.
8+
*/
9+
export interface HeaderProps extends React.ComponentPropsWithoutRef<'div'>, SxProp {
10+
/**
11+
* Style variations. Usage is discretionary.
12+
*
13+
* - `"filled"` - Superimposed on a background, offset from nearby content
14+
* - `"subtle"` - Relatively less offset from nearby content
15+
*/
16+
variant?: 'subtle' | 'filled'
17+
18+
/**
19+
* Primary text which names a `Group`.
20+
*/
21+
title: string
22+
23+
/**
24+
* Secondary text which provides additional information about a `Group`.
25+
*/
26+
auxiliaryText?: string
27+
}
28+
29+
const StyledHeader = styled.div<{variant: HeaderProps['variant']} & SxProp>`
30+
{
31+
/* 6px vertical padding + 20px line height = 32px total height
32+
*
33+
* TODO: When rem-based spacing on a 4px scale lands, replace
34+
* hardcoded '6px' with 'calc((${get('space.s32')} - ${get('space.20')}) / 2)'.
35+
*/
36+
}
37+
padding: 6px ${get('space.3')};
38+
font-size: ${get('fontSizes.0')};
39+
font-weight: ${get('fontWeights.bold')};
40+
color: ${get('colors.text.secondary')};
41+
42+
${({variant}) =>
43+
variant === 'filled' &&
44+
css`
45+
background: ${get('colors.bg.tertiary')};
46+
margin: ${get('space.2')} 0;
47+
border-top: 1px solid ${get('colors.border.tertiary')};
48+
border-bottom: 1px solid ${get('colors.border.tertiary')};
49+
50+
&:first-child {
51+
margin-top: 0;
52+
}
53+
`}
54+
55+
${sx}
56+
`
57+
58+
/**
59+
* Displays the name and description of a `Group`.
60+
*/
61+
export function Header({
62+
variant = 'subtle',
63+
title,
64+
auxiliaryText,
65+
children: _children,
66+
...props
67+
}: HeaderProps): JSX.Element {
68+
return (
69+
<StyledHeader role="heading" variant={variant} {...props}>
70+
{title}
71+
{auxiliaryText && <span>auxiliaryText</span>}
72+
</StyledHeader>
73+
)
74+
}

src/ActionList/Item.tsx

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import type {IconProps} from '@primer/octicons-react'
2+
import React from 'react'
3+
import styled from 'styled-components'
4+
import {get} from '../constants'
5+
import sx, {SxProp} from '../sx'
6+
7+
/**
8+
* Contract for props passed to the `Item` component.
9+
*/
10+
export interface ItemProps extends React.ComponentPropsWithoutRef<'div'>, SxProp {
11+
/**
12+
* Primary text which names an `Item`.
13+
*/
14+
text: string
15+
16+
/**
17+
* Secondary text which provides additional information about an `Item`.
18+
*/
19+
description?: string
20+
21+
/**
22+
* Secondary text style variations. Usage is discretionary.
23+
*
24+
* - `"inline"` - Secondary text is positioned beside primary text.
25+
* - `"block"` - Secondary text is positioned below primary text.
26+
*/
27+
descriptionVariant?: 'inline' | 'block'
28+
29+
/**
30+
* Icon (or similar) positioned before `Item` text.
31+
*/
32+
leadingVisual?: React.FunctionComponent<IconProps>
33+
34+
/**
35+
* Style variations associated with various `Item` types.
36+
*
37+
* - `"default"` - An action `Item`.
38+
* - `"danger"` - A destructive action `Item`.
39+
*/
40+
variant?: 'default' | 'danger'
41+
}
42+
43+
const StyledItem = styled.div<{variant: ItemProps['variant']} & SxProp>`
44+
/* 6px vertical padding + 20px line height = 32px total height
45+
*
46+
* TODO: When rem-based spacing on a 4px scale lands, replace
47+
* hardcoded '6px' with 'calc((${get('space.s32')} - ${get('space.20')}) / 2)'.
48+
*/
49+
padding: 6px ${get('space.2')};
50+
display: flex;
51+
border-radius: ${get('radii.2')};
52+
color: ${({variant}) => (variant === 'danger' ? get('colors.text.danger') : 'inherit')};
53+
54+
@media (hover: hover) and (pointer: fine) {
55+
:hover {
56+
background: ${props =>
57+
props.variant === 'danger' ? get('colors.bg.danger') : get('colors.selectMenu.tapHighlight')};
58+
cursor: pointer;
59+
}
60+
}
61+
62+
${sx}
63+
`
64+
65+
const StyledTextContainer = styled.div<{descriptionVariant: ItemProps['descriptionVariant']}>`
66+
flex-direction: ${({descriptionVariant}) => (descriptionVariant === 'inline' ? 'row' : 'column')};
67+
`
68+
69+
const LeadingVisualContainer = styled.div`
70+
{
71+
/* Match visual height to adjacent text line height.
72+
*
73+
* TODO: When rem-based spacing on a 4px scale lands, replace
74+
* hardcoded '20px' with '${get('space.s20')}'.
75+
*/
76+
}
77+
height: 20px;
78+
width: ${get('space.3')};
79+
display: flex;
80+
flex-direction: column;
81+
justify-content: center;
82+
margin-right: ${get('space.2')};
83+
84+
svg {
85+
fill: ${get('colors.text.secondary')};
86+
font-size: ${get('fontSizes.0')};
87+
}
88+
`
89+
90+
const DescriptionContainer = styled.span`
91+
color: ${get('colors.text.secondary')};
92+
`
93+
94+
/**
95+
* An actionable or selectable `Item` with an optional icon and description.
96+
*/
97+
export function Item({
98+
text,
99+
description,
100+
descriptionVariant = 'inline',
101+
leadingVisual: LeadingVisual,
102+
variant = 'default',
103+
...props
104+
}: Partial<ItemProps>): JSX.Element {
105+
return (
106+
<StyledItem variant={variant} {...props}>
107+
{LeadingVisual && (
108+
<LeadingVisualContainer>
109+
<LeadingVisual />
110+
</LeadingVisualContainer>
111+
)}
112+
<StyledTextContainer descriptionVariant={descriptionVariant}>
113+
<div>{text}</div>
114+
{description && <DescriptionContainer>{description}</DescriptionContainer>}
115+
</StyledTextContainer>
116+
</StyledItem>
117+
)
118+
}

0 commit comments

Comments
 (0)