diff --git a/.changeset/tall-moles-smoke.md b/.changeset/tall-moles-smoke.md new file mode 100644 index 00000000000..8c37de5742a --- /dev/null +++ b/.changeset/tall-moles-smoke.md @@ -0,0 +1,5 @@ +--- +'@primer/components': minor +--- + +Add experimental `ActionList` with composable API diff --git a/docs/content/ActionList2.mdx b/docs/content/ActionList2.mdx new file mode 100644 index 00000000000..76bb9b39703 --- /dev/null +++ b/docs/content/ActionList2.mdx @@ -0,0 +1,354 @@ +--- +title: ActionList +status: Alpha +source: https://github.com/primer/react/tree/main/src/ActionList2 +storybook: '/react/storybook?path=/story/composite-components-actionlist2' +description: 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. +--- + +import {BorderBox, Avatar} from '@primer/components' +import {ActionList} from '@primer/components/unreleased' +import {Props} from '../src/props' + +import {ImageContainer} from '@primer/gatsby-theme-doctocat' +import {LinkIcon, AlertIcon, ArrowRightIcon} from '@primer/octicons-react' + + + + + + + + + + github.com/primer + + A React implementation of GitHub's Primer Design System + + + + + + + mona + Monalisa Octocat + + + + + + 4 vulnerabilities + + + + + + + + + +```js +import {ActionList} from '@primer/components/unreleased' +``` + + + +## Examples + +## Minimal example + +```javascript live noinline +const {ActionList} = unreleased + +render( + + New file + Copy link + Edit file + + Delete file + +) +``` + + + +## With Leading Visual + +Leading visuals are optional and appear at the start of an item. They can be octicons, avatars, and other custom visuals that fit a small area. + + +```javascript live noinline +const {ActionList} = unreleased + +render( + + + + github.com/primer + + + + 4 vulnerabilities + + + + mona + + +) +``` + + + +## With Trailing Visual + +Trailing visual and trailing text can display auxiliary information. They're placed at the right of the item, and can denote status, keyboard shortcuts, or be used to set expectations about what the action does. + +```javascript live noinline +const {ActionList} = unreleased + +render( + + + New file + ⌘ + N + + + Copy link + ⌘ + C + + + Edit file + ⌘ + E + + + Delete file + ⌫ + + +) +``` + + + +## With Description and Dividers + +Item dividers allow users to parse heavier amounts of information. They're placed between items and are useful in complex lists, particularly when descriptions or multi-line text is present. + +```javascript live noinline +const {ActionList} = unreleased + +render( + + + + + + mona + Monalisa Octocat + + + + + + hubot + Hubot + + + + + + primer-css + GitHub Design Systems Bot + + +) +``` + +## With Links + +When you want to add links to the List instead of actions, use `ActionList.LinkItem` + + +```javascript live noinline +const {ActionList} = unreleased + +render( + + + + + + github/primer + + + + + + MIT License + + + + + + 1.4k stars + + +) +``` + + + +## With Groups + +```javascript live noinline +const {ActionList} = unreleased + +const SelectFields = () => { + const [options, setOptions] = React.useState([ + {text: 'Status', selected: true}, + {text: 'Stage', selected: true}, + {text: 'Assignee', selected: true}, + {text: 'Team', selected: true}, + {text: 'Estimate', selected: false}, + {text: 'Due Date', selected: false} + ]) + + const visibleOptions = options.filter(option => option.selected) + const hiddenOptions = options.filter(option => !option.selected) + + const toggle = text => { + setOptions( + options.map(option => { + if (option.text === text) option.selected = !option.selected + return option + }) + ) + } + + return ( + + + {visibleOptions.map(option => ( + toggle(option.text)}> + {option.text} + + ))} + + + {hiddenOptions.map((option, index) => ( + toggle(option.text)}> + {option.text} + + ))} + {hiddenOptions.length === 0 && No hidden fields} + + + ) +} + +render() +``` + + + +## Props / API reference + +## ActionList + +| Name | Type | Default | Description | +| :--------------- | :------------------------------------------------------------------------------------------------ | :-----: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| children\* | `ActionList.Item[]` or ActionList.LinkItem[] or `ActionList.Group[]` | - | Required. | +| variant | `'inset'` or `'full'` | 'inset' | Optional. Usage is discretionary, `inset` children are offset (vertically and horizontally) from `List`’s edges, `full` children are flush (vertically and horizontally) with `List` edges | +| selectionVariant | `'single'` or `'multiple'` | - | Optional. Whether multiple Items or a single Item can be selected. | +| showDivider | boolean | false | Optional. Display a divider above each `Item` in this `List` when it does not follow a `Header` or `Divider`. | +| sx | sxProp | - | Optional. See guide to [Overriding styles](/overriding-styles) | +| role | [AriaRole](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques#roles) | - | Optional. The ARIA role describing the function of `List` component. `listbox` or `menu` are a common values. | + + + +## ActionList.Item + +| Name | Type | Default | Description | +| :--------- | :------------------------------------------------------------------------------------------------------------ | :---------: | :----------------------------------------------------------------------------------------------- | +| children\* | one of [`React.ReactNode`, `ActionList.LeadingVisual`, `ActionList.Description`, `ActionList.TrailingVisual`] | - | Required. | +| variant | `'default'` or `'danger'` | `'default'` | Optional. variant="danger" creates a destructive action `Item`. | +| onSelect | Function | - | Optional. Callback that will trigger both on click selection and keyboard selection. | +| selected | boolean | false | Optional. For `Item`s which can be selected, whether the `Item` is currently selected. | +| disabled | boolean | false | Optional. Items that are disabled can not be clicked, selected, or navigated through. | +| role | [AriaRole](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques#roles) | - | Optional. The ARIA role describing the function of `Item` component. `option` is a common value. | +| sx | sxProp | - | Optional. See guide to [Overriding styles](/overriding-styles) | + + + +## ActionList.LinkItem + +| Name | Type | Default | Description | +| :-------------------------------------------------- | :------------------------------------------------------------------------------------------------------------ | :-----: | :------------------------------------------------------------- | +| children\* | one of [`React.ReactNode`, `ActionList.LeadingVisual`, `ActionList.Description`, `ActionList.TrailingVisual`] | - | Required. | +| sx | sxProp | - | Optional. See guide to [Overriding styles](/overriding-styles) | +| + AnchorHTMLAttributes like href, target, rel, etc. | | | | + + + +## ActionList.LeadingVisual + +| Name | Type | Default | Description | +| :--------- | :------------------ | :-----: | :------------------------------------------------------------- | +| children\* | `React.ReactNode` - | - | Required. Icon (or similar) positioned before `Item` text. | +| sx | sxProp | - | Optional. See guide to [Overriding styles](/overriding-styles) | + +## ActionList.TrailingVisual + +| Name | Type | Default | Description | +| :--------- | :------------------ | :-----: | :------------------------------------------------------------- | +| children\* | `React.ReactNode` - | - | Required. Visual positioned after `Item` text. | +| sx | sxProp | - | Optional. See guide to [Overriding styles](/overriding-styles) | + + + +## ActionList.Description + +| Name | Type | Default | Description | +| :--------- | :---------------------- | :--------: | :-------------------------------------------------------------------------------------------------------------------------------- | +| children\* | `React.ReactNode` - | - | Required. Visual positioned after `Item` text. | +| variant | `'inline'` or `'block'` | `'inline'` | Optional. `"inline"` secondary text is positioned beside primary text. `"block"` secondary text is positioned below primary text. | +| sx | sxProp | - | Optional. See guide to [Overriding styles](/overriding-styles) | + + + +## ActionList.Group + +| Name | Type | Default | Description | +| :--------------- | :------------------------------------------- | :--------: | :--------------------------------------------------------------------------------------------------------------------------------------- | +| children\* | `ActionList.Item[] or ActionList.LinkItem[]` | - | Required. | +| title | string | - | Optional. Primary text which names a `Group` | +| auxiliaryText | string | - | Optional. Secondary text which provides additional information about a `Group`. | +| variant | `'filled'` or `'subtle'` | `'subtle'` | Optional. `"filled"` - Superimposed on a background, offset from nearby content, `"subtle"` - Relatively less offset from nearby content | +| selectionVariant | `'single'` or `'multiple'` or `false` | - | Optional. Set `selectionVariant` at the group level | + + + +## Further reading + +[Interface guidelines: Action List](https://primer.style/design/components/action-list) + + + +## Related components + +- [ActionMenu](/ActionMenu) +- [SelectPanel](/SelectPanel) + + diff --git a/docs/src/@primer/gatsby-theme-doctocat/live-code-scope.js b/docs/src/@primer/gatsby-theme-doctocat/live-code-scope.js index c5e30f6a6d7..ad451ada796 100644 --- a/docs/src/@primer/gatsby-theme-doctocat/live-code-scope.js +++ b/docs/src/@primer/gatsby-theme-doctocat/live-code-scope.js @@ -1,4 +1,6 @@ +import React from 'react' import * as primerComponents from '@primer/components' +import * as unreleased from '@primer/components/unreleased' import * as doctocatComponents from '@primer/gatsby-theme-doctocat' import { CheckIcon, @@ -18,6 +20,10 @@ import { GearIcon, TypographyIcon, VersionsIcon, + LinkIcon, + LawIcon, + StarIcon, + AlertIcon, ArrowRightIcon } from '@primer/octicons-react' import State from '../../../components/State' @@ -26,9 +32,16 @@ import {AnchoredOverlay} from '../../../../src/AnchoredOverlay' import {ConfirmationDialog, useConfirm} from '../../../../src/Dialog/ConfirmationDialog' import {SelectPanel} from '../../../../src/SelectPanel/SelectPanel' +const ReactRouterLink = ({to, ...props}) => { + // eslint-disable-next-line jsx-a11y/anchor-has-content + return +} + export default { ...doctocatComponents, ...primerComponents, + unreleased, + ReactRouterLink, State, CheckIcon, SearchIcon, @@ -47,6 +60,10 @@ export default { GearIcon, TypographyIcon, VersionsIcon, + LinkIcon, + LawIcon, + StarIcon, + AlertIcon, ArrowRightIcon, Dialog2, ConfirmationDialog, diff --git a/package-lock.json b/package-lock.json index e05c0f786c8..98ab3b1563e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@primer/octicons-react": "^13.0.0", - "@primer/primitives": "5.1.0", + "@primer/primitives": "6.1.0", "@radix-ui/react-polymorphic": "0.0.14", "@react-aria/ssr": "3.1.0", "@styled-system/css": "5.1.5", @@ -90,6 +90,8 @@ "lodash.isobject": "3.0.2", "prettier": "2.3.2", "react": "17.0.2", + "react-dnd": "14.0.4", + "react-dnd-html5-backend": "14.0.2", "react-dom": "17.0.2", "react-test-renderer": "17.0.2", "rollup": "2.56.3", @@ -7559,9 +7561,9 @@ } }, "node_modules/@primer/primitives": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@primer/primitives/-/primitives-5.1.0.tgz", - "integrity": "sha512-pW8DIh6rZV0/R7lxHnVRJ/tdN4kDVSpAtdcCecxKsvtgK5d9haekt3ERpM6i93xKwB5CJYy9ouuC9C0UqWPI0A==" + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@primer/primitives/-/primitives-6.1.0.tgz", + "integrity": "sha512-gwSVf5rVf2CMa/bU3/47LZosDHNfODMRJfKi7uJOqHWABVNl6Lf+thDM7Jb8tS9sEQQsUnrLDiGNjCScS81IXA==" }, "node_modules/@radix-ui/react-polymorphic": { "version": "0.0.14", @@ -7598,6 +7600,24 @@ "react": "^16.8.0 || ^17.0.0-rc.1" } }, + "node_modules/@react-dnd/asap": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.0.tgz", + "integrity": "sha512-0XhqJSc6pPoNnf8DhdsPHtUhRzZALVzYMTzRwV4VI6DJNJ/5xxfL9OQUwb8IH5/2x7lSf7nAZrnzUD+16VyOVQ==", + "dev": true + }, + "node_modules/@react-dnd/invariant": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-2.0.0.tgz", + "integrity": "sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw==", + "dev": true + }, + "node_modules/@react-dnd/shallowequal": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz", + "integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==", + "dev": true + }, "node_modules/@rollup/plugin-commonjs": { "version": "19.0.2", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-19.0.2.tgz", @@ -17234,6 +17254,17 @@ "integrity": "sha1-44Mx8IRLukm5qctxx3FYWqsbxlo=", "dev": true }, + "node_modules/dnd-core": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-14.0.1.tgz", + "integrity": "sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==", + "dev": true, + "dependencies": { + "@react-dnd/asap": "^4.0.0", + "@react-dnd/invariant": "^2.0.0", + "redux": "^4.1.1" + } + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -31528,6 +31559,45 @@ "node": ">= 8" } }, + "node_modules/react-dnd": { + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.4.tgz", + "integrity": "sha512-AFJJXzUIWp5WAhgvI85ESkDCawM0lhoVvfo/lrseLXwFdH3kEO3v8I2C81QPqBW2UEyJBIPStOhPMGYGFtq/bg==", + "dev": true, + "dependencies": { + "@react-dnd/invariant": "^2.0.0", + "@react-dnd/shallowequal": "^2.0.0", + "dnd-core": "14.0.1", + "fast-deep-equal": "^3.1.3", + "hoist-non-react-statics": "^3.3.2" + }, + "peerDependencies": { + "@types/hoist-non-react-statics": ">= 3.3.1", + "@types/node": ">= 12", + "@types/react": ">= 16", + "react": ">= 16.14" + }, + "peerDependenciesMeta": { + "@types/hoist-non-react-statics": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-dnd-html5-backend": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-14.0.2.tgz", + "integrity": "sha512-QgN6rYrOm4UUj6tIvN8ovImu6uP48xBXF2rzVsp6tvj6d5XQ7OjHI4SJ/ZgGobOneRAU3WCX4f8DGCYx0tuhlw==", + "dev": true, + "dependencies": { + "dnd-core": "14.0.1" + } + }, "node_modules/react-docgen": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-5.4.0.tgz", @@ -31956,6 +32026,15 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.1.2.tgz", + "integrity": "sha512-SH8PglcebESbd/shgf6mii6EIoRM0zrQyjcuQ+ojmfxjTtE0z9Y8pa62iA/OJ58qjP6j27uyW4kUF4jl/jd6sw==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, "node_modules/refractor": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/refractor/-/refractor-3.5.0.tgz", @@ -43272,9 +43351,9 @@ "requires": {} }, "@primer/primitives": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@primer/primitives/-/primitives-5.1.0.tgz", - "integrity": "sha512-pW8DIh6rZV0/R7lxHnVRJ/tdN4kDVSpAtdcCecxKsvtgK5d9haekt3ERpM6i93xKwB5CJYy9ouuC9C0UqWPI0A==" + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@primer/primitives/-/primitives-6.1.0.tgz", + "integrity": "sha512-gwSVf5rVf2CMa/bU3/47LZosDHNfODMRJfKi7uJOqHWABVNl6Lf+thDM7Jb8tS9sEQQsUnrLDiGNjCScS81IXA==" }, "@radix-ui/react-polymorphic": { "version": "0.0.14", @@ -43302,6 +43381,24 @@ "@babel/runtime": "^7.6.2" } }, + "@react-dnd/asap": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.0.tgz", + "integrity": "sha512-0XhqJSc6pPoNnf8DhdsPHtUhRzZALVzYMTzRwV4VI6DJNJ/5xxfL9OQUwb8IH5/2x7lSf7nAZrnzUD+16VyOVQ==", + "dev": true + }, + "@react-dnd/invariant": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-2.0.0.tgz", + "integrity": "sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw==", + "dev": true + }, + "@react-dnd/shallowequal": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz", + "integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==", + "dev": true + }, "@rollup/plugin-commonjs": { "version": "19.0.2", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-19.0.2.tgz", @@ -50661,6 +50758,17 @@ "integrity": "sha1-44Mx8IRLukm5qctxx3FYWqsbxlo=", "dev": true }, + "dnd-core": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-14.0.1.tgz", + "integrity": "sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==", + "dev": true, + "requires": { + "@react-dnd/asap": "^4.0.0", + "@react-dnd/invariant": "^2.0.0", + "redux": "^4.1.1" + } + }, "doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -61611,6 +61719,28 @@ } } }, + "react-dnd": { + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.4.tgz", + "integrity": "sha512-AFJJXzUIWp5WAhgvI85ESkDCawM0lhoVvfo/lrseLXwFdH3kEO3v8I2C81QPqBW2UEyJBIPStOhPMGYGFtq/bg==", + "dev": true, + "requires": { + "@react-dnd/invariant": "^2.0.0", + "@react-dnd/shallowequal": "^2.0.0", + "dnd-core": "14.0.1", + "fast-deep-equal": "^3.1.3", + "hoist-non-react-statics": "^3.3.2" + } + }, + "react-dnd-html5-backend": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-14.0.2.tgz", + "integrity": "sha512-QgN6rYrOm4UUj6tIvN8ovImu6uP48xBXF2rzVsp6tvj6d5XQ7OjHI4SJ/ZgGobOneRAU3WCX4f8DGCYx0tuhlw==", + "dev": true, + "requires": { + "dnd-core": "14.0.1" + } + }, "react-docgen": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-5.4.0.tgz", @@ -61956,6 +62086,15 @@ "strip-indent": "^3.0.0" } }, + "redux": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.1.2.tgz", + "integrity": "sha512-SH8PglcebESbd/shgf6mii6EIoRM0zrQyjcuQ+ojmfxjTtE0z9Y8pa62iA/OJ58qjP6j27uyW4kUF4jl/jd6sw==", + "dev": true, + "requires": { + "@babel/runtime": "^7.9.2" + } + }, "refractor": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/refractor/-/refractor-3.5.0.tgz", diff --git a/package.json b/package.json index bdbf226beb2..a4eee2d2c39 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "license": "MIT", "dependencies": { "@primer/octicons-react": "^13.0.0", - "@primer/primitives": "5.1.0", + "@primer/primitives": "6.1.0", "@radix-ui/react-polymorphic": "0.0.14", "@react-aria/ssr": "3.1.0", "@styled-system/css": "5.1.5", @@ -124,6 +124,8 @@ "lodash.isobject": "3.0.2", "prettier": "2.3.2", "react": "17.0.2", + "react-dnd": "14.0.4", + "react-dnd-html5-backend": "14.0.2", "react-dom": "17.0.2", "react-test-renderer": "17.0.2", "rollup": "2.56.3", diff --git a/src/ActionList2/Description.tsx b/src/ActionList2/Description.tsx new file mode 100644 index 00000000000..d50fe34dfe4 --- /dev/null +++ b/src/ActionList2/Description.tsx @@ -0,0 +1,49 @@ +import React from 'react' +import Box from '../Box' +import {SxProp, merge} from '../sx' +import Truncate from '../Truncate' +import {Slot, ItemContext} from './Item' + +export type DescriptionProps = { + /** + * Secondary text style variations. + * + * - `"inline"` - Secondary text is positioned beside primary text. + * - `"block"` - Secondary text is positioned below primary text. + */ + variant?: 'inline' | 'block' +} & SxProp + +export const Description: React.FC = ({variant = 'inline', sx = {}, ...props}) => { + const styles = { + color: 'fg.muted', + fontSize: 0, + lineHeight: '16px', + flexGrow: 1, + flexBasis: 0, + minWidth: 0, + marginLeft: variant === 'block' ? 0 : 2 + } + + return ( + + {({blockDescriptionId, inlineDescriptionId}: ItemContext) => + variant === 'block' ? ( + + {props.children} + + ) : ( + + {props.children} + + ) + } + + ) +} diff --git a/src/ActionList2/Divider.tsx b/src/ActionList2/Divider.tsx new file mode 100644 index 00000000000..d6f9444dd93 --- /dev/null +++ b/src/ActionList2/Divider.tsx @@ -0,0 +1,24 @@ +import React from 'react' +import Box from '../Box' +import {get} from '../constants' +import {Theme} from '../ThemeProvider' + +/** + * Visually separates `Item`s or `Group`s in an `ActionList`. + */ +export function Divider(): JSX.Element { + return ( + `calc(${get('space.2')(theme)} - 1px)`, + marginBottom: 2, + listStyle: 'none' // hide the ::marker inserted by browser's stylesheet + }} + data-component="ActionList.Divider" + /> + ) +} diff --git a/src/ActionList2/Group.tsx b/src/ActionList2/Group.tsx new file mode 100644 index 00000000000..30cb1ed74a2 --- /dev/null +++ b/src/ActionList2/Group.tsx @@ -0,0 +1,34 @@ +import React from 'react' +import Box from '../Box' +import {SxProp} from '../sx' +import {Header, HeaderProps} from './Header' +import {ListProps} from './List' + +export type GroupProps = HeaderProps & + SxProp & { + selectionVariant?: ListProps['selectionVariant'] | false + } + +type ContextProps = Pick +export const GroupContext = React.createContext({}) + +export const Group: React.FC = ({title, variant, auxiliaryText, selectionVariant, sx = {}, ...props}) => { + return ( + + {title && } + + + {props.children} + + + + ) +} diff --git a/src/ActionList2/Header.tsx b/src/ActionList2/Header.tsx new file mode 100644 index 00000000000..d04fce24df0 --- /dev/null +++ b/src/ActionList2/Header.tsx @@ -0,0 +1,58 @@ +import React from 'react' +import Box from '../Box' +import {SxProp} from '../sx' +import {ListContext} from './List' + +/** + * Contract for props passed to the `Header` component. + */ +export type HeaderProps = { + /** + * Style variations. Usage is discretionary. + * + * - `"filled"` - Superimposed on a background, offset from nearby content + * - `"subtle"` - Relatively less offset from nearby content + */ + variant?: 'subtle' | 'filled' + + /** + * Primary text which names a `Group`. + */ + title?: string + + /** + * Secondary text which provides additional information about a `Group`. + */ + auxiliaryText?: string +} & SxProp + +/** + * Displays the name and description of a `Group`. + */ +export const Header = ({variant = 'subtle', title, auxiliaryText, sx = {}, ...props}: HeaderProps): JSX.Element => { + const {variant: listVariant} = React.useContext(ListContext) + + const styles = { + paddingY: '6px', + paddingX: listVariant === 'full' ? 2 : 3, + fontSize: 0, + fontWeight: 'bold', + color: 'fg.muted', + ...(variant === 'filled' && { + backgroundColor: 'canvas.subtle', + marginX: 0, + marginBottom: 2, + borderTop: '1px solid', + borderBottom: '1px solid', + borderColor: 'neutral.muted' + }), + ...sx + } + + return ( + + {title} + {auxiliaryText && {auxiliaryText}} + + ) +} diff --git a/src/ActionList2/Item.tsx b/src/ActionList2/Item.tsx new file mode 100644 index 00000000000..0f5958abe74 --- /dev/null +++ b/src/ActionList2/Item.tsx @@ -0,0 +1,228 @@ +import React from 'react' +import {ForwardRefComponent as PolymorphicForwardRefComponent} from '@radix-ui/react-polymorphic' +import {useSSRSafeId} from '@react-aria/ssr' +import styled from 'styled-components' +import {useTheme} from '../ThemeProvider' +import Box, {BoxProps} from '../Box' +import sx, {SxProp, merge} from '../sx' +import createSlots from '../utils/create-slots' +import {AriaRole} from '../utils/types' +import {ListContext} from './List' +import {Selection} from './Selection' + +export const getVariantStyles = (variant: ItemProps['variant'], disabled: ItemProps['disabled']) => { + if (disabled) { + return { + color: 'fg.muted', + iconColor: 'fg.muted', + annotationColor: 'fg.muted' + } + } + + switch (variant) { + case 'danger': + return { + color: 'danger.fg', + iconColor: 'danger.fg', + annotationColor: 'fg.muted', + hoverColor: 'actionListItem.danger.hoverText' + } + default: + return { + color: 'fg.default', + iconColor: 'fg.muted', + annotationColor: 'fg.muted', + hoverColor: 'fg.default' + } + } +} + +export type ItemProps = { + /** + * Primary content for an Item + */ + children?: React.ReactNode + /** + * Callback that will trigger both on click selection and keyboard selection. + */ + onSelect?: (event: React.MouseEvent | React.KeyboardEvent) => void + /** + * Is the `Item` is currently selected? + */ + selected?: boolean + /** + * Style variations associated with various `Item` types. + * + * - `"default"` - An action `Item`. + * - `"danger"` - A destructive action `Item`. + */ + variant?: 'default' | 'danger' + /** + * Items that are disabled can not be clicked, selected, or navigated through. + */ + disabled?: boolean + /** + * The ARIA role describing the function of `Item` component. `option` is a common value. + */ + role?: AriaRole + /** + * id to attach to the root element of the Item + */ + id?: string + /** + * Private API for use internally only. Used by LinkItem to wrap contents in an anchor + */ + _PrivateItemWrapper?: React.FC +} & SxProp + +const {Slots, Slot} = createSlots(['LeadingVisual', 'InlineDescription', 'BlockDescription', 'TrailingVisual']) +export {Slot} +export type ItemContext = Pick & { + inlineDescriptionId: string + blockDescriptionId: string +} + +const LiBox = styled.li(sx) +export const TEXT_ROW_HEIGHT = '20px' // custom value off the scale + +export const Item = React.forwardRef( + ( + { + variant = 'default', + disabled = false, + selected = undefined, + onSelect = () => null, + sx: sxProp = {}, + id, + _PrivateItemWrapper = ({children}) => <>{children}>, + ...props + }, + forwardedRef + ): JSX.Element => { + const {variant: listVariant, showDividers} = React.useContext(ListContext) + + const {theme} = useTheme() + + const styles = { + display: 'flex', + paddingX: 2, + fontSize: 1, + paddingY: '6px', // custom value off the scale + lineHeight: TEXT_ROW_HEIGHT, + marginX: listVariant === 'inset' ? 2 : 0, + minHeight: 5, + borderRadius: 2, + transition: 'background 33.333ms linear', + color: getVariantStyles(variant, disabled).color, + textDecoration: 'none', // for as="a" + ':not([aria-disabled])': {cursor: 'pointer'}, + '@media (hover: hover) and (pointer: fine)': { + ':hover:not([aria-disabled])': { + backgroundColor: `actionListItem.${variant}.hoverBg`, + color: getVariantStyles(variant, disabled).hoverColor + }, + ':focus:not([aria-disabled])': { + backgroundColor: `actionListItem.${variant}.selectedBg`, + color: getVariantStyles(variant, disabled).hoverColor, + outline: 'none' + }, + ':active:not([aria-disabled])': { + backgroundColor: `actionListItem.${variant}.activeBg`, + color: getVariantStyles(variant, disabled).hoverColor + } + }, + + /** Divider styles */ + '[data-component="ActionList.Item--DividerContainer"]': { + position: 'relative' + }, + '[data-component="ActionList.Item--DividerContainer"]::before': { + content: '" "', + display: 'block', + position: 'absolute', + width: '100%', + top: '-7px', + border: '0 solid', + borderTopWidth: showDividers ? `1px` : '0', + borderColor: 'var(--divider-color, transparent)' + }, + // show between 2 items + ':not(:first-of-type):not([aria-selected=true])': {'--divider-color': theme?.colors.actionListItem.inlineDivider}, + // hide divider after dividers & group header, with higher importance! + '[data-component="ActionList.Divider"] + &': {'--divider-color': 'transparent !important'}, + // hide border on current and previous item + '&:hover:not([aria-disabled]), &:focus:not([aria-disabled])': {'--divider-color': 'transparent'}, + '&:hover:not([aria-disabled]) + &, &:focus:not([aria-disabled]) + &': {'--divider-color': 'transparent'}, + // hide border around selected item + '&[aria-selected=true] + &': {'--divider-color': 'transparent'} + } + + const clickHandler = React.useCallback( + event => { + if (disabled) return + if (!event.defaultPrevented) onSelect(event) + }, + [onSelect, disabled] + ) + + // use props.id if provided, otherwise generate one. + const labelId = useSSRSafeId(id) + const inlineDescriptionId = useSSRSafeId(id && `${id}--inline-description`) + const blockDescriptionId = useSSRSafeId(id && `${id}--block-description`) + + return ( + + {slots => ( + + <_PrivateItemWrapper> + + {slots.LeadingVisual} + + + + + {props.children} + + {slots.InlineDescription} + + {slots.TrailingVisual} + + {slots.BlockDescription} + + + + )} + + ) + } +) as PolymorphicForwardRefComponent<'li', ItemProps> + +Item.displayName = 'ActionList.Item' + +const ConditionalBox: React.FC<{if: boolean} & BoxProps> = props => { + const {if: condition, ...rest} = props + + if (condition) return {props.children} + else return <>{props.children}> +} diff --git a/src/ActionList2/LinkItem.tsx b/src/ActionList2/LinkItem.tsx new file mode 100644 index 00000000000..e2bad03244a --- /dev/null +++ b/src/ActionList2/LinkItem.tsx @@ -0,0 +1,49 @@ +import React from 'react' +import {ForwardRefComponent as PolymorphicForwardRefComponent} from '@radix-ui/react-polymorphic' +import Link from '../Link' +import {SxProp, merge} from '../sx' +import {Item, ItemProps} from './Item' + +// adopted from React.AnchorHTMLAttributes +type LinkProps = { + download?: string + href?: string + hrefLang?: string + media?: string + ping?: string + rel?: string + target?: string + type?: string + referrerPolicy?: React.AnchorHTMLAttributes['referrerPolicy'] +} + +// LinkItem does not support selected, variants, etc. +type LinkItemProps = Pick & LinkProps + +export const LinkItem = React.forwardRef(({sx = {}, as: Component, ...props}, forwardedRef) => { + const styles = { + // occupy full size of Item + paddingX: 2, + paddingY: '6px', // custom value off the scale + display: 'flex', + flexGrow: 1, // full width + borderRadius: 2, + + // inherit Item styles + color: 'inherit', + '&:hover': {color: 'inherit', textDecoration: 'none'} + } + + return ( + ( + + {children} + + )} + > + {props.children} + + ) +}) as PolymorphicForwardRefComponent<'a', LinkItemProps> diff --git a/src/ActionList2/List.tsx b/src/ActionList2/List.tsx new file mode 100644 index 00000000000..9a8e12e569a --- /dev/null +++ b/src/ActionList2/List.tsx @@ -0,0 +1,55 @@ +import React from 'react' +import {ForwardRefComponent as PolymorphicForwardRefComponent} from '@radix-ui/react-polymorphic' +import styled from 'styled-components' +import sx, {SxProp, merge} from '../sx' +import {AriaRole} from '../utils/types' + +export type ListProps = { + /** + * `inset` children are offset (vertically and horizontally) from `List`’s edges, `full` children are flush (vertically and horizontally) with `List` edges + */ + variant?: 'inset' | 'full' + /** + * Whether multiple Items or a single Item can be selected. + */ + selectionVariant?: 'single' | 'multiple' + /** + * Display a divider above each `Item` in this `List` when it does not follow a `Header` or `Divider`. + */ + showDividers?: boolean + /** + * The ARIA role describing the function of `List` component. `listbox` or `menu` are a common values. + */ + role?: AriaRole +} & SxProp + +type ContextProps = Omit +export const ListContext = React.createContext({}) + +const ListBox = styled.ul(sx) + +export const List = React.forwardRef( + ( + {variant = 'inset', selectionVariant, showDividers = false, sx: sxProp = {}, ...props}, + forwardedRef + ): JSX.Element => { + const styles = { + margin: 0, + paddingInlineStart: 0, // reset ul styles + paddingY: variant === 'inset' ? 2 : 0 + } + + return ( + + {props.children} + + ) + } +) as PolymorphicForwardRefComponent<'ul', ListProps> + +List.displayName = 'ActionList' diff --git a/src/ActionList2/Selection.tsx b/src/ActionList2/Selection.tsx new file mode 100644 index 00000000000..e9efa21a158 --- /dev/null +++ b/src/ActionList2/Selection.tsx @@ -0,0 +1,40 @@ +import React from 'react' +import {CheckIcon} from '@primer/octicons-react' +import {ListContext} from './List' +import {GroupContext} from './Group' +import {ItemProps} from './Item' +import {LeadingVisualContainer} from './Visuals' + +type SelectionProps = Pick +export const Selection: React.FC = ({selected, disabled}) => { + const {selectionVariant: listSelectionVariant} = React.useContext(ListContext) + const {selectionVariant: groupSelectionVariant} = React.useContext(GroupContext) + + /** selectionVariant in Group can override the selectionVariant in List root */ + const selectionVariant = typeof groupSelectionVariant !== 'undefined' ? groupSelectionVariant : listSelectionVariant + + // if selectionVariant is not set on List, don't show selection + if (!selectionVariant) { + // to avoid confusion, fail loudly instead of silently ignoring + if (selected) + throw new Error( + 'For Item to be selected, ActionList or ActionList.Group needs to have a selectionVariant defined' + ) + return null + } + + if (selectionVariant === 'single') { + return {selected && } + } + + /** + * selectionVariant is multiple + * readOnly is required because we are doing a one-way bind to `checked` + * aria-readonly="false" tells screen that they can still interact with the checkbox + */ + return ( + + + + ) +} diff --git a/src/ActionList2/Visuals.tsx b/src/ActionList2/Visuals.tsx new file mode 100644 index 00000000000..1e4e98bf84b --- /dev/null +++ b/src/ActionList2/Visuals.tsx @@ -0,0 +1,76 @@ +import React from 'react' +import Box from '../Box' +import {SxProp, merge} from '../sx' +import {get} from '../constants' +import {getVariantStyles, Slot, ItemContext, TEXT_ROW_HEIGHT} from './Item' + +type VisualProps = SxProp & React.HTMLAttributes + +export const LeadingVisualContainer: React.FC = ({sx = {}, ...props}) => { + return ( + + ) +} + +export type LeadingVisualProps = VisualProps +export const LeadingVisual: React.FC = ({sx = {}, ...props}) => { + return ( + + {({variant, disabled}: ItemContext) => ( + + {props.children} + + )} + + ) +} + +export type TrailingVisualProps = VisualProps +export const TrailingVisual: React.FC = ({sx = {}, ...props}) => { + return ( + + {({variant, disabled}: ItemContext) => ( + + {props.children} + + )} + + ) +} diff --git a/src/ActionList2/index.ts b/src/ActionList2/index.ts new file mode 100644 index 00000000000..d05da47652f --- /dev/null +++ b/src/ActionList2/index.ts @@ -0,0 +1,39 @@ +import {List} from './List' +import {Group} from './Group' +import {Item} from './Item' +import {LinkItem} from './LinkItem' +import {Divider} from './Divider' +import {Description} from './Description' +import {LeadingVisual, TrailingVisual} from './Visuals' + +export type {ListProps as ActionListProps} from './List' +export type {GroupProps} from './Group' +export type {ItemProps} from './Item' +export type {DescriptionProps} from './Description' +export type {LeadingVisualProps, TrailingVisualProps} from './Visuals' + +/** + * Collection of list-related components. + */ +export const ActionList = Object.assign(List, { + /** Collects related `Items` in an `ActionList`. */ + Group, + + /** An actionable or selectable `Item` */ + Item, + + /** A `Item` that renders a full-size anchor inside ListItem */ + LinkItem, + + /** Visually separates `Item`s or `Group`s in an `ActionList`. */ + Divider, + + /** Secondary text which provides additional information about an `Item`. */ + Description, + + /** Icon (or similar) positioned before `Item` text. */ + LeadingVisual, + + /** Icon (or similar) positioned after `Item` text. */ + TrailingVisual +}) diff --git a/src/__tests__/ActionList2.test.tsx b/src/__tests__/ActionList2.test.tsx new file mode 100644 index 00000000000..0a0679466b0 --- /dev/null +++ b/src/__tests__/ActionList2.test.tsx @@ -0,0 +1,47 @@ +import {cleanup, render as HTMLRender} from '@testing-library/react' +import 'babel-polyfill' +import {axe, toHaveNoViolations} from 'jest-axe' +import React from 'react' +import theme from '../theme' +import {ActionList} from '../ActionList2' +import {behavesAsComponent, checkExports} from '../utils/testing' +import {BaseStyles, ThemeProvider, SSRProvider} from '..' +expect.extend(toHaveNoViolations) + +function SimpleActionList(): JSX.Element { + return ( + + + + + New file + + Copy link + Edit file + Delete file + + + + + ) +} + +describe('ActionList', () => { + behavesAsComponent({ + Component: ActionList, + options: {skipAs: true, skipSx: true}, + toRender: () => + }) + + checkExports('ActionList2', { + default: undefined, + ActionList + }) + + it('should have no axe violations', async () => { + const {container} = HTMLRender() + const results = await axe(container) + expect(results).toHaveNoViolations() + cleanup() + }) +}) diff --git a/src/__tests__/__snapshots__/ActionList2.test.tsx.snap b/src/__tests__/__snapshots__/ActionList2.test.tsx.snap new file mode 100644 index 00000000000..5bbbad35875 --- /dev/null +++ b/src/__tests__/__snapshots__/ActionList2.test.tsx.snap @@ -0,0 +1,14 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ActionList renders consistently 1`] = ` +.c0 { + margin: 0; + padding-inline-start: 0; + padding-top: 8px; + padding-bottom: 8px; +} + + +`; diff --git a/src/__tests__/utils/__snapshots__/createSlots.test.tsx.snap b/src/__tests__/utils/__snapshots__/createSlots.test.tsx.snap new file mode 100644 index 00000000000..fe239b71ff1 --- /dev/null +++ b/src/__tests__/utils/__snapshots__/createSlots.test.tsx.snap @@ -0,0 +1,55 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ComponentWithSlots renders all slots 1`] = ` + + + first + + free form + + second + + + + +`; + +exports[`ComponentWithSlots renders with context passed to children 1`] = ` + + + + free form + + + hi + + third + + + +`; + +exports[`ComponentWithSlots renders with just one slot 1`] = ` + + + first + + free form + + + + + +`; + +exports[`ComponentWithSlots renders without any slots 1`] = ` + + + + free form + + + + + +`; diff --git a/src/__tests__/utils/createSlots.test.tsx b/src/__tests__/utils/createSlots.test.tsx new file mode 100644 index 00000000000..621bc60117b --- /dev/null +++ b/src/__tests__/utils/createSlots.test.tsx @@ -0,0 +1,74 @@ +import React from 'react' +import 'babel-polyfill' +import {render, waitFor} from '@testing-library/react' +import createSlots from '../../utils/create-slots' + +// setup a component with slots +const {Slots, Slot} = createSlots(['One', 'Two', 'Three']) +type ContextTypes = {salutation?: string} + +const ComponentWithSlots: React.FC = ({salutation, children}) => { + return ( + + {slots => ( + + {slots.One} + + {children} {slots.Two} {slots.Three} + + + )} + + ) +} +const SlotItem1: React.FC = ({children}) => {children} +const SlotItem2: React.FC = ({children}) => {children} +const SlotItem3: React.FC = ({children}) => ( + + {(context: ContextTypes) => ( + <> + {context.salutation} {children} + > + )} + +) + +describe('ComponentWithSlots', () => { + it('renders all slots', async () => { + const component = render( + + first + second + free form + + ) + + await waitFor(() => component.getByText('first')) + expect(component.container).toMatchSnapshot() + }) + + it('renders without any slots', async () => { + const component = render(free form) + expect(component.container).toMatchSnapshot() + }) + + it('renders with just one slot', async () => { + const component = render( + + first + free form + + ) + expect(component.container).toMatchSnapshot() + }) + + it('renders with context passed to children', async () => { + const component = render( + + third + free form + + ) + expect(component.container).toMatchSnapshot() + }) +}) diff --git a/src/stories/ActionList2.stories.tsx b/src/stories/ActionList2.stories.tsx new file mode 100644 index 00000000000..cb60e078c26 --- /dev/null +++ b/src/stories/ActionList2.stories.tsx @@ -0,0 +1,1279 @@ +import { + ServerIcon, + PlusCircleIcon, + TypographyIcon, + VersionsIcon, + SearchIcon, + NoteIcon, + ProjectIcon, + FilterIcon, + GearIcon, + ArrowRightIcon, + ArrowLeftIcon, + LinkIcon, + LawIcon, + StarIcon, + GitForkIcon, + AlertIcon, + TableIcon, + PeopleIcon, + CalendarIcon, + IssueOpenedIcon, + NumberIcon, + XIcon, + RepoIcon +} from '@primer/octicons-react' +import {Meta} from '@storybook/react' +import React, {forwardRef} from 'react' +import styled from 'styled-components' +import {DndProvider, useDrag, useDrop} from 'react-dnd' +import {HTML5Backend} from 'react-dnd-html5-backend' +import {Label, ThemeProvider} from '..' +import {ActionList as _ActionList, ItemProps} from '../ActionList2' +import {Header} from '../ActionList/Header' +import BaseStyles from '../BaseStyles' +import Avatar from '../Avatar' +import {ButtonInvisible} from '../Button' +import TextInput from '../TextInput' +import Spinner from '../Spinner' +import Box from '../Box' +import {AnchoredOverlay} from '../AnchoredOverlay' + +const ActionList = Object.assign(_ActionList, { + Header +}) + +const meta: Meta = { + title: 'Composite components/ActionList2', + component: ActionList, + decorators: [ + (Story: React.ComponentType): JSX.Element => ( + + + + + + ) + ], + parameters: { + controls: { + disable: true + } + } +} +export default meta + +const ErsatzOverlay = styled.div<{maxWidth?: string}>` + border-radius: 12px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 8px 24px rgba(149, 157, 165, 0.2); + overflow: hidden; + max-width: ${({maxWidth}) => maxWidth || 'none'}; +` +export function SimpleListStory(): JSX.Element { + return ( + <> + Simple List + + + Copy link + Quote reply + Edit comment + + Delete file + + + > + ) +} +SimpleListStory.storyName = 'Simple List' + +export function WithIcon(): JSX.Element { + return ( + <> + With Icon + + + + + + + github.com/primer + + + + + + MIT License + + + + + + 256 stars + + + + + + 3 forks + + + + + + 4 vulnerabilities + + + + > + ) +} +WithIcon.storyName = 'With Icon' + +const users = [ + {login: 'pksjce', name: 'Pavithra Kodmad'}, + {login: 'jfuchs', name: 'Jonathan Fuchs'}, + {login: 'colebemis', name: 'Cole Bemis'}, + {login: 'mperrotti', name: 'Mike Perrotti'}, + {login: 'dgreif', name: 'Dusty Greif'}, + {login: 'smockle', name: 'Clay Miller'}, + {login: 'siddharthkp', name: 'Siddharth Kshetrapal'} +] + +export function WithAvatar(): JSX.Element { + return ( + <> + With Avatar + + + {users.map(user => ( + + + + + {user.login} + + ))} + + + > + ) +} +WithAvatar.storyName = 'With Avatar' + +const projects = [ + {name: 'Primer Backlog', scope: 'GitHub'}, + {name: 'Accessibility', scope: 'GitHub'}, + {name: 'Octicons', scope: 'github/primer'}, + {name: 'Primer React', scope: 'github/primer'} +] + +export function WithDescription(): JSX.Element { + return ( + <> + With Description & Dividers + + + {users.map(user => ( + + + + + {user.login} + {user.name} + + ))} + + {projects.map((project, index) => ( + + + + + {project.name} + {project.scope} + + ))} + + + > + ) +} +WithDescription.storyName = 'With Description & Dividers' + +export function SingleSelectListStory(): JSX.Element { + const [selectedIndex, setSelectedIndex] = React.useState(1) + + return ( + <> + Single Select List + + + {projects.map((project, index) => ( + setSelectedIndex(index)} + > + + + + {project.name} + {project.scope} + + ))} + + + > + ) +} +SingleSelectListStory.storyName = 'Single Select' + +export function MultiSelectListStory(): JSX.Element { + const [assignees, setAssignees] = React.useState(users.slice(0, 2)) + + const toggleAssignee = assignee => { + const assigneeIndex = assignees.findIndex(a => a.login === assignee.login) + + if (assigneeIndex === -1) setAssignees([...assignees, assignee]) + else setAssignees(assignees.filter((_, index) => index !== assigneeIndex)) + } + + return ( + <> + Multi Select List + + + {users.map(user => ( + assignee.login === user.login))} + onSelect={() => toggleAssignee(user)} + > + + + + {user.login} + {user.name} + + ))} + + + > + ) +} +MultiSelectListStory.storyName = 'Multi Select' + +export function DisabledStory(): JSX.Element { + const [selectedIndex, setSelectedIndex] = React.useState(0) + + return ( + <> + Disabled Items + + + {projects.map((project, index) => ( + setSelectedIndex(index)} + disabled={index === 1} + > + + + + {project.name} + {project.scope} + + ))} + + + > + ) +} +DisabledStory.storyName = 'Disabled Items' + +export function GroupsStory(): JSX.Element { + const [assignees, setAssignees] = React.useState(users.slice(0, 1)) + + const toggleAssignee = assignee => { + const assigneeIndex = assignees.findIndex(a => a.login === assignee.login) + + if (assigneeIndex === -1) setAssignees([...assignees, assignee]) + else setAssignees(assignees.filter((_, index) => index !== assigneeIndex)) + } + + return ( + <> + Groups + + + + {users.slice(0, 2).map(user => ( + assignee.login === user.login))} + onSelect={() => toggleAssignee(user)} + > + + + + {user.login} + {user.name} + Recently edited these files + + ))} + + + {users.slice(2).map(user => ( + assignee.login === user.login))} + onSelect={() => toggleAssignee(user)} + > + + + + {user.login} + {user.name} + + ))} + + + + > + ) +} +GroupsStory.storyName = 'Groups' + +export function ActionsStory(): JSX.Element { + return ( + <> + Actions + + + + + + + Open current Codespace + + Your existing Codespace will be opened to its previous state, and you'll be asked to manually switch + to new-branch. + + + + + + + Create new Codespace + + Create a brand new Codespace with a fresh image and checkout this branch. + + + + + > + ) +} +ActionsStory.storyName = 'Actions' + +export function ComplexListInsetVariantStory(): JSX.Element { + return ( + <> + Complex List + Inset Variant + + + + + + + Rename + + + + + + Duplicate + Create a copy + + + + + + + + repo:github/memex,github/github + + + + + + + + + Table + + Information-dense table optimized for operations across teams + + + + + + + Board + + Kanban-style board focused on visual states + + + + + + + + + + Save sort and filters to current view + + + + + + Save sort and filters to new view + + + + + + + + + View settings + + + + + > + ) +} +ComplexListInsetVariantStory.storyName = 'Complex List — Inset Variant' + +export function ComplexListFullVariantStory(): JSX.Element { + return ( + <> + Complex List + Full Variant + + + + + + + Rename + + + + + + Duplicate + Create a copy + + + + + + + + repo:github/memex,github/github + + + + + + + + + Table + + Information-dense table optimized for operations across teams + + + + + + + Board + + Kanban-style board focused on visual states + + + + + + + + + + Save sort and filters to current view + + + + + + Save sort and filters to new view + + + + + + + + + View settings + + + + + > + ) +} +ComplexListFullVariantStory.storyName = 'Complex List — Full Variant' + +type ReactRouterLikeLinkProps = {to: string; children: React.ReactNode} +const ReactRouterLikeLink = forwardRef( + ({to, ...props}: {to: string; children: React.ReactNode}, ref) => { + // eslint-disable-next-line jsx-a11y/anchor-has-content + return + } +) + +const NextJSLikeLink = forwardRef( + ({href, children}: {href: string; children: React.ReactNode}, ref): React.ReactElement => { + const child = React.Children.only(children) + const childProps = { + ref, + href + } + return <>{React.isValidElement(child) ? React.cloneElement(child, childProps) : null}> + } +) + +export function LinkItemStory(): JSX.Element { + return ( + <> + List with LinkItem + + + + + + + not a link, just an Item for comparison + + + + + + ActionList.LinkItem + + + + + + ActionList.LinkItem with anchor attributes + + + + + + as ReactRouterLink + + + + + + + NextJS style Link + + + + + + + ActionList.LinkItem with everything + inline description + Block description + ⌘ + L + + + + > + ) +} +LinkItemStory.storyName = 'List with LinkItem' + +export function DOMPropsStory(): JSX.Element { + return ( + <> + Simple List + + + alert(`Id is '${event.currentTarget.getAttribute('id')}'`)}> + Has an id + + + + > + ) +} +DOMPropsStory.storyName = 'List an item input including DOM props' + +export function CustomItemChildren(): JSX.Element { + return ( + <> + Custom Item Children + + + + + + + + Choose this one + + + + + + + + > + ) +} +CustomItemChildren.storyName = 'Custom Item Children' + +export function SizeStressTestingStory(): JSX.Element { + return ( + <> + Size Stress Testing + + + + + + + Block Description. Long text should wrap + + This description is long, but it is block so it wraps + + + + + + + + + + Inline Description + This description gets truncated because it is inline + + + + + + + + + Really long text without a description should wrap so it wraps + + + + + + + > + ) +} +SizeStressTestingStory.storyName = 'Size Stress Testing' + +export function AllCombinations(): JSX.Element { + return ( + <> + All Possible Combinations + + dynamic features: L = Leading Visual, I = Inline Description, B = Block Description, T = Trailing Visual + + + 16 possible combinations + + + + + + + + + The everything bagel + inline description + Block description + + + + + none of them, only text + + + + + only L + + + only Iinline description + + + only BBlock description + + + only T + + + + + + + + + L + Iinline description + + + + + + L + BBlock description + + + + + + L + T + + + + + + I + Binline description + Block description + + + I + Tinline description + + + + + + B + TBlock description + + + + + + + + + L + I + Binline description + Block description + + + + + + L + I + Tinline description + + + + + + + + + L + B + TBlock description + + + + + + I + B + Tinline description + Block description + + + + + + + > + ) +} +AllCombinations.storyName = 'All Combinations' + +const teams = [ + {id: '5025661', type: 'team', slug: 'github/primer-reviewers', name: 'Primer Reviewers', members: 20}, + {id: '1929972', type: 'team', slug: 'github/design-infrastructure', name: 'Design Infrastructure', members: 20} +] + +export function ConditionalChildren(): JSX.Element { + type reviewerType = {name: string; id?: string; type?: string; login?: string; slug?: string; members?: number} + const potentialReviewers: reviewerType[] = [...teams, ...users] + return ( + <> + Conditional Children + + + {potentialReviewers.map((reviewer, index) => ( + + + {reviewer.type === 'team' ? ( + + ) : ( + + )} + + {reviewer.login || reviewer.slug} + {reviewer.type === 'team' ? ( + {reviewer.name} + ) : ( + {reviewer.name} + )} + {reviewer.type === 'team' && ( + + + {reviewer.members} + + )} + + ))} + + + > + ) +} +ConditionalChildren.storyName = 'Conditional Children' + +export function NestedChildren(): JSX.Element { + return ( + <> + Nested Children + + + + + {users.map(user => ( + + + + + {user.login} + + + ))} + + + + + > + ) +} +NestedChildren.storyName = 'Nested Children' + +const ReviewerDescription = ({user}) => { + const usersRecentlyEditedFile = users.slice(0, 2) + + if (usersRecentlyEditedFile.find(u => u.login === user.login)) { + return ( + + {user.name} + Recently edited this file + + ) + } else { + return {user.name} + } +} + +export function ChildWithInternalState(): JSX.Element { + return ( + <> + Child with internal state - broken + + + {users.map(user => ( + + + + + {user.login} + {user.name} + + ))} + + + > + ) +} +ChildWithInternalState.storyName = 'Child with internal state' + +const StatefulChild = props => { + const [nameVisible, setNameVisibility] = React.useState(false) + const toggle = () => { + setNameVisibility(!nameVisible) + } + + /** once description is registered, it cannot be unregistered, only updated. */ + + return ( + <> + + {nameVisible ? 'Hide name' : 'Show name'} + + {nameVisible && {props.children}} + > + ) +} + +export function ChildWithSideEffects(): JSX.Element { + const user = users[0] + const [selected, setSelected] = React.useState(true) + + return ( + <> + Child with side effects + + + setSelected(!selected)} role="option"> + + + + {user.login} + + + + + > + ) +} +ChildWithSideEffects.storyName = 'Child with side effects' + +const SideEffectDescription = () => { + const [seconds, setSeconds] = React.useState(0) + + React.useEffect(() => { + const fn = () => setSeconds(s => s + 1) + const interval = window.setInterval(fn, 1000) + return () => window.clearInterval(interval) + }, []) + + return {seconds} seconds passed +} + +export function WithSx(): JSX.Element { + return ( + <> + With sx prop + + + + + + + github.com/primer + + all items support sx prop + + + + + + + MIT License + + + + + + 4 vulnerabilities + + + + > + ) +} +WithSx.storyName = 'With sx' + +export function MemexGroupBy(): JSX.Element { + const [selectedIndex, setSelectedIndex] = React.useState(1) + + const options = [ + {text: 'Status', icon: }, + {text: 'Stage', icon: }, + {text: 'Assignee', icon: }, + {text: 'Team', icon: }, + {text: 'Estimate', icon: }, + {text: 'Due Date', icon: } + ] + + return ( + <> + Memex GroupBy List + + + + {options.map((option, index) => ( + setSelectedIndex(index)}> + {option.icon} + {option.text} + + ))} + + {typeof selectedIndex === 'number' && ( + <> + + setSelectedIndex(null)}> + + + + Clear Group by + + > + )} + + + > + ) +} +MemexGroupBy.storyName = 'Memex GroupBy List' + +type Option = {text: string; icon: React.ReactNode; selected: boolean} +export function MemexSortable(): JSX.Element { + const [options, setOptions] = React.useState([ + {text: 'Status', icon: , selected: true}, + {text: 'Stage', icon: , selected: true}, + {text: 'Assignee', icon: , selected: true}, + {text: 'Team', icon: , selected: true}, + {text: 'Estimate', icon: , selected: false}, + {text: 'Due Date', icon: , selected: false} + ]) + + const toggle = (text: string) => { + setOptions( + options.map(option => { + if (option.text === text) option.selected = !option.selected + return option + }) + ) + } + + const reorder = ({optionToMove, moveAfterOption}: {optionToMove: Option; moveAfterOption: Option}) => { + setOptions(currentOptions => { + const newOptions = [...currentOptions] + // remove option to move + const currentPosition = newOptions.findIndex(o => o.text === optionToMove.text) + newOptions.splice(currentPosition, 1) + // add it after the provided element + const newPosition = newOptions.findIndex(o => o.text === moveAfterOption.text) + 1 + newOptions.splice(newPosition, 0, optionToMove) + return newOptions + }) + } + + const visibleOptions = options.filter(option => option.selected) + const hiddenOptions = options.filter(option => !option.selected) + + return ( + <> + Memex Sortable List + + + + + {visibleOptions.map(option => ( + toggle(option.text)} + reorder={reorder} + /> + ))} + + + {hiddenOptions.map((option, index) => ( + toggle(option.text)}> + {option.icon} + {option.text} + + ))} + {hiddenOptions.length === 0 && No hidden fields} + + + + + > + ) +} +MemexSortable.storyName = 'Memex Sortable List' + +type SortableItemProps = { + option: Option + onSelect: ItemProps['onSelect'] + reorder: ({optionToMove, moveAfterOption}: {optionToMove: Option; moveAfterOption: Option}) => void +} +const SortableItem: React.FC = ({option, onSelect, reorder}) => { + const [{isDragging}, dragRef] = useDrag(() => ({ + type: 'ITEM', + item: option, + collect: monitor => { + return {isDragging: monitor.isDragging()} + } + })) + + const [{isOver}, dropRef] = useDrop(() => ({ + accept: 'ITEM', + collect: monitor => { + return {isOver: monitor.isOver()} + }, + drop: (optionDropped: Option) => { + reorder({optionToMove: optionDropped, moveAfterOption: option}) + } + })) + + return ( + dragRef(element) && dropRef(element)} // merge refs + selected={option.selected} + onSelect={onSelect} + sx={{ + opacity: isDragging ? 0.5 : 1, + boxShadow: isOver ? theme => `0px 2px 0 0px ${theme.colors.accent.emphasis}` : undefined, + borderRadius: isOver ? 0 : undefined + }} + > + {option.icon} + {option.text} + + ) +} + +const repos = [ + 'primer/primer-markdown', + 'primer/octicons', + 'primer/css', + 'primer/primer-layout', + 'primer/primer-alerts', + 'primer/primer-avatars', + 'primer/react', + 'primer/primitives' +] + +export function AsyncListStory(): JSX.Element { + const [results, setResults] = React.useState(repos.slice(0, 6)) + const [loading, setLoading] = React.useState(false) + const filter = async event => { + setLoading(true) + const filteredResults = await filterSlowly(event.target.value) + setResults(filteredResults) + setLoading(false) + } + + return ( + <> + Async List Items + + + + {loading ? ( + + + + ) : ( + results.map(name => ( + + + + + {name} + + )) + )} + + + > + ) +} +AsyncListStory.storyName = 'Async List Options' + +const filterSlowly = async query => { + // sleep for 1s before returning results + await new Promise(resolve => setTimeout(resolve, 1000)) + return await repos.filter(name => name.includes(query)) +} + +export function InsideOverlay(): JSX.Element { + const [open, setOpen] = React.useState(false) + const toggle = () => setOpen(!open) + return ( + <> + Inside Overlay + toggle overlay} + > + + + Use your arrow keys + ↓ + + + keep going + ↓ + + + more more + ↓ + + + + now go up! + ↑ + + + + > + ) +} +InsideOverlay.storyName = 'Inside Overlay' diff --git a/src/sx.ts b/src/sx.ts index f97d5f342aa..d62299f6a45 100644 --- a/src/sx.ts +++ b/src/sx.ts @@ -1,6 +1,7 @@ import css, {SystemCssProperties, SystemStyleObject} from '@styled-system/css' import {ThemeColorPaths, ThemeShadowPaths} from './theme' import {ColorProps, ShadowProps} from 'styled-system' +import merge from 'deepmerge' export type BetterCssProperties = { [K in keyof SystemCssProperties]: K extends keyof ColorProps @@ -19,3 +20,5 @@ export interface SxProp { const sx = (props: SxProp) => css(props.sx) export default sx + +export {merge} diff --git a/src/theme-preval.js b/src/theme-preval.js index 2718c0a84e2..d7e959ee9ef 100644 --- a/src/theme-preval.js +++ b/src/theme-preval.js @@ -1,5 +1,6 @@ // @preval // This file needs to be a JavaScript file using CommonJS to be compatiable with preval +// Cache bust: 2021-11-04 12:00:00 GMT (This file is cached by our deployment tooling, update this timestamp to rebuild this file) const {default: primitives} = require('@primer/primitives') const {partitionColors, fontStack, omitScale} = require('./utils/theme') diff --git a/src/unreleased.ts b/src/unreleased.ts new file mode 100644 index 00000000000..6b2effa5942 --- /dev/null +++ b/src/unreleased.ts @@ -0,0 +1,9 @@ +/** This is the place where we keep components that are not part of the public + * api yet (not in main bundle). We don't recommend using it in production. + * + * But, they are published on npm and you can import them for experimentation/feedback. + * example: import {ActionList} from '@primer/components/unreleased + */ + +// Components +export * from './ActionList2' diff --git a/src/utils/create-slots.tsx b/src/utils/create-slots.tsx new file mode 100644 index 00000000000..aee01490f18 --- /dev/null +++ b/src/utils/create-slots.tsx @@ -0,0 +1,96 @@ +import React from 'react' +import {useForceUpdate} from './use-force-update' + +/** createSlots is a factory that can create a + * typesafe Slots + Slot pair to use in a component definition + * For example: ActionList.Item uses createSlots to get a Slots wrapper + * + Slot component that is used by LeadingVisual, Description + */ +const createSlots = (slotNames: SlotNames[]) => { + type Slots = { + [key in SlotNames]?: React.ReactNode + } + + type ContextProps = { + registerSlot: (name: SlotNames, contents: React.ReactNode) => void + unregisterSlot: (name: SlotNames) => void + context: Record + } + const SlotsContext = React.createContext({ + registerSlot: () => null, + unregisterSlot: () => null, + context: {} + }) + + /** Slots uses a Double render strategy inspired by [reach-ui/descendants](https://github.com/reach/reach-ui/tree/develop/packages/descendants) + * Slot registers themself with the Slots parent. + * When all the children have mounted = registered themselves in slot, + * we re-render the parent component to render with slots + */ + const Slots: React.FC<{ + context?: ContextProps['context'] + children: (slots: Slots) => React.ReactNode + }> = ({context = {}, children}) => { + // initialise slots + const slotsDefinition: Slots = {} + slotNames.map(name => (slotsDefinition[name] = null)) + const slotsRef = React.useRef(slotsDefinition) + + const rerenderWithSlots = useForceUpdate() + const [isMounted, setIsMounted] = React.useState(false) + + // fires after all the effects in children + React.useEffect(() => { + rerenderWithSlots() + setIsMounted(true) + }, [rerenderWithSlots]) + + const registerSlot = React.useCallback( + (name: SlotNames, contents: React.ReactNode) => { + slotsRef.current[name] = contents + + // don't render until the component mounts = all slots are registered + if (isMounted) rerenderWithSlots() + }, + [isMounted, rerenderWithSlots] + ) + + // Slot can be removed from the tree as well, + // we need to unregister them from the slot + const unregisterSlot = React.useCallback( + (name: SlotNames) => { + slotsRef.current[name] = null + rerenderWithSlots() + }, + [rerenderWithSlots] + ) + + /** + * Slots uses a render prop API so abstract the + * implementation detail of using a context provider. + */ + const slots = slotsRef.current + + return ( + {children(slots)} + ) + } + + const Slot: React.FC<{ + name: SlotNames + children: React.ReactNode + }> = ({name, children}) => { + const {registerSlot, unregisterSlot, context} = React.useContext(SlotsContext) + + React.useEffect(() => { + registerSlot(name, typeof children === 'function' ? children(context) : children) + return () => unregisterSlot(name) + }, [name, children, registerSlot, unregisterSlot, context]) + + return null + } + + return {Slots, Slot} +} + +export default createSlots diff --git a/src/utils/use-force-update.ts b/src/utils/use-force-update.ts new file mode 100644 index 00000000000..b61e0ca89d3 --- /dev/null +++ b/src/utils/use-force-update.ts @@ -0,0 +1,7 @@ +// Inspired from reach-ui: https://github.com/reach/reach-ui/blob/develop/packages/utils/src/use-force-update.ts +import React from 'react' + +export const useForceUpdate = () => { + const [, rerender] = React.useState({}) + return React.useCallback(() => rerender({}), []) +}
+ dynamic features: L = Leading Visual, I = Inline Description, B = Block Description, T = Trailing Visual +
16 possible combinations