diff --git a/docs/content/Buttons.md b/docs/content/Buttons.md index 87cc8141910..46697c6b9ff 100644 --- a/docs/content/Buttons.md +++ b/docs/content/Buttons.md @@ -22,6 +22,8 @@ To create a button group, wrap `Button` elements in the `ButtonGroup` element. ` + +Button Table List ``` ## System props diff --git a/docs/content/Details.md b/docs/content/Details.md index e2bc55e480e..2d27ed4243a 100644 --- a/docs/content/Details.md +++ b/docs/content/Details.md @@ -16,6 +16,7 @@ You are responsible for rendering your own ``. To style your summary el

This should show and hide

+ ``` ## With children as a function @@ -34,6 +35,27 @@ The render function gets an object with the `open` render prop to allow you to c ``` +## Manage the open state manually +The `Details` element is built to also let you manage the open state and toggle functionality if necessary. Just provide values to the `open` and `onToggle` props. + +**Note:** The `overlay` prop will not function automatically if you chose to provide your own `open` state. You'll need to implement this yourself. You can use the `onClickOutside` prop to implement and customize this behavior. + +```jsx live + + {([open, setOpen]) => { + const handleToggle = (e) => setOpen(e.target.open) + const handleClickOutside = () => setOpen(false) + + return ( +
+ +

This should show and hide

+
+ ) + }} +
+``` + ## System props Details components get `COMMON` system props. Read our [System Props](/system-props) doc page for a full list of available props. @@ -43,4 +65,7 @@ Details components get `COMMON` system props. Read our [System Props](/system-pr | Name | Type | Default | Description | | :- | :- | :-: | :- | | defaultOpen | Boolean | | Sets the initial open/closed state | -| overlay | Boolean | false | Sets whether or not element will close when user clicks outside of it +| overlay | Boolean | false | Sets whether or not element will close when user clicks outside of it | +| open | Boolean | | Use the open prop if you'd like to manage the open state | +| onToggle | Function | | Called whenever user clicks on `summary` element. If you are controlling your own `open` state this will be the only function called on click, otherwise it's called before the internal `handleToggle` function.| +| onClickOutside | Function | | Function to call whenever user clicks outside of the Details component. This is optional and only necessary if you are controlling your own `open` state. | diff --git a/docs/content/Dropdown.md b/docs/content/Dropdown.md index 5843117f9d1..5edf5fc83b0 100644 --- a/docs/content/Dropdown.md +++ b/docs/content/Dropdown.md @@ -3,11 +3,14 @@ title: Dropdown --- The Dropdown component is a lightweight context menu for housing navigation and actions. +Use `Dropdown.Button` as the trigger for the dropdown, or use a custom `summary` element if you would like. **You must use a `summary` tag in order for the dropdown to behave properly!**. You should also add `aria-haspopup="true"` to custom dropdown triggers for accessibility purposes. You can use the `Dropdown.Caret` component to add a caret to a custom dropdown trigger. + Dropdown.Menu wraps your menu content. Be sure to pass a `direction` prop to this component to position the menu in relation to the Dropdown.Button. ## Default example ```jsx live - + + Dropdown Item 1 Item 2 @@ -16,20 +19,78 @@ Dropdown.Menu wraps your menu content. Be sure to pass a `direction` prop to thi ``` +## With custom button +```jsx live + + + Dropdown + + + + Item 1 + Item 2 + Item 3 + + +``` + +## Manage the open state manually +The `Dropdown` element is built to also let you manage the open state and toggle functionality if necessary. Just provide values to the `open` and `onToggle` props. + +**Note:** Closing the dropdown on outside clicks will not function automatically if you chose to provide your own `open` state. You'll need to implement this yourself. You can use the `onClickOutside` prop to implement and customize this behavior. + +```jsx live + + {([open, setOpen]) => { + + const handleToggle = (e) => setOpen(e.target.open) + const handleClickOutside = () => setOpen(false) + + return ( + + Dropdown + + Item 1 + Item 2 + Item 3 + + + ) + }} + +``` + ## System props -Dropdown, Dropdown.Menu, and Dropdown.Item all get `COMMON` system props. Read our [System Props](/system-props) doc page for a full list of available props. +Dropdown, Dropdown.Menu, Dropdown.Button, Dropdown.Caret, and Dropdown.Item all get `COMMON` system props. Read our [System Props](/system-props) doc page for a full list of available props. ## Component props +The Dropdown component is extended from the [`Details`](/Details) component and gets all props that the [`Details`](/Details) component gets. They are listed below, but you may reference the [`Details`](/Details) docs for more details on how to manage your own `open` state. + +#### Dropdown +| Name | Type | Default | Description | +| :- | :- | :-: | :- | +| defaultOpen | Boolean | | Sets the initial open/closed state | +| overlay | Boolean | false | Sets whether or not element will close when user clicks outside of it | +| open | Boolean | | Use the open prop if you'd like to manage the open state | +| onToggle | Function | | Called whenever user clicks on `summary` element. If you are controlling your own `open` state this will be the only function called on click, otherwise it's called before the internal `handleToggle` function.| +| onClickOutside | Function | | Function to call whenever user clicks outside of the Details component. This is optional and only necessary if you are controlling your own `open` state. | + + + #### Dropdown.Menu | Name | Type | Default | Description | | :- | :- | :-: | :- | -| direction | String | 'sw' | Sets the direction of the dropdown menu. | -| title | String or Node | | Sets the text inside of the button, can be either a string or a React node | +| direction | String | 'sw' | Sets the direction of the dropdown menu. Pick from 'ne', 'e', 'se', 's', 'sw', or 'w' | -#### Dropdown.Item +#### Dropdown.Button +| Name | Type | Default | Description | +| :- | :- | :-: | :- | +| onClick | Function | none | Use the `onClick` handler to add additional functionality when the button is clicked, such as fetching data. | + +#### Dropdown.Caret No additional props. -#### Dropdown +#### Dropdown.Item No additional props. diff --git a/docs/content/Pagination.md b/docs/content/Pagination.md new file mode 100644 index 00000000000..2dbe002064d --- /dev/null +++ b/docs/content/Pagination.md @@ -0,0 +1,194 @@ +--- +title: Pagination +--- +import State from '../components/State' + +Use the pagination component to create a connected set of links that lead to related pages (for example, previous, next, or page numbers). + +## Basic example + +The pagination component only requires two properties to render: `pageCount`, which is the total number of pages, and `currentPage`, which is the currently selected page number (which should be managed by the consuming application). + +```jsx live + e.preventDefault()} +/> +``` + +However, to handle state changes when the user clicks a page, you also need to pass `onPageChange`, which is a function that takes a click event and page number as an argument: + +```javascript +type PageChangeCallback = (evt: React.MouseEvent, page: number) => void +``` + +By default, clicking a link in the pagination component will cause the browser to navigate to the URL specified by the page. To cancel navigation and handle state management on your own, you should call `preventDefault` on the event, as in this example: + +```jsx live + + {([page, setPage]) => { + const totalPages = 15 + const onPageChange = (evt, page) => { + evt.preventDefault() + setPage(page) + } + + return ( + + Current page: {page} / {totalPages} + + + ) + }} + +``` + +## Customizing link URLs + +To customize the URL generated for each link, you can pass a function to the `hrefBuilder` property. The function should take a page number as an argument and return a URL to use for the link. + +```javascript +type HrefBuilder = (page: number) => string +``` + +```jsx live + + {([lastUrl, setLastUrl]) => { + const onPageChange = (evt, page) => { + evt.preventDefault() + setLastUrl(evt.target.href) + } + const hrefBuilder = (page) => { + return `https://example.com/pages/${page}` + } + + return ( + + The last URL clicked was: {lastUrl} + + + ) + }} + +``` + +## Customizing which pages are shown + +Two props control how many links are displayed in the pagination container at any given time. `marginPageCount` controls how many pages are guaranteed to be displayed on the left and right of the component; `surroundingPageCount` controls how many pages will be displayed to the left and right of the current page. + +```jsx live + e.preventDefault()} +/> +``` + +The algorithm tries to minimize the amount the component shrinks and grows as the user changes pages; for this reason, if any of the pages in the margin (controlled via `marginPageCount`) intersect with pages in the center (controlled by `surroundingPageCount`), the center section will be shifted away from the margin. Consider the following examples, where pages one through six are shown when any of the first four pages are selected. Only when the fifth page is selected and there is a gap between the margin pages and the center pages does a break element appear. + +```jsx live + + {[1, 2, 3, 4, 5].map(page => ( + e.preventDefault()} + /> + ))} + +``` + +### Previous/next pagination + +To hide all the page numbers and create a simple pagination container with just the Previous and Next buttons, set `showPages` to `false`. + +```jsx live + + {([page, setPage]) => { + const totalPages = 10 + const onPageChange = (evt, page) => { + evt.preventDefault() + setPage(page) + } + + return ( + + Current page: {page} / {totalPages} + + + ) + }} + +``` + +## System props + +Pagination components get `COMMON` system props. Read our [System Props](/system-props) doc page for a full list of available props. + +## Component props + +| Name | Type | Default | Description | +| :- | :- | :-: | :- | +| currentPage | Number | | **Required.** The currently selected page. | +| hrefBuilder | Function | `#${page}` | A function to generate links based on page number. | +| marginPageCount | Number | 1 | How many pages to always show at the left and right of the component. | +| onPageChange | Function | no-op | Called with event and page number when a page is clicked. | +| pageCount | Number | | **Required.** The total number of pages. | +| showPages | Boolean | `true` | Whether or not to show the individual page links. | +| surroundingPageCount | Number | 2 | How many pages to display on each side of the currently selected page. | + +## Theming + +The following snippet shows the properties in the theme that control the styling of the pagination component: + +```javascript +{ + // ... rest of theme ... + pagination: { + fontSize, + fontWeight, + borderRadius, + colors: { + normal: { + fg, + bg, + border + }, + disabled: { + fg, + bg, + border + }, + hover: { + fg, + bg, + border + }, + selected: { + fg, + bg, + border + } + } + } +} +``` diff --git a/docs/content/SelectMenu.md b/docs/content/SelectMenu.md new file mode 100644 index 00000000000..3a95eb0fe65 --- /dev/null +++ b/docs/content/SelectMenu.md @@ -0,0 +1,287 @@ +--- +title: SelectMenu +--- + +The `SelectMenu` components are a suite of components which can be combined together to make several different variations of our GitHub select menu. At it's most basic form, a select menu is comprised of a `SelectMenu` wrapper, which contains a `summary` component of your choice and a `Select.Modal` which contains the select menu content. Use `SelectMenu.List` to wrap items in the select menu, and `SelectMenu.Item` to wrap each item. + +Several additional components exist to provide even more functionality: `SelectMenu.Header`, `SelectMenu.Filter`, `SelectMenu.Tabs`, `SelectMenu.TabPanel` `SelectMenu.Footer` and `SelectMenu.Divider`. + +## Basic Example +```jsx live + + + + Projects + + Primer Components bugs + Primer Components roadmap + Project 3 + Project 4 + + + +``` + +## SelectMenu +Main wrapper component for select menu. + +```jsx + + {/* all other sub components are wrapped here*/} + +``` + +### System props + +SelectMenu components get `COMMON` system props. Read our [System Props](/system-props) doc page for a full list of available props. + +### Component Props +| Name | Type | Default | Description | +| :- | :- | :-: | :- | +| initialTab | String | | If using the `SelectMenu.Tabs` component, you can use this prop to change the tab shown on open. By default, the first tab will be used. + + +## SelectMenu.Modal +Used to wrap the content in a `SelectMenu`. + +```jsx + + {/* all menu content is wrapped in the modal*/} + +``` + +### System Props + +SelectMenu.Modal components get `COMMON` system props. Read our [System Props](/system-props) doc page for a full list of available props. + +### Component Props +SelectMenu.Modal components do not get any additional props besides system props. + + +## SelectMenu.List + +Used to wrap the select menu list content. All menu items **must** be wrapped in a SelectMenu.List in order for the accessbility keyboard handling to function properly. If you are using the `SelectMenu.TabPanel` you do not need to provide a `SelectMenu.List` as that component renders a `SelectMenu.List` as a wrapper. + +```jsx + + {/* all menu list items are wrapped in the list*/} + +``` + +### System Props + +SelectMenu.List components get `COMMON` system props. Read our [System Props](/system-props) doc page for a full list of available props. + +### Component Props +SelectMenu.List components do not get any additional props besides system props. + + +## SelectMenu.Item + +Individual items in a select menu. + +```jsx + + {/* wraps an individual list item*/} + +``` + +### System Props + +SelectMenu.Item components get `COMMON` system props. Read our [System Props](/system-props) doc page for a full list of available props. + +### Component Props +| Name | Type | Default | Description | +| :- | :- | :-: | :- | +| selected | boolean | | Used to apply styles to the selected items in the list. | +| onClick | function | | Function called when item is clicked. By default we also close the menu when items are clicked. If you would like the menu to stay open, pass an `e.preventDefault()` to your onClick handler. | + +## SelectMenu.Filter +Use a `SelectMenu.Filter` to add a filter UI to your select menu. Users are expected to implement their own filtering and manage the state of the `value` prop on the input. This gives users more flexibility over the type of filtering and type of content passed into each select menu item. + +```jsx live + + + + Filter by Project + + + Primer Components bugs + Primer Components roadmap + More Options + Project 3 + Project 4 + + + +``` + + +### System Props +SelectMenu.Filter components get `COMMON` system props. Read our [System Props](/system-props) doc page for a full list of available props. + +### Component Props +SelectMenu.Filter components receive all the props that the [TextInput](/TextInput) component gets. + +| Name | Type | Default | Description | +| :- | :- | :-: | :- | +| value | String | | Users of this component must provide a value for the filter input that is managed in the consuming application | + + +## SelectMenu.Tabs +Use `SelectMenu.Tabs` to wrap the the tab navigation and `SelectMenu.Tab` for each tab in the navigation. + +`SelectMenu.TabPanel` should wrap each corresponding panel for each of the tabs. The `tabName` prop for each `SelectMenu.TabPanel` must match the name provided in the `tabName` prop on `SelectMenu.Tab`. + +To set one of the tabs to be open by default, use `initialTab` on the main `SelectMenu` component. Otherwise, the first tab will be shown by default. + +Each `Select.Menu` tab will need to have an `index` prop. The first tab should be at index `0`, the second at index `1` and so forth. The `index` prop is used to show the first tab by default. + +If you need access to the selected tab state, you can find it in the MenuContext object exported from `SelectMenu` as `MenuContext.selectedTab`. + +```jsx live + + + + Projects + + + + + + Primer Components bugs + Primer Components roadmap + Project 3 + Project 4 + + + Project 2 + + Showing 3 of 3 + + +``` + +### System Props + +SelectMenu.Tabs components get `COMMON` system props. Read our [System Props](/system-props) doc page for a full list of available props. + +### Component Props +SelectMenu.Tabs components do not get any additional props besides system props. + +## SelectMenu.Tab +Used for each individual tab inside of a `SelectMenu.Tabs`. Be sure to set the `index` prop to correspond to the order the tab is in. The `tabName` prop should correspond to the `tabName` set on the `SelectMenu.TabPanel`. + +The `onClick` prop is optional and can be used for any events or data fetching you might need to trigger on tab clicks. + +```jsx + + +``` + +### System Props +SelectMenu.Tab components get `COMMON` system props. Read our [System Props](/system-props) doc page for a full list of available props. + +### Component Props +| Name | Type | Default | Description | +| :- | :- | :-: | :- | +| tabName | String | | Used to identify the corresponding tab. Must match the string used in the `tabs` array in the `SelectMenu.Tabs` component. | +| index | Number | | The index at which the tab is in the list of tabs | +| onClick | Function | | Function to be called when the tab is clicked. Optional. | + +## SelectMenu.TabPanel +Wraps the content for each tab. Make sure to use the `tabName` prop to identify each tab panel with the correct tab in the tab navigation. + +**Note**: SelectMenu.TabPanel wraps content in a SelectMenu.List, so adding a SelectMenu.List manually is not necessary. + +```jsx + + {/* Wraps content for each tab */} + +``` + +### System Props +SelectMenu.TabPanel components get `COMMON` system props. Read our [System Props](/system-props) doc page for a full list of available props. + +### Component Props +| Name | Type | Default | Description | +| :- | :- | :-: | :- | +| tabName | String | | Used to identify the corresponding tab. Must match the string used in the `tabs` array in the `SelectMenu.Tabs` component. + +## SelectMenu.Divider +Use a `SelectMenu.Divider` to add information between items in a `SelectMenu.List`. + +```jsx live + + + + Projects + + Primer Components bugs + Primer Components roadmap + More Options + Project 3 + Project 4 + + + +``` + +### System Props + +SelectMenu.Divder components get `COMMON` system props. Read our [System Props](/system-props) doc page for a full list of available props. + +### Component Props +SelectMenu.Divider components do not get any additional props besides system props. + +## SelectMenu.Footer +Use a `SelectMenu.Footer` to add content to the bottom of the select menu. + +```jsx live + + + + Projects + + Primer Components bugs + Primer Components roadmap + Project 3 + Project 4 + Use ⌥ + click/return to exclude labels. + + + +``` + +### System Props + +SelectMenu.Footer components get `COMMON` system props. Read our [System Props](/system-props) doc page for a full list of available props. + +### Component Props +SelectMenu.Footer components do not get any additional props besides system props. + +## SelectMenu.Header +Use a `SelectMenu.Header` to add a header to the top of the select menu content. + +```jsx live + + + + Projects + + Primer Components bugs + Primer Components roadmap + Project 3 + Project 4 + Use ⌥ + click/return to exclude labels. + + + +``` + +### System Props + +SelectMenu.Header components get `COMMON` system props. Read our [System Props](/system-props) doc page for a full list of available props. + +### Component Props +SelectMenu.Header components do not get any additional props besides system props. \ No newline at end of file diff --git a/docs/src/@primer/gatsby-theme-doctocat/nav.yml b/docs/src/@primer/gatsby-theme-doctocat/nav.yml index 7f9f8399039..29e1666c0d2 100644 --- a/docs/src/@primer/gatsby-theme-doctocat/nav.yml +++ b/docs/src/@primer/gatsby-theme-doctocat/nav.yml @@ -60,6 +60,8 @@ url: /LabelGroup - title: Link url: /Link + - title: Pagination + url: /Pagination - title: PointerBox url: /PointerBox - title: Popover @@ -68,6 +70,8 @@ url: /Position - title: ProgressBar url: /ProgressBar + - title: SelectMenu + url: /SelectMenu - title: SideNav url: /SideNav - title: StateLabel diff --git a/index.d.ts b/index.d.ts index e37386e440a..9f9cf12be19 100644 --- a/index.d.ts +++ b/index.d.ts @@ -83,6 +83,9 @@ declare module '@primer/components' { children?: DetailsRenderFunction | React.ReactNode defaultOpen?: boolean overlay?: boolean + open?: boolean + onToggle?: (event: React.SyntheticEvent) => void + onClickOutside?: (event: MouseEvent) => void } export const Details: React.FunctionComponent @@ -95,9 +98,16 @@ declare module '@primer/components' { variant?: 'small' | 'medium' | 'large' } + export interface ButtonTableListProps + extends CommonProps, + TypographyProps, + LayoutProps, + Omit, 'color'> {} + export const ButtonPrimary: React.FunctionComponent export const ButtonOutline: React.FunctionComponent export const ButtonDanger: React.FunctionComponent + export const ButtonTableList: React.FunctionComponent export const ButtonGroup: React.FunctionComponent export const Button: React.FunctionComponent @@ -147,18 +157,23 @@ declare module '@primer/components' { export const StyledOcticon: React.FunctionComponent - export interface DropdownProps extends React.Props, StyledSystem.ColorProps, StyledSystem.SpaceProps, Omit { - as?: React.ReactType - title?: string | React.ReactNode - } + export interface DropdownProps extends DetailsProps, Omit, 'color'> {} + + export interface DropdownItem extends CommonProps, Omit, 'color'> {} export interface DropdownMenuProps extends CommonProps, Omit, 'color'> { - direction?: string + direction?: 'ne'| 'e'| 'se'| 's'| 'sw'| 'w' } + export interface DropdownButtonProps extends ButtonProps, Omit {} + + export interface DropdownCaretProps extends CommonProps, Omit, 'color'> {} + export const Dropdown: React.FunctionComponent & { Menu: React.FunctionComponent Item: React.FunctionComponent + Button: React.FunctionComponent + Caret: React.FunctionComponent } export interface FilteredSearchProps extends CommonProps { @@ -217,6 +232,28 @@ declare module '@primer/components' { export const Link: React.FunctionComponent + export type PaginationHrefBuilder = (page: number) => string + + export type PaginationPageChangeCallback = (e: React.MouseEvent, page: number) => void + + export interface PaginationProps extends CommonProps { + currentPage: number + hrefBuilder?: PaginationHrefBuilder + /** + * How many pages to show on the left and right of the component + */ + marginPageCount?: number + onPageChange?: PaginationPageChangeCallback + pageCount: number + showPages?: boolean + /** + * How many pages to show directly to the left and right of the current page + */ + surroundingPageCount?: number + } + + export const Pagination: React.FunctionComponent + export interface PointerBoxProps extends CommonProps, LayoutProps, BorderBoxProps { caret?: string } @@ -258,6 +295,61 @@ declare module '@primer/components' { export const Sticky: React.FunctionComponent export const Fixed: React.FunctionComponent + export interface SelectMenuProps extends Omit, Omit, 'color'> { + initialTab?: string + } + + export interface SelectMenuModalProps extends CommonProps, Omit, 'color'> {} + + export interface SelectMenuListProps extends CommonProps, Omit, 'color'> {} + + export interface SelectMenuItemProps extends Omit, + Omit, 'color'> { + selected?: boolean + } + + export interface SelectMenuFooterProps extends CommonProps, Omit, 'color'> {} + + export interface SelectMenuDividerProps extends CommonProps, Omit, 'color'> {} + + export interface SelectMenuHeaderProps extends CommonProps, TypographyProps, Omit, 'color'> {} + + export interface SelectMenuFilterProps extends TextInputProps { + value: string + } + + export interface SelectMenuTabsProps extends CommonProps, + Omit, 'color'> {} + + export interface SelectMenuTabProps extends CommonProps, Omit, 'color'> { + index: number, + tabName: string + } + + export interface SelectMenuTabPanelProps extends CommonProps, Omit, 'color'> { + tabName: string + } + + export const SelectMenu: React.FunctionComponent & { + MenuContext: React.FunctionComponent<{ + selectedTab: string | undefined + setSelectedTab: (selectedTab: string | undefined) => void, + open: boolean | undefined, + setOpen: (open: boolean | undefined) => void, + initialTab: string | undefined + }> + Divider: React.FunctionComponent + Filter: React.FunctionComponent + Footer: React.FunctionComponent + List: React.FunctionComponent + Item: React.FunctionComponent + Modal: React.FunctionComponent + Tabs: React.FunctionComponent + Tab: React.FunctionComponent + TabPanel: React.FunctionComponent + Header: React.FunctionComponent + } + export interface SideNavProps extends CommonProps, BorderProps, Omit, 'color'> { bordered?: boolean variant?: 'normal' | 'lightweight' @@ -417,7 +509,6 @@ declare module '@primer/components' { export const ProgressBar: React.FunctionComponent } - declare module '@primer/components/src/Box' { import {Box} from '@primer/components' export default Box @@ -448,6 +539,11 @@ declare module '@primer/components/src/ButtonOutline' { export default ButtonOutline } +declare module '@primer/components/src/ButtonTableList' { + import {ButtonTableList} from '@primer/components' + export default ButtonTableList +} + declare module '@primer/components/src/ButtonGroup' { import {ButtonGroup} from '@primer/components' export default ButtonGroup @@ -531,6 +627,10 @@ declare module '@primer/components/src/Link' { import {Link} from '@primer/components' export default Link } +declare module '@primer/components/src/Pagination' { + import {Pagination} from '@primer/components' + export default Pagination +} declare module '@primer/components/src/PointerBox' { import {PointerBox} from '@primer/components' export default PointerBox @@ -555,10 +655,17 @@ declare module '@primer/components/src/Fixed' { import {Fixed} from '@primer/components' export default Fixed } + +declare module '@primer/components/src/SelectMenu' { + import {SelectMenu} from '@primer/components' + export default SelectMenu +} + declare module '@primer/components/src/StateLabel' { import {StateLabel} from '@primer/components' export default StateLabel } + declare module '@primer/components/src/TabNav' { import {TabNav} from '@primer/components' export default TabNav diff --git a/package.json b/package.json index eb85db2d2cd..5531ae268d2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@primer/components", - "version": "16.3.0", + "version": "17.0.0", "description": "Primer react components", "main": "dist/index.umd.js", "module": "dist/index.esm.js", @@ -41,6 +41,7 @@ "@styled-system/prop-types": "5.1.2", "@styled-system/props": "5.1.4", "@styled-system/theme-get": "5.1.2", + "@types/styled-components": "^4.4.0", "@testing-library/react": "9.4.0", "@types/styled-system": "5.1.2", "babel-plugin-macros": "2.6.1", diff --git a/src/ButtonTableList.js b/src/ButtonTableList.js new file mode 100644 index 00000000000..9a767522875 --- /dev/null +++ b/src/ButtonTableList.js @@ -0,0 +1,49 @@ +import styled from 'styled-components' +import {COMMON, LAYOUT, TYPOGRAPHY, get} from './constants' +import theme from './theme' + +const ButtonTableList = styled.summary` + display: inline-block; + padding: 0; + font-size: ${get('fontSizes.1')}; + color: ${get('colors.gray.6')}; + text-decoration: none; + white-space: nowrap; + cursor: pointer; + user-select: none; + background-color: transparent; + border: 0; + appearance: none; // Corrects inability to style clickable input types in iOS. + + &:hover { + text-decoration: underline; + } + + &:disabled { + &, + &:hover { + color: rgba(${get('colors.gray.6')}, 0.5); + cursor: default; + } + } + + &:after { + display: inline-block; + margin-left: ${get('space.1')}; + width: 0; + height: 0; + vertical-align: -2px; + content: ""; + border: 4px solid transparent; + border-top-color: currentcolor; + } + ${COMMON} + ${TYPOGRAPHY} + ${LAYOUT} +` + +ButtonTableList.defaultProps = { + theme +} + +export default ButtonTableList diff --git a/src/Details.js b/src/Details.js index e6d9e9d5119..0c097a48dbe 100644 --- a/src/Details.js +++ b/src/Details.js @@ -12,55 +12,72 @@ if (typeof window !== 'undefined') { require('details-element-polyfill') } -const DetailsReset = styled('details')` +const StyledDetails = styled('details')` & > summary { list-style: none; } & > summary::-webkit-details-marker { display: none; } + + ${COMMON} ` function getRenderer(children) { return typeof children === 'function' ? children : () => children } -function DetailsBase({children, overlay, render = getRenderer(children), defaultOpen = false, ...rest}) { - const [open, setOpen] = useState(defaultOpen) +function Details({ + children, + overlay, + render = getRenderer(children), + open: userOpen, + onClickOutside, + onToggle, + defaultOpen = false, + ...rest +}) { + const [internalOpen, setInternalOpen] = useState(defaultOpen) const ref = useRef(null) + // only use internal open state if user doesn't provide a value for the open prop + const open = typeof userOpen !== 'undefined' ? userOpen : internalOpen - const closeMenu = useCallback( + const onClickOutsideInternal = useCallback( event => { - // only close the menu if we're clicking outside - if (event && event.target.closest('details') !== ref.current) { - setOpen(false) - document.removeEventListener('click', closeMenu) + if (event.target.closest('details') !== ref.current) { + onClickOutside && onClickOutside(event) + if (!event.defaultPrevented) { + setInternalOpen(false) + } } }, - [ref] + [ref, onClickOutside, setInternalOpen] ) + // handles the overlay behavior - closing the menu when clicking outside of it useEffect(() => { - if (overlay && open) { - document.addEventListener('click', closeMenu) + if (open && overlay) { + document.addEventListener('click', onClickOutsideInternal) return () => { - document.removeEventListener('click', closeMenu) + document.removeEventListener('click', onClickOutsideInternal) } } - }, [open, overlay, closeMenu]) + }, [open, overlay, onClickOutsideInternal]) + + function handleToggle(e) { + onToggle && onToggle(e) - function toggle(event) { - setOpen(event.target.open) + if (!e.defaultPrevented) { + setInternalOpen(e.target.open) + } } return ( - + {render({open})} - + ) } -const Details = styled(DetailsBase)(COMMON) - Details.defaultProps = { theme, overlay: false diff --git a/src/Dropdown.js b/src/Dropdown.js index 811268fa2db..6363b62d886 100644 --- a/src/Dropdown.js +++ b/src/Dropdown.js @@ -7,26 +7,29 @@ import {COMMON, get} from './constants' import getDirectionStyles from './DropdownStyles' import theme from './theme' -const DropdownBase = ({title, children, className, ...rest}) => { - return ( -
- <> - - {children} - -
- ) -} -const Dropdown = styled(DropdownBase)` +const StyledDetails = styled(Details)` position: relative; display: inline-block; - ${COMMON}; ` -const DropdownCaret = styled.div` +const Dropdown = ({children, className, ...rest}) => { + return ( + + {children} + + ) +} + +Dropdown.Button = ({children, ...rest}) => { + return ( + + ) +} + +Dropdown.Caret = styled.div` border: 4px solid transparent; margin-left: 12px; border-top-color: currentcolor; @@ -36,9 +39,10 @@ const DropdownCaret = styled.div` height: 0; vertical-align: middle; width: 0; + ${COMMON} ` -const DropdownMenu = styled.ul` +Dropdown.Menu = styled.ul` background-clip: padding-box; background-color: ${get('colors.white')}; border: 1px solid rgba(27, 31, 35, 0.15); @@ -83,7 +87,7 @@ const DropdownMenu = styled.ul` ${COMMON}; ` -const DropdownItem = styled.li` +Dropdown.Item = styled.li` display: block; padding: ${get('space.1')} 10px ${get('space.1')} 15px; overflow: hidden; @@ -118,9 +122,6 @@ const DropdownItem = styled.li` ${COMMON}; ` -Dropdown.Menu = DropdownMenu -Dropdown.Item = DropdownItem - Dropdown.Menu.propTypes = { direction: PropTypes.oneOf(['ne', 'e', 'se', 's', 'sw', 'w']), ...COMMON.propTypes @@ -136,10 +137,16 @@ Dropdown.Item.propTypes = { ...COMMON.propTypes } +Dropdown.Button.defaultProps = {theme} + +Dropdown.Caret.defaultProps = {theme} +Dropdown.Caret.propTpyes = { + ...COMMON.propTypes +} + Dropdown.defaultProps = {theme} Dropdown.propTypes = { - children: PropTypes.node, - title: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + ...Details.propTypes, ...COMMON.propTypes } diff --git a/src/Pagination/Pagination.js b/src/Pagination/Pagination.js new file mode 100644 index 00000000000..ff9b56a5b7f --- /dev/null +++ b/src/Pagination/Pagination.js @@ -0,0 +1,163 @@ +import React from 'react' +import PropTypes from 'prop-types' +import styled, {css} from 'styled-components' +import {get, COMMON} from '../constants' +import theme from '../theme' +import Box from '../Box' +import {buildPaginationModel, buildComponentData} from './model' + +const Page = styled.a` + position: relative; + float: left; + padding: 7px 12px; + margin-left: -1px; + font-size: ${get('pagination.fontSize')}; + font-style: normal; + font-weight: ${get('pagination.fontWeight')}; + color: ${get('pagination.colors.normal.fg')}; + white-space: nowrap; + vertical-align: middle; + cursor: pointer; + user-select: none; + background: ${get('pagination.colors.normal.bg')}; + border: ${get('borders.1')} ${get('pagination.colors.normal.border')}; + text-decoration: none; + + &:first-child { + margin-left: 0; + border-top-left-radius: ${get('pagination.borderRadius')}; + border-bottom-left-radius: ${get('pagination.borderRadius')}; + } + + &:last-child { + border-top-right-radius: ${get('pagination.borderRadius')}; + border-bottom-right-radius: ${get('pagination.borderRadius')}; + } + + ${props => + !props.selected && + !props.disabled && + css` + &:hover, + &:focus { + z-index: 2; + color: ${get('pagination.colors.hover.fg')}; + background-color: ${get('pagination.colors.hover.bg')}; + border-color: ${get('pagination.colors.hover.border')}; + } + `} + + ${props => + props.selected && + css` + z-index: 3; + color: ${get('pagination.colors.selected.fg')}; + background-color: ${get('pagination.colors.selected.bg')}; + border-color: ${get('pagination.colors.selected.border')}; + `} + + ${props => + props.disabled && + css` + color: ${get('pagination.colors.disabled.fg')}; + cursor: default; + background-color: ${get('pagination.colors.disabled.bg')}; + border-color: ${get('pagination.colors.disabled.border')}; + `} + + ${COMMON}; +` + +function usePaginationPages({ + theme, + pageCount, + currentPage, + onPageChange, + hrefBuilder, + marginPageCount, + showPages, + surroundingPageCount +}) { + const pageChange = React.useCallback(n => e => onPageChange(e, n), [onPageChange]) + + const model = React.useMemo(() => { + return buildPaginationModel(pageCount, currentPage, showPages, marginPageCount, surroundingPageCount) + }, [pageCount, currentPage, showPages, marginPageCount, surroundingPageCount]) + + const children = React.useMemo(() => { + return model.map(page => { + const {props, key, content} = buildComponentData(page, hrefBuilder, pageChange(page.num)) + return ( + + {content} + + ) + }) + }, [model, hrefBuilder, pageChange]) + + return children +} + +const PaginationContainer = styled.nav` + margin-top: 20px; + margin-bottom: 15px; + text-align: center; +` + +function Pagination({ + theme, + pageCount, + currentPage, + onPageChange, + hrefBuilder, + marginPageCount, + showPages, + surroundingPageCount, + ...rest +}) { + const pageElements = usePaginationPages({ + theme, + pageCount, + currentPage, + onPageChange, + hrefBuilder, + marginPageCount, + showPages, + surroundingPageCount + }) + return ( + + + {pageElements} + + + ) +} + +function defaultHrefBuilder(pageNum) { + return `#${pageNum}` +} + +function noop() {} + +Pagination.propTypes = { + currentPage: PropTypes.number.isRequired, + hrefBuilder: PropTypes.func, + marginPageCount: PropTypes.number, + onPageChange: PropTypes.func, + pageCount: PropTypes.number.isRequired, + showPages: PropTypes.bool, + surroundingPageCount: PropTypes.number, + ...COMMON.propTypes +} + +Pagination.defaultProps = { + hrefBuilder: defaultHrefBuilder, + marginPageCount: 1, + onPageChange: noop, + showPages: true, + surroundingPageCount: 2, + theme +} + +export default Pagination diff --git a/src/Pagination/index.js b/src/Pagination/index.js new file mode 100644 index 00000000000..1466412190c --- /dev/null +++ b/src/Pagination/index.js @@ -0,0 +1,3 @@ +import Pagination from './Pagination' + +export default Pagination diff --git a/src/Pagination/model.js b/src/Pagination/model.js new file mode 100644 index 00000000000..9a9dda8a166 --- /dev/null +++ b/src/Pagination/model.js @@ -0,0 +1,170 @@ +export function buildPaginationModel(pageCount, currentPage, showPages, marginPageCount, surroundingPageCount) { + const pages = [] + + if (showPages) { + const pageNums = [] + const addPage = n => { + if (n >= 1 && n <= pageCount) { + pageNums.push(n) + } + } + + // Start by defining the window of pages to show around the current page. + // If the window goes off either edge, shift it until it fits. + let extentLeft = currentPage - surroundingPageCount + let extentRight = currentPage + surroundingPageCount + if (extentLeft < 1 && extentRight > pageCount) { + // Our window is larger than the entire range, + // so simply display every page. + extentLeft = 1 + extentRight = pageCount + } else if (extentLeft < 1) { + while (extentLeft < 1) { + extentLeft++ + extentRight++ + } + } else if (extentRight > pageCount) { + while (extentRight > pageCount) { + extentLeft-- + extentRight-- + } + } + + // Next, include the pages in the margins. + // If a margin page is already covered in the window, + // extend the window to the other direction. + for (let i = 1; i <= marginPageCount; i++) { + const leftPage = i + const rightPage = pageCount - (i - 1) + if (leftPage >= extentLeft) { + extentRight++ + } else { + addPage(leftPage) + } + if (rightPage <= extentRight) { + extentLeft-- + } else { + addPage(rightPage) + } + } + + for (let i = extentLeft; i <= extentRight; i++) { + addPage(i) + } + + const sorted = pageNums + .slice() + .sort((a, b) => a - b) + .filter((item, idx, ary) => !idx || item !== ary[idx - 1]) + for (let idx = 0; idx < sorted.length; idx++) { + const num = sorted[idx] + const selected = num === currentPage + if (idx === 0) { + if (num !== 1) { + // If the first page isn't page one, + // we need to add a break + pages.push({ + type: 'BREAK', + num: 1 + }) + } + pages.push({ + type: 'NUM', + num, + selected + }) + } else { + const last = sorted[idx - 1] + const delta = num - last + if (delta === 1) { + pages.push({ + type: 'NUM', + num, + selected + }) + } else { + // We skipped some, so add a break + pages.push({ + type: 'BREAK', + num: num - 1 + }) + pages.push({ + type: 'NUM', + num, + selected + }) + } + } + } + + const lastPage = pages[pages.length - 1] + if (lastPage.type === 'NUM' && lastPage.num !== pageCount) { + // The last page we rendered wasn't the actual last page, + // so we need an additional break + pages.push({ + type: 'BREAK', + num: pageCount + }) + } + } + + const prev = {type: 'PREV', num: currentPage - 1, disabled: currentPage === 1} + const next = {type: 'NEXT', num: currentPage + 1, disabled: currentPage === pageCount} + return [prev, ...pages, next] +} + +export function buildComponentData(page, hrefBuilder, onClick) { + const props = {} + let content = '' + let key = '' + + switch (page.type) { + case 'PREV': { + key = 'page-prev' + content = 'Previous' + if (page.disabled) { + Object.assign(props, {as: 'span', 'aria-disabled': 'true', disabled: true}) + } else { + Object.assign(props, { + rel: 'prev', + href: hrefBuilder(page.num), + 'aria-label': 'Previous Page', + onClick + }) + } + break + } + case 'NEXT': { + key = 'page-next' + content = 'Next' + if (page.disabled) { + Object.assign(props, {as: 'span', 'aria-disabled': 'true', disabled: true}) + } else { + Object.assign(props, { + rel: 'next', + href: hrefBuilder(page.num), + 'aria-label': 'Next Page', + onClick + }) + } + break + } + case 'NUM': { + key = `page-${page.num}` + content = page.num + if (page.selected) { + Object.assign(props, {as: 'em', 'aria-current': 'page', selected: true}) + } else { + Object.assign(props, {href: hrefBuilder(page.num), 'aria-label': `Page ${page.num}`, onClick}) + } + break + } + case 'BREAK': { + key = `page-${page.num}-break` + content = '…' + Object.assign(props, {as: 'span', disabled: true}) + } + } + + return {props, key, content} +} diff --git a/src/SelectMenu/SelectMenu.js b/src/SelectMenu/SelectMenu.js new file mode 100644 index 00000000000..e6969f11570 --- /dev/null +++ b/src/SelectMenu/SelectMenu.js @@ -0,0 +1,101 @@ +import React, {useRef, useState} from 'react' +import styled from 'styled-components' +import PropTypes from 'prop-types' +import {COMMON} from '../constants' +import theme from '../theme' +import {MenuContext} from './SelectMenuContext' +import SelectMenuDivider from './SelectMenuDivider' +import SelectMenuFilter from './SelectMenuFilter' +import SelectMenuFooter from './SelectMenuFooter' +import SelectMenuItem from './SelectMenuItem' +import SelectMenuList from './SelectMenuList' +import SelectMenuModal from './SelectMenuModal' +import SelectMenuTabs from './SelectMenuTabs' +import SelectMenuHeader from './SelectMenuHeader' +import SelectMenuTab from './SelectMenuTab' +import SelectMenuTabPanel from './SelectMenuTabPanel' +import useKeyboardNav from './hooks/useKeyboardNav' + +const wrapperStyles = ` + &[open] > summary::before { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 80; + display: block; + cursor: default; + content: ' '; + background: transparent; + } + // Remove marker added by the display: list-item browser default + > summary { + list-style: none; + } + // Remove marker added by details polyfill + > summary::before { + display: none; + } + // Remove marker added by Chrome + > summary::-webkit-details-marker { + display: none; + } +` + +const StyledSelectMenu = styled.details` + ${wrapperStyles} + ${COMMON} +` + +// 'as' is spread out because we don't want users to be able to change the tag. +const SelectMenu = ({children, initialTab, as, ...rest}) => { + const ref = useRef(null) + const [selectedTab, setSelectedTab] = useState(initialTab) + const [open, setOpen] = useState(false) + const menuProviderValues = { + selectedTab, + setSelectedTab, + setOpen, + open, + initialTab + } + + function toggle(event) { + setOpen(event.target.open) + } + + useKeyboardNav(ref, open, setOpen) + + return ( + + + {children} + + + ) +} + +SelectMenu.MenuContext = MenuContext +SelectMenu.List = SelectMenuList +SelectMenu.Divider = SelectMenuDivider +SelectMenu.Filter = SelectMenuFilter +SelectMenu.Footer = SelectMenuFooter +SelectMenu.Item = SelectMenuItem +SelectMenu.List = SelectMenuList +SelectMenu.Modal = SelectMenuModal +SelectMenu.Tabs = SelectMenuTabs +SelectMenu.Tab = SelectMenuTab +SelectMenu.TabPanel = SelectMenuTabPanel +SelectMenu.Header = SelectMenuHeader + +SelectMenu.defaultProps = { + theme +} + +SelectMenu.propTypes = { + initialTab: PropTypes.string, + ...COMMON.propTypes +} + +export default SelectMenu diff --git a/src/SelectMenu/SelectMenuContext.js b/src/SelectMenu/SelectMenuContext.js new file mode 100644 index 00000000000..a63f91b96fc --- /dev/null +++ b/src/SelectMenu/SelectMenuContext.js @@ -0,0 +1,3 @@ +import {createContext} from 'react' + +export const MenuContext = createContext() diff --git a/src/SelectMenu/SelectMenuDivider.js b/src/SelectMenu/SelectMenuDivider.js new file mode 100644 index 00000000000..7ecebf1c9ea --- /dev/null +++ b/src/SelectMenu/SelectMenuDivider.js @@ -0,0 +1,28 @@ +import styled, {css} from 'styled-components' +import theme from '../theme' +import {COMMON, get} from '../constants' + +const dividerStyles = css` + padding: ${get('space.1')} ${get('space.3')}; + margin: 0; + font-size: ${get('fontSizes.0')}; + font-weight: ${get('fontWeights.bold')}; + color: ${get('colors.text.grayLight')}; + background-color: ${get('colors.bg.gray')}; + border-bottom: ${get('borders.1')} ${get('colors.border.grayLight')}; +` + +const SelectMenuDivider = styled.div` + ${dividerStyles} + ${COMMON} +` + +SelectMenuDivider.defaultProps = { + theme +} + +SelectMenuDivider.propTypes = { + ...COMMON.propTypes +} + +export default SelectMenuDivider diff --git a/src/SelectMenu/SelectMenuFilter.js b/src/SelectMenu/SelectMenuFilter.js new file mode 100644 index 00000000000..085868b9127 --- /dev/null +++ b/src/SelectMenu/SelectMenuFilter.js @@ -0,0 +1,47 @@ +import React, {useRef, useContext, useEffect} from 'react' +import styled from 'styled-components' +import PropTypes from 'prop-types' +import {COMMON, get} from '../constants' +import theme from '../theme' +import TextInput from '../TextInput' +import {MenuContext} from './SelectMenuContext' + +const StyledForm = styled.form` + padding: ${get('space.3')}; + margin: 0; + border-top: ${get('borders.1')} ${get('colors.border.gray')}; + background-color: ${get('colors.white')}; + ${COMMON}; + + @media (min-width: ${get('breakpoints.0')}) { + padding: ${get('space.2')}; + } +` + +function SelectMenuFilter({theme, value, ...rest}) { + const inputRef = useRef(null) + const {open} = useContext(MenuContext) + + // puts focus on the filter input when the menu is opened + useEffect(() => { + if (open) { + inputRef.current.focus() + } + }, [open]) + return ( + + + + ) +} + +SelectMenuFilter.defaultProps = { + theme +} + +SelectMenuFilter.propTypes = { + ...COMMON.propTypes, + value: PropTypes.string +} + +export default SelectMenuFilter diff --git a/src/SelectMenu/SelectMenuFooter.js b/src/SelectMenu/SelectMenuFooter.js new file mode 100644 index 00000000000..4e107684879 --- /dev/null +++ b/src/SelectMenu/SelectMenuFooter.js @@ -0,0 +1,31 @@ +import styled, {css} from 'styled-components' +import {COMMON, get} from '../constants' +import theme from '../theme' + +const footerStyles = css` + margin-top: -1px; + padding: ${get('space.2')} ${get('space.3')}; + font-size: ${get('fontSizes.0')}; + color: ${get('colors.text.grayLight')}; + text-align: center; + border-top: ${get('borders.1')} ${get('colors.border.gray')}; + + @media (min-width: ${get('breakpoints.0')}) { + padding: ${get('space.1')} ${get('space.2')}; + } +` + +const SelectMenuFooter = styled.footer` + ${footerStyles} + ${COMMON} +` + +SelectMenuFooter.defaultProps = { + theme +} + +SelectMenuFooter.propTypes = { + ...COMMON.propTypes +} + +export default SelectMenuFooter diff --git a/src/SelectMenu/SelectMenuHeader.js b/src/SelectMenu/SelectMenuHeader.js new file mode 100644 index 00000000000..35b42838526 --- /dev/null +++ b/src/SelectMenu/SelectMenuHeader.js @@ -0,0 +1,44 @@ +import React from 'react' +import styled from 'styled-components' +import {get, COMMON, TYPOGRAPHY} from '../constants' +import theme from '../theme' + +// SelectMenu.Header is intentionally not exported, it's an internal component used in +// SelectMenu.Modal + +const SelectMenuTitle = styled.h3` + flex: auto; + font-size: ${get('fontSizes.1')}; + font-weight: ${get('fontWeights.bold')}; + margin: 0; + + @media (min-width: ${get('breakpoints.0')}) { + font-size: inherit; + } +` + +const StyledHeader = styled.header` + display: flex; + flex: none; // fixes header from getting squeezed in Safari iOS + padding: ${get('space.3')}; + ${COMMON} + ${TYPOGRAPHY} + + @media (min-width: ${get('breakpoints.0')}) { + padding-top: ${get('space.2')}; + padding-bottom: ${get('space.2')}; + } +` +const SelectMenuHeader = ({children, theme, ...rest}) => { + return ( + + {children} + + ) +} + +SelectMenuHeader.defaultProps = { + theme +} + +export default SelectMenuHeader diff --git a/src/SelectMenu/SelectMenuItem.js b/src/SelectMenu/SelectMenuItem.js new file mode 100644 index 00000000000..cbd293bae71 --- /dev/null +++ b/src/SelectMenu/SelectMenuItem.js @@ -0,0 +1,130 @@ +import React, {useContext} from 'react' +import PropTypes from 'prop-types' +import styled, {css} from 'styled-components' +import {Check} from '@primer/octicons-react' +import {MenuContext} from './SelectMenuContext' +import {COMMON, get} from '../constants' +import StyledOcticon from '../StyledOcticon' +import theme from '../theme' + +export const listItemStyles = css` + display: flex; + align-items: center; + padding: ${get('space.3')}; + overflow: hidden; + text-align: left; + cursor: pointer; + background-color: ${get('colors.white')}; + border: 0; + border-bottom: ${get('borders.1')} ${get('colors.border.grayLight')}; + color: ${get('colors.text.gray')}; + text-decoration: none; + + &:hover { + text-decoration: none; + } + &:focus { + outline: none; + } + + &[hidden] { + display: none !important; + } + + @media (min-width: ${get('breakpoints.0')}) { + padding-top: ${get('space.2')}; + padding-bottom: ${get('space.2')}; + } + + .SelectMenu-icon { + width: ${get('space.3')}; + margin-right: ${get('space.2')}; + flex-shrink: 0; + } + + .SelectMenu-selected-icon { + visibility: hidden; + transition: transform 0.12s cubic-bezier(0.5, 0.1, 1, 0.5), visibility 0s 0.12s linear; + transform: scale(0); + } + + // selected items + &[aria-checked='true'] { + font-weight: 500; + color: ${get('colors.gray.9')}; + + .SelectMenu-selected-icon { + visibility: visible; + transition: transform 0.12s cubic-bezier(0, 0, 0.2, 1), visibility 0s linear; + transform: scale(1); + } + } + + // can hover states + @media (hover: hover) { + body:not(.intent-mouse) .SelectMenu-item:focus, + &:hover, + &:active, + &:focus { + background-color: ${get('colors.bg.gray')}; + } + } + + // Can not hover states + // + // For touch input + + @media (hover: none) { + // Android + &:focus, + &:active { + background-color: ${get('colors.bg.grayLight')}; + } + + // iOS Safari + // :active would work if ontouchstart is added to the button + // Instead this tweaks the "native" highlight color + -webkit-tap-highlight-color: rgba(${get('colors.gray.3')}, 0.5); + } +` + +const StyledItem = styled.a.attrs(() => ({ + role: 'menuitemcheckbox' +}))` + ${listItemStyles} + ${COMMON} +` + +// 'as' is spread out because we don't want users to be able to change the tag. using something +// other than 'a' will break a11y. +const SelectMenuItem = ({children, selected, theme, onClick, as, ...rest}) => { + const menuContext = useContext(MenuContext) + + // close the menu when an item is clicked + // this can be overriden if the user provides a `onClick` prop and prevents default in it + const handleClick = e => { + onClick && onClick(e) + + if (!e.defaultPrevented) { + menuContext.setOpen(false) + } + } + return ( + + + {children} + + ) +} + +SelectMenuItem.defaultProps = { + theme, + selected: false +} + +SelectMenuItem.propTypes = { + selected: PropTypes.bool, + ...COMMON.propTypes +} + +export default SelectMenuItem diff --git a/src/SelectMenu/SelectMenuList.js b/src/SelectMenu/SelectMenuList.js new file mode 100644 index 00000000000..5e57b049e26 --- /dev/null +++ b/src/SelectMenu/SelectMenuList.js @@ -0,0 +1,45 @@ +import styled, {css} from 'styled-components' +import theme from '../theme' +import {COMMON, get} from '../constants' + +const listStyles = css` + position: relative; + padding: 0; + margin: 0; + flex: auto; + overflow-x: hidden; + overflow-y: auto; + background-color: ${get('colors.white')}; + border-top: ${get('borders.1')} ${get('colors.border.gray')}; + -webkit-overflow-scrolling: touch; // Adds momentum + bouncy scrolling + + @media (hover: hover) { + .SelectMenuTab:focus { + background-color: ${get('colors.blue.1')}; + } + + .SelectMenuTab:not([aria-checked='true']):hover { + color: ${get('colors.gray.9')}; + background-color: ${get('colors.gray.2')}; + } + + .SelectMenuTab:not([aria-checked='true']):active { + color: ${get('colors.gray.9')}; + background-color: ${get('colors.gray.1')}; + } + } +` + +const SelectMenuList = styled.div` + ${listStyles} + ${COMMON} +` +SelectMenuList.defaultProps = { + theme +} + +SelectMenuList.propTypes = { + ...COMMON.propTypes +} + +export default SelectMenuList diff --git a/src/SelectMenu/SelectMenuModal.js b/src/SelectMenu/SelectMenuModal.js new file mode 100644 index 00000000000..fc172c2cecf --- /dev/null +++ b/src/SelectMenu/SelectMenuModal.js @@ -0,0 +1,103 @@ +import React from 'react' +import styled, {keyframes, css} from 'styled-components' +import {COMMON, get} from '../constants' +import theme from '../theme' + +const animateModal = keyframes` + 0% { + opacity: 0; + transform: scale(0.9); + } +` + +const modalStyles = css` + position: relative; + z-index: 99; // Needs to be higher than .details-overlay's z-index: 80. + display: flex; + ${props => (props.filter ? 'height: 80%' : '')}; + max-height: ${props => (props.filter ? 'none' : '66%')}; + margin: auto 0; + ${props => (props.filter ? 'margin-top: 0' : '')}; + overflow: hidden; // Enables border radius on scrollable child elements + pointer-events: auto; + flex-direction: column; + background-color: ${get('colors.white')}; + border-radius: ${get('radii.2')}; + box-shadow: 0 1px 5px rgba(27, 31, 35, 0.15); + animation: ${animateModal} 0.12s cubic-bezier(0, 0.1, 0.1, 1) backwards; + + @media (min-width: ${get('breakpoints.0')}) { + width: '300px'; + height: auto; + max-height: 350px; + margin: ${get('space.1')} 0 ${get('space.3')} 0; + font-size: ${get('fontSizes.0')}; + border: ${get('borders.1')} ${get('colors.border.grayDark')}; + border-radius: ${get('radii.2')}; + box-shadow: 0 1px 5px ${get('colors.blackfade15')} !default; + } +` + +const modalWrapperStyles = css` + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 99; + display: flex; + padding: ${get('space.3')}; + pointer-events: none; + flex-direction: column; + + &::before { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + pointer-events: none; + content: ''; + background-color: ${get('colors.blackfade50')}; + + @media (min-width: ${get('breakpoints.0')}) { + display: none; + } + } + + @media (min-width: ${get('breakpoints.0')}) { + position: absolute; + top: auto; + right: auto; + bottom: auto; + left: auto; + padding: 0; + } +` + +const Modal = styled.div` + ${modalStyles} +` + +const ModalWrapper = styled.div` + ${modalWrapperStyles} + ${COMMON} +` + +const SelectMenuModal = ({children, theme, ...rest}) => { + return ( + + {children} + + ) +} + +SelectMenuModal.defaultProps = { + theme +} + +SelectMenuModal.propTypes = { + ...COMMON.propTypes +} + +export default SelectMenuModal diff --git a/src/SelectMenu/SelectMenuTab.js b/src/SelectMenu/SelectMenuTab.js new file mode 100644 index 00000000000..160df1806da --- /dev/null +++ b/src/SelectMenu/SelectMenuTab.js @@ -0,0 +1,94 @@ +import React, {useContext, useEffect} from 'react' +import classnames from 'classnames' +import PropTypes from 'prop-types' +import styled, {css} from 'styled-components' +import {MenuContext} from './SelectMenuContext' +import {get, COMMON} from '../constants' +import theme from '../theme' + +const tabStyles = css` + flex: 1; + padding: ${get('space.2')} ${get('space.3')}; + font-size: ${get('fontSizes.0')}; + font-weight: 500; + color: ${get('colors.gray.5')}; + text-align: center; + background-color: transparent; + border: 0; + box-shadow: inset 0 -1px 0 ${get('colors.border.gray')}; + + @media (min-width: ${get('breakpoints.0')}) { + flex: none; + padding: ${get('space.1')} ${get('space.3')}; + border: ${get('borders.1')} transparent; + border-bottom-width: 0; + border-top-left-radius: ${get('radii.2')}; + border-top-right-radius: ${get('radii.2')}; + } + + &[aria-selected='true'] { + z-index: 1; // Keeps box-shadow visible when hovering + color: ${get('colors.gray.9')}; + background-color: ${get('colors.white')}; + box-shadow: 0 0 0 1px ${get('colors.border.gray')}; + + @media (min-width: ${get('breakpoints.0')}) { + border-color: ${get('colors.border.gray')}; + box-shadow: none; + } + } + + &:focus { + background-color: #dbedff; + } +` + +const StyledTab = styled.button` + ${tabStyles} + ${COMMON} +` + +const SelectMenuTab = ({tabName, index, className, onClick, ...rest}) => { + const menuContext = useContext(MenuContext) + const handleClick = e => { + // if consumer has attached an onClick event, call it + onClick && onClick(e) + if (!e.defaultPrevented) { + menuContext.setSelectedTab(tabName) + } + } + + // if no tab is selected when the component renders, show the first tab + useEffect(() => { + if (!menuContext.selectedTab && index === 0) { + menuContext.setSelectedTab(tabName) + } + }, []) + + const isSelected = menuContext.selectedTab === tabName + + return ( + + {tabName} + + ) +} + +SelectMenuTab.defaultProps = { + theme +} + +SelectMenuTab.propTypes = { + index: PropTypes.number, + onClick: PropTypes.func, + tabName: PropTypes.string, + ...COMMON.propTypes +} + +export default SelectMenuTab diff --git a/src/SelectMenu/SelectMenuTabPanel.js b/src/SelectMenu/SelectMenuTabPanel.js new file mode 100644 index 00000000000..338598c9645 --- /dev/null +++ b/src/SelectMenu/SelectMenuTabPanel.js @@ -0,0 +1,31 @@ +import React, {useContext} from 'react' +import PropTypes from 'prop-types' +import styled from 'styled-components' +import {MenuContext} from './SelectMenuContext' +import SelectMenuList from './SelectMenuList' +import theme from '../theme' +import {COMMON} from '../constants' + +const TabPanelBase = ({tabName, className, children, ...rest}) => { + const menuContext = useContext(MenuContext) + return ( + + ) +} + +const TabPanel = styled(TabPanelBase)` + ${COMMON} +` + +TabPanel.defaultProps = { + theme +} + +TabPanel.propTypes = { + tabName: PropTypes.string, + ...COMMON.propTypes +} + +export default TabPanel diff --git a/src/SelectMenu/SelectMenuTabs.js b/src/SelectMenu/SelectMenuTabs.js new file mode 100644 index 00000000000..72ae1007446 --- /dev/null +++ b/src/SelectMenu/SelectMenuTabs.js @@ -0,0 +1,45 @@ +import React from 'react' +import styled, {css} from 'styled-components' +import {COMMON, get} from '../constants' +import theme from '../theme' + +const tabWrapperStyles = css` + display: flex; + flex-shrink: 0; + margin-bottom: -1px; // hide border of element below + border-top: ${get('borders.1')} ${get('colors.border.gray')}; + -webkit-overflow-scrolling: touch; + + // Hide scrollbar so it doesn't cover the text + &::-webkit-scrollbar { + display: none; + } + + @media (min-width: ${get('breakpoints.0')}) { + padding: 0 ${get('space.2')}; + border-top: 0; + } +` + +const Tabs = ({children, ...rest}) => { + return ( +
+ {children} +
+ ) +} + +const SelectMenuTabs = styled(Tabs)` + ${tabWrapperStyles} + ${COMMON} +` + +SelectMenuTabs.defaultProps = { + theme +} + +SelectMenuTabs.propTypes = { + ...COMMON.propTypes +} + +export default SelectMenuTabs diff --git a/src/SelectMenu/hooks/useKeyboardNav.js b/src/SelectMenu/hooks/useKeyboardNav.js new file mode 100644 index 00000000000..1c5dae1fb67 --- /dev/null +++ b/src/SelectMenu/hooks/useKeyboardNav.js @@ -0,0 +1,82 @@ +import {useEffect} from 'react' + +// adapted from details-menu web component https://github.com/github/details-menu-element +function useKeyboardNav(details, open, setOpen) { + const handleKeyDown = event => { + const closeDetails = () => { + setOpen(false) + const summary = details.current.querySelector('summary') + if (summary) summary.focus() + } + const openDetails = () => { + setOpen(true) + } + const focusItem = next => { + const options = Array.from( + details.current.querySelectorAll('[role^="menuitem"]:not([hidden]):not([disabled]):not([aria-disabled="true"])') + ) + const selected = document.activeElement + const index = options.indexOf(selected) + const found = next ? options[index + 1] : options[index - 1] + const def = next ? options[0] : options[options.length - 1] + return found || def + } + + const isMenuItem = el => { + const role = el.getAttribute('role') + return role === 'menuitem' || role === 'menuitemcheckbox' || role === 'menuitemradio' + } + if (!(event instanceof KeyboardEvent)) return + const isSummaryFocused = event.target instanceof Element && event.target.tagName === 'SUMMARY' + switch (event.key) { + case 'Escape': + if (open) { + closeDetails(details) + event.preventDefault() + event.stopPropagation() + } + break + case 'ArrowDown': + { + if (isSummaryFocused && !open) { + openDetails(details) + } + const target = focusItem(true) + if (target) target.focus() + event.preventDefault() + } + break + case 'ArrowUp': + { + if (isSummaryFocused && !open) { + openDetails() + } + const target = focusItem(false) + if (target) target.focus() + event.preventDefault() + } + break + case ' ': + case 'Enter': + { + const selected = document.activeElement + if (selected && isMenuItem(selected) && selected.closest('details') === details) { + event.preventDefault() + event.stopPropagation() + selected.click() + } + } + break + } + } + useEffect(() => { + if (!details.current) return + + details.current.addEventListener('keydown', handleKeyDown) + return () => { + details.current.removeEventListener('keydown', handleKeyDown) + } + }, [details.current]) +} + +export default useKeyboardNav diff --git a/src/SelectMenu/index.js b/src/SelectMenu/index.js new file mode 100644 index 00000000000..64cf8e036d8 --- /dev/null +++ b/src/SelectMenu/index.js @@ -0,0 +1,3 @@ +import SelectMenu from './SelectMenu' + +export default SelectMenu diff --git a/src/TextInput.js b/src/TextInput.js index 2f77cd7c9ac..d1742652519 100644 --- a/src/TextInput.js +++ b/src/TextInput.js @@ -26,7 +26,8 @@ const sizeVariants = variant({ } }) -const TextInput = ({icon, className, block, disabled, ...rest}) => { +// using forwardRef is important so that other components (ex. SelectMenu) can autofocus the input +const TextInput = React.forwardRef(({icon, className, block, disabled, ...rest}, ref) => { // this class is necessary to style FilterSearch, plz no touchy! const wrapperClasses = classnames(className, 'TextInput-wrapper') const wrapperProps = pick(rest) @@ -41,14 +42,12 @@ const TextInput = ({icon, className, block, disabled, ...rest}) => { {...wrapperProps} > {icon && } - + ) -} +}) -const Input = styled.input.attrs(props => ({ - type: props.type || 'text' -}))` +const Input = styled.input` border: 0; font-size: inherit; background-color: transparent; @@ -115,7 +114,7 @@ const Wrapper = styled.span` `} // Ensures inputs don't zoom on mobile but are body-font size on desktop - @media (max-width: ${get('breakpoints.1')}) { + @media (min-width: ${get('breakpoints.1')}) { font-size: ${get('fontSizes.1')}; } ${COMMON} @@ -125,7 +124,10 @@ const Wrapper = styled.span` ${sizeVariants} ` -TextInput.defaultProps = {theme} +TextInput.defaultProps = { + theme, + type: 'text' +} TextInput.propTypes = { block: PropTypes.bool, diff --git a/src/__tests__/BreadcrumbItem.js b/src/__tests__/BreadcrumbItem.js index 4f0fd776ab0..4b1e25c573c 100644 --- a/src/__tests__/BreadcrumbItem.js +++ b/src/__tests__/BreadcrumbItem.js @@ -23,7 +23,7 @@ describe('Breadcrumb.Item', () => { }) it('renders the given "as" prop', () => { - const Type = props => + const Type = ({theme, ...props}) => expect(render()).toMatchSnapshot() }) diff --git a/src/__tests__/Dropdown.js b/src/__tests__/Dropdown.js index 23a2098b8fc..e649139492f 100644 --- a/src/__tests__/Dropdown.js +++ b/src/__tests__/Dropdown.js @@ -17,7 +17,7 @@ describe('Dropdown', () => { it('matches the snapshots', () => { expect(render(hi)).toMatchSnapshot() - expect(render(hello!)).toMatchSnapshot() + expect(render(hello!)).toMatchSnapshot() }) it('implements system props', () => { @@ -44,6 +44,16 @@ describe('Dropdown.Item', () => { }) }) +describe('Dropdown.Button', () => { + it('matches the snapshots', () => { + expect(render(hi)).toMatchSnapshot() + }) + + it('has default theme', () => { + expect(Dropdown.Button).toSetDefaultTheme() + }) +}) + describe('Dropdown.Menu', () => { it('matches the snapshots', () => { expect( diff --git a/src/__tests__/FilterListItem.js b/src/__tests__/FilterListItem.js index 02a5742e458..56d00c1bfab 100644 --- a/src/__tests__/FilterListItem.js +++ b/src/__tests__/FilterListItem.js @@ -28,7 +28,7 @@ describe('FilterList.Item', () => { }) it('renders the given "as" prop', () => { - const Type = props => + const Type = ({theme, ...props}) => expect(render()).toMatchSnapshot() }) diff --git a/src/__tests__/Pagination/Pagination.js b/src/__tests__/Pagination/Pagination.js new file mode 100644 index 00000000000..0be0c01aa4d --- /dev/null +++ b/src/__tests__/Pagination/Pagination.js @@ -0,0 +1,41 @@ +import React from 'react' +import Pagination from '../../Pagination' +import {render} from '../../utils/testing' +import {COMMON} from '../../constants' +import {render as HTMLRender, cleanup} from '@testing-library/react' +import {axe, toHaveNoViolations} from 'jest-axe' +import 'babel-polyfill' +expect.extend(toHaveNoViolations) + +const reqProps = {pageCount: 10, currentPage: 1} +const comp = + +describe('Pagination', () => { + it('implements common system props', () => { + expect(Pagination).toImplementSystemProps(COMMON) + }) + + it('should have no axe violations', async () => { + const {container} = HTMLRender(comp) + const results = await axe(container, { + rules: { + // The skip-link rule has to do with entire documents + // and is not relevant to this component. + // See https://dequeuniversity.com/rules/axe/3.3/skip-link?application=axeAPI + 'skip-link': { + enabled: false + } + } + }) + expect(results).toHaveNoViolations() + cleanup() + }) + + it('has default theme', () => { + expect(comp).toSetDefaultTheme() + }) + + it('matches snapshot', () => { + expect(render(comp)).toMatchSnapshot() + }) +}) diff --git a/src/__tests__/Pagination/PaginationModel.js b/src/__tests__/Pagination/PaginationModel.js new file mode 100644 index 00000000000..3dec12c539b --- /dev/null +++ b/src/__tests__/Pagination/PaginationModel.js @@ -0,0 +1,131 @@ +import 'babel-polyfill' +import {buildPaginationModel} from '../../Pagination/model' + +function first(array, count = 1) { + const slice = array.slice(0, count) + return count === 1 ? slice[0] : slice +} + +function last(array, count = 1) { + const len = array.length + const slice = array.slice(len - count, len) + return count === 1 ? slice[0] : slice +} + +describe('Pagination model', () => { + it('sets disabled on prev links', () => { + const model1 = buildPaginationModel(10, 1, true, 1, 2) + expect(first(model1).type).toEqual('PREV') + expect(first(model1).disabled).toBe(true) + + const model2 = buildPaginationModel(10, 2, true, 1, 2) + expect(first(model2).type).toEqual('PREV') + expect(first(model2).disabled).toBe(false) + }) + + it('sets disabled on next links', () => { + const model1 = buildPaginationModel(10, 10, true, 1, 2) + expect(last(model1).type).toEqual('NEXT') + expect(last(model1).disabled).toBe(true) + + const model2 = buildPaginationModel(10, 9, true, 1, 2) + expect(last(model2).type).toEqual('NEXT') + expect(last(model2).disabled).toBe(false) + }) + + it('sets the page number for prev and next links', () => { + const model = buildPaginationModel(10, 5, true, 1, 2) + expect(first(model).num).toEqual(4) + expect(last(model).num).toEqual(6) + }) + + it('ensures margin pages on the left', () => { + const model = buildPaginationModel(10, 10, true, 2, 0) + const slice = first(model, 5) + + const expected = [ + {type: 'PREV', num: 9}, + {type: 'NUM', num: 1}, + {type: 'NUM', num: 2}, + {type: 'BREAK'}, + {type: 'NUM'} + ] + + expect(slice).toMatchObject(expected) + }) + + it('ensures margin pages on the right', () => { + const model = buildPaginationModel(10, 1, true, 2, 0) + const slice = last(model, 5) + + const expected = [ + {type: 'NUM'}, + {type: 'BREAK'}, + {type: 'NUM', num: 9}, + {type: 'NUM', num: 10}, + {type: 'NEXT', num: 2} + ] + + expect(slice).toMatchObject(expected) + }) + + it('ensures that the current page is surrounded by the right number of pages', () => { + const model = buildPaginationModel(10, 5, true, 1, 1) + const expected = [ + {type: 'PREV', num: 4}, + {type: 'NUM', num: 1}, + {type: 'BREAK'}, + {type: 'NUM', num: 4}, + {type: 'NUM', num: 5, selected: true}, + {type: 'NUM', num: 6}, + {type: 'BREAK'}, + {type: 'NUM', num: 10}, + {type: 'NEXT', num: 6} + ] + expect(model).toMatchObject(expected) + }) + + it('adds items to the right if it hits bounds to the left', () => { + const model = buildPaginationModel(15, 2, true, 1, 1) + const expected = [ + {type: 'PREV', num: 1}, + {type: 'NUM', num: 1}, + {type: 'NUM', num: 2, selected: true}, + {type: 'NUM', num: 3}, + // normally with a surround of 1, only 1 and 3 would be shown + // however, since 1 was already shown, we extend to 4 + {type: 'NUM', num: 4}, + {type: 'BREAK'} + ] + expect(first(model, 6)).toMatchObject(expected) + }) + + it('adds items to the left if it hits bounds to the right', () => { + const model = buildPaginationModel(15, 14, true, 1, 1) + const expected = [ + // normally with a surround of 1, only 13 and 15 would be shown + // however, since 15 was already shown, we extend to 12 + {type: 'BREAK'}, + {type: 'NUM', num: 12}, + {type: 'NUM', num: 13}, + {type: 'NUM', num: 14, selected: true}, + {type: 'NUM', num: 15}, + {type: 'NEXT', num: 15} + ] + expect(last(model, 6)).toMatchObject(expected) + }) + + it('correctly creates breaks next to the next/prev links when margin is 0', () => { + const model = buildPaginationModel(10, 5, true, 0, 1) + const expected = [ + {type: 'PREV'}, + {type: 'BREAK', num: 1}, + {type: 'NUM', num: 4}, + {type: 'NUM', num: 5, selected: true}, + {type: 'NUM', num: 6}, + {type: 'BREAK', num: 10}, + {type: 'NEXT'} + ] + expect(model).toMatchObject(expected) + }) +}) diff --git a/src/__tests__/Pagination/__snapshots__/Pagination.js.snap b/src/__tests__/Pagination/__snapshots__/Pagination.js.snap new file mode 100644 index 00000000000..04172d2115d --- /dev/null +++ b/src/__tests__/Pagination/__snapshots__/Pagination.js.snap @@ -0,0 +1,216 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Pagination matches snapshot 1`] = ` +.c1 { + display: inline-block; +} + +.c2 { + position: relative; + float: left; + padding: 7px 12px; + margin-left: -1px; + font-size: 13px; + font-style: normal; + font-weight: 600; + color: #0366d6; + white-space: nowrap; + vertical-align: middle; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + background: #fff; + border: 1px solid #e1e4e8; + -webkit-text-decoration: none; + text-decoration: none; + color: #d1d5da; + cursor: default; + background-color: #fafbfc; + border-color: #e1e4e8; +} + +.c2:first-child { + margin-left: 0; + border-top-left-radius: 3px; + border-bottom-left-radius: 3px; +} + +.c2:last-child { + border-top-right-radius: 3px; + border-bottom-right-radius: 3px; +} + +.c3 { + position: relative; + float: left; + padding: 7px 12px; + margin-left: -1px; + font-size: 13px; + font-style: normal; + font-weight: 600; + color: #0366d6; + white-space: nowrap; + vertical-align: middle; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + background: #fff; + border: 1px solid #e1e4e8; + -webkit-text-decoration: none; + text-decoration: none; + z-index: 3; + color: #fff; + background-color: #0366d6; + border-color: #0366d6; +} + +.c3:first-child { + margin-left: 0; + border-top-left-radius: 3px; + border-bottom-left-radius: 3px; +} + +.c3:last-child { + border-top-right-radius: 3px; + border-bottom-right-radius: 3px; +} + +.c4 { + position: relative; + float: left; + padding: 7px 12px; + margin-left: -1px; + font-size: 13px; + font-style: normal; + font-weight: 600; + color: #0366d6; + white-space: nowrap; + vertical-align: middle; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + background: #fff; + border: 1px solid #e1e4e8; + -webkit-text-decoration: none; + text-decoration: none; +} + +.c4:first-child { + margin-left: 0; + border-top-left-radius: 3px; + border-bottom-left-radius: 3px; +} + +.c4:last-child { + border-top-right-radius: 3px; + border-bottom-right-radius: 3px; +} + +.c4:hover, +.c4:focus { + z-index: 2; + color: #0366d6; + background-color: #f6f8fa; + border-color: #e1e4e8; +} + +.c0 { + margin-top: 20px; + margin-bottom: 15px; + text-align: center; +} + + +`; diff --git a/src/__tests__/SelectMenu.js b/src/__tests__/SelectMenu.js new file mode 100644 index 00000000000..250d0084e56 --- /dev/null +++ b/src/__tests__/SelectMenu.js @@ -0,0 +1,126 @@ +import React from 'react' +import SelectMenu from '../SelectMenu' +import Button from '../Button' +import {mount, renderRoot} from '../utils/testing' +import {COMMON} from '../constants' +import {render as HTMLRender, cleanup} from '@testing-library/react' +import {axe, toHaveNoViolations} from 'jest-axe' +import 'babel-polyfill' +expect.extend(toHaveNoViolations) + +const BasicSelectMenu = ({onClick, as}) => { + return ( + + + + + + Primer Components bugs + + + Primer Components roadmap + + stuff + Project 3 + Project 4 + footer + + + + ) +} + +const MenuWithTabs = ({onClick}) => { + return ( + + + + + + + + + Primer Components bugs + Primer Components roadmap + Project 3 + Project 4 + + + Project 2 + + Showing 3 of 3 + + + ) +} + +describe('SelectMenu', () => { + it('should have no axe violations', async () => { + const {container} = HTMLRender() + const results = await axe(container) + expect(results).toHaveNoViolations() + cleanup() + }) + it('implements system props', () => { + expect(SelectMenu).toImplementSystemProps(COMMON) + }) + + it('has default theme', () => { + expect(SelectMenu).toSetDefaultTheme() + }) + + it('does not allow the "as" prop on SelectMenu', () => { + const component = mount() + expect(component.find('details').length).toEqual(1) + }) + + it('does not allow the "as" prop on SelectMenu.Item', () => { + const component = mount() + expect( + component + .find("[data-test='menu-item']") + .first() + .getDOMNode().tagName + ).toEqual('A') + }) + + it('shows correct initial tab', () => { + const testInstance = renderRoot() + expect(testInstance.findByProps({'aria-selected': true}).props.children).toBe('Organization') + }) + + it('clicking on a tab opens the tab', () => { + const component = mount() + const tab = component.find("[data-test='repo-tab']").first() + tab.simulate('click') + expect(tab.getDOMNode().attributes['aria-selected']).toBeTruthy() + }) + + it('selected items have aria-checked', () => { + const testInstance = renderRoot() + expect(testInstance.findByProps({'aria-checked': true}).props.children[1]).toBe('Primer Components bugs') + }) + + it('clicking on a list item calls user provided onClick handler', () => { + const mockClick = jest.fn() + const component = mount() + const item = component.find("[data-test='menu-item']").first() + item.simulate('click') + expect(mockClick.mock.calls.length).toEqual(1) + }) + + it('clicking on a tab calls user provided onClick handler', () => { + const mockClick = jest.fn() + const component = mount() + const item = component.find("[data-test='repo-tab']").first() + item.simulate('click') + expect(mockClick.mock.calls.length).toEqual(1) + }) + + it('clicking on an item closes the modal', () => { + const component = mount() + const item = component.find("[data-test='menu-item']").first() + item.simulate('click') + expect(component.getDOMNode().attributes.open).toBeFalsy() + }) +}) diff --git a/src/__tests__/SubNavLink.js b/src/__tests__/SubNavLink.js index e7794278302..d18e7676c6a 100644 --- a/src/__tests__/SubNavLink.js +++ b/src/__tests__/SubNavLink.js @@ -23,7 +23,7 @@ describe('SubNav.Link', () => { }) it('renders the given "as" prop', () => { - const Type = props => + const Type = ({theme, ...props}) => expect(render()).toMatchSnapshot() }) diff --git a/src/__tests__/UnderlineNavLink.js b/src/__tests__/UnderlineNavLink.js index ece0cdc884d..c09b7fac3de 100644 --- a/src/__tests__/UnderlineNavLink.js +++ b/src/__tests__/UnderlineNavLink.js @@ -23,7 +23,7 @@ describe('UnderlineNav.Link', () => { }) it('renders the given "as" prop', () => { - const Type = props => + const Type = ({theme, ...props}) => expect(render()).toMatchSnapshot() }) diff --git a/src/__tests__/__snapshots__/BreadcrumbItem.js.snap b/src/__tests__/__snapshots__/BreadcrumbItem.js.snap index 1d89748a790..f4419bc6c37 100644 --- a/src/__tests__/__snapshots__/BreadcrumbItem.js.snap +++ b/src/__tests__/__snapshots__/BreadcrumbItem.js.snap @@ -23,314 +23,6 @@ exports[`Breadcrumb.Item renders the given "as" prop 1`] = ` activeClassName="" aria-current={null} className="c0" - theme={ - Object { - "borders": Array [ - 0, - "1px solid", - ], - "breakpoints": Array [ - "544px", - "768px", - "1012px", - "1280px", - ], - "buttons": Object { - "danger": Object { - "bg": Object { - "active": "#be222e", - "default": "#fafbfc", - "disabled": "#F3F4F6", - "hover": "#cb2431", - }, - "border": Object { - "active": "#9e1c23", - "default": "#e1e4e8", - "hover": "#b31d28", - }, - "color": Object { - "active": "#fff", - "default": "#cb2431", - "disabled": "rgba(203,36,49, .5)", - "hover": "#fff", - }, - "shadow": Object { - "active": "inset 0px 2px 0px rgba(179, 29, 40, 0.4)", - "default": "0px 1px 0px rgba(149, 157, 165, 0.1), inset 0px 2px 0px rgba(255, 255, 255, 0.25)", - "focus": "0 0 0 3px rgba(203, 36, 49, 0.4)", - "hover": "0px 1px 0px rgba(149, 157, 165, 0.1), inset 0px 2px 0px rgba(255, 255, 255, 0.03)", - }, - }, - "default": Object { - "bg": Object { - "active": "#edeff2", - "default": "#fafbfc", - "disabled": "#fafbfc", - "hover": "#F3F4F6", - }, - "border": Object { - "active": "#d1d5da", - "default": "#e1e4e8", - "disabled": "#eaecef", - }, - "color": Object { - "default": "#24292e", - "disabled": "#959da5", - }, - "shadow": Object { - "active": "inset 0px 2px 0px rgba(149, 157, 165, 0.1)", - "default": "0px 1px 0px rgba(149, 157, 165, 0.1), inset 0px 2px 0px rgba(255, 255, 255, 0.25)", - "focus": "0 0 0 3px rgba(3, 102, 214, 0.3)", - "hover": "0px 1px 0px rgba(209, 213, 218, 0.2), inset 0px 2px 0px rgba(255, 255, 255, 0.1)", - }, - }, - "outline": Object { - "bg": Object { - "active": "#035fc7", - "default": "#fafbfc", - "disabled": "#F3F4F6", - "hover": "#0366d6", - }, - "border": Object { - "active": "rgba(4, 66, 137, .5)", - "default": "#e1e4e8", - "hover": "#005cc5", - }, - "color": Object { - "active": "#fff", - "default": "#005cc5", - "disabled": "#959da5", - "hover": "#fff", - }, - "shadow": Object { - "active": "inset 0px 1px 0px rgba(4, 66, 137, 0.2)", - "default": "0px 1px 0px rgba(149, 157, 165, 0.1), inset 0px 2px 0px rgba(255, 255, 255, 0.25)", - "focus": "0 0 0 3px rgba(3, 102, 214, 0.3)", - "hover": "0px 1px 0px rgba(149, 157, 165, 0.1), inset 0px 2px 0px rgba(255, 255, 255, 0.03)", - }, - }, - "primary": Object { - "bg": Object { - "active": "#128031", - "default": "#159739", - "disabled": "#94D3A2", - "focus": "#138934", - "hover": "#138934", - }, - "border": Object { - "active": "#176f2c", - "default": "#22863a", - "disabled": "rgba(34, 134, 58, 0.1)", - "hover": "#176f2c", - }, - "color": Object { - "default": "#fff", - "disabled": "rgba(255, 255, 255, 0.50)", - }, - "shadow": Object { - "active": "inset 0px 1px 0px rgba(20, 70, 32, 0.2)", - "default": " 0px 1px 0px rgba(20, 70, 32, 0.1), inset 0px 2px 0px rgba(255, 255, 255, 0.03)", - "focus": "0 0 0 3px #94D3A2", - }, - }, - }, - "colors": Object { - "accent": "#f66a0a", - "bg": Object { - "disabled": "#F3F4F6", - "gray": "#f6f8fa", - "grayLight": "#fafbfc", - }, - "black": "#1b1f23", - "blackfade15": "rgba(27, 31, 35, 0.15)", - "blackfade20": "rgba(27, 31, 35, 0.20)", - "blackfade30": "rgba(27,31,35,0.3)", - "blackfade35": "rgba(27, 31, 35, 0.35)", - "blackfade50": "rgba(27, 31, 35, 0.5)", - "blue": Array [ - "#f1f8ff", - "#dbedff", - "#c8e1ff", - "#79b8ff", - "#2188ff", - "#0366d6", - "#005cc5", - "#044289", - "#032f62", - "#05264c", - ], - "bodytext": "#24292e", - "border": Object { - "gray": "#e1e4e8", - "grayDark": "#d1d5da", - "grayLight": "#eaecef", - }, - "counter": Object { - "bg": "rgba(27, 31, 35, 0.08)", - }, - "filterList": Object { - "hoverBg": "#eaecef", - }, - "gray": Array [ - "#fafbfc", - "#f6f8fa", - "#e1e4e8", - "#d1d5da", - "#959da5", - "#6a737d", - "#586069", - "#444d56", - "#2f363d", - "#24292e", - ], - "green": Array [ - "#f0fff4", - "#dcffe4", - "#bef5cb", - "#85e89d", - "#34d058", - "#28a745", - "#22863a", - "#176f2c", - "#165c26", - "#144620", - ], - "orange": Array [ - "#fff8f2", - "#ffebda", - "#ffd1ac", - "#ffab70", - "#fb8532", - "#f66a0a", - "#e36209", - "#d15704", - "#c24e00", - "#a04100", - ], - "purple": Array [ - "#f5f0ff", - "#e6dcfd", - "#d1bcf9", - "#b392f0", - "#8a63d2", - "#6f42c1", - "#5a32a3", - "#4c2889", - "#3a1d6e", - "#29134e", - ], - "red": Array [ - "#ffeef0", - "#ffdce0", - "#fdaeb7", - "#f97583", - "#ea4a5a", - "#d73a49", - "#cb2431", - "#b31d28", - "#9e1c23", - "#86181d", - ], - "state": Object { - "error": "#d73a49", - "failure": "#d73a49", - "pending": "#dbab09", - "queued": "#dbab09", - "success": "#28a745", - "unknown": "#959da5", - }, - "text": Object { - "gray": "#586069", - "grayDark": "#24292e", - "grayLight": "#6a737d", - "red": "#cb2431", - }, - "white": "#fff", - "whitefade15": "rgba(255, 255, 255, 0.15)", - "whitefade50": "rgba(255, 255, 255, 0.50)", - "yellow": Array [ - "#fffdef", - "#fffbdd", - "#fff5b1", - "#ffea7f", - "#ffdf5d", - "#ffd33d", - "#f9c513", - "#dbab09", - "#b08800", - "#735c0f", - ], - }, - "fontSizes": Array [ - "12px", - "14px", - "16px", - "20px", - "24px", - "32px", - "40px", - "48px", - ], - "fontWeights": Object { - "bold": 600, - "light": 300, - "normal": 400, - "semibold": 500, - }, - "fonts": Object { - "mono": "SFMono-Regular, Consolas, \\"Liberation Mono\\", Menlo, Courier, monospace", - "normal": "-apple-system, BlinkMacSystemFont, \\"Segoe UI\\", Helvetica, Arial, sans-serif, \\"Apple Color Emoji\\", \\"Segoe UI Emoji\\", \\"Segoe UI Symbol\\"", - }, - "lineHeights": Object { - "condensed": 1.25, - "condensedUltra": 1, - "default": 1.5, - }, - "maxWidths": Object { - "large": "1012px", - "medium": "768px", - "small": "544px", - "xlarge": "1280px", - }, - "popovers": Object { - "colors": Object { - "caret": "rgba(27, 31, 35, 0.15)", - }, - }, - "radii": Array [ - "0", - "3px", - "6px", - "100px", - ], - "shadows": Object { - "extra-large": "0 10px 50px rgba(27, 31, 35, 0.07)", - "formControl": "inset 0px 2px 0px rgba(225, 228, 232, 0.2)", - "formControlDisabled": "inset 0px 2px 0px rgba(220, 227, 237, 0.3)", - "formControlFocus": "rgba(3, 102, 214, 0.3) 0px 0px 0px 0.2em", - "large": "0 1px 15px rgba(27, 31, 35, 0.15)", - "medium": "0 1px 5px rgba(27, 31, 35, 0.15)", - "primaryActiveShadow": "inset 0px 1px 0px rgba(20, 70, 32, 0.2)", - "primaryShadow": "0px 1px 0px rgba(20, 70, 32, 0.1), inset 0px 2px 0px rgba(255, 255, 255, 0.03)", - "small": "0 1px 1px rgba(27, 31, 35, 0.1)", - }, - "space": Array [ - "0", - "4px", - "8px", - "16px", - "24px", - "32px", - "40px", - "48px", - "64px", - "80px", - "96px", - "112px", - "128px", - ], - } - } /> `; diff --git a/src/__tests__/__snapshots__/Dropdown.js.snap b/src/__tests__/__snapshots__/Dropdown.js.snap index b21fa7442f7..b5723539189 100644 --- a/src/__tests__/__snapshots__/Dropdown.js.snap +++ b/src/__tests__/__snapshots__/Dropdown.js.snap @@ -1,67 +1,29 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Dropdown matches the snapshots 1`] = ` -.c2 { - position: relative; - display: inline-block; - padding: 6px 16px; - font-weight: 600; - line-height: 20px; - white-space: nowrap; - vertical-align: middle; - cursor: pointer; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - border-radius: 6px; - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - -webkit-text-decoration: none; - text-decoration: none; - font-size: 14px; - color: #24292e; - background-color: #fafbfc; - border: 1px solid #e1e4e8; - box-shadow: 0px 1px 0px rgba(149,157,165,0.1),inset 0px 2px 0px rgba(255,255,255,0.25); -} - -.c2:hover { - -webkit-text-decoration: none; - text-decoration: none; -} - -.c2:focus { - outline: none; -} - -.c2:disabled { - cursor: default; -} - -.c2:hover { - background-color: #F3F4F6; - box-shadow: 0px 1px 0px rgba(209,213,218,0.2),inset 0px 2px 0px rgba(255,255,255,0.1); +.c0 > summary { + list-style: none; } -.c2:focus { - border-color: transparent; - box-shadow: 0 0 0 3px rgba(3,102,214,0.3); +.c0 > summary::-webkit-details-marker { + display: none; } -.c2:active { - background-color: #edeff2; - box-shadow: inset 0px 2px 0px rgba(149,157,165,0.1); - border-color: #d1d5da; +.c1 { + position: relative; + display: inline-block; } -.c2:disabled { - color: #959da5; - background-color: #fafbfc; - border-color: #eaecef; -} +
+ hi +
+`; +exports[`Dropdown matches the snapshots 2`] = ` .c0 > summary { list-style: none; } @@ -75,37 +37,17 @@ exports[`Dropdown matches the snapshots 1`] = ` display: inline-block; } -.c3 { - border: 4px solid transparent; - margin-left: 12px; - border-top-color: currentcolor; - border-bottom-width: 0; - content: ''; - display: inline-block; - height: 0; - vertical-align: middle; - width: 0; -} -
- -
-
- hi + hello!
`; -exports[`Dropdown matches the snapshots 2`] = ` -.c2 { +exports[`Dropdown.Button matches the snapshots 1`] = ` +.c0 { position: relative; display: inline-block; padding: 6px 16px; @@ -131,55 +73,42 @@ exports[`Dropdown matches the snapshots 2`] = ` box-shadow: 0px 1px 0px rgba(149,157,165,0.1),inset 0px 2px 0px rgba(255,255,255,0.25); } -.c2:hover { +.c0:hover { -webkit-text-decoration: none; text-decoration: none; } -.c2:focus { +.c0:focus { outline: none; } -.c2:disabled { +.c0:disabled { cursor: default; } -.c2:hover { +.c0:hover { background-color: #F3F4F6; box-shadow: 0px 1px 0px rgba(209,213,218,0.2),inset 0px 2px 0px rgba(255,255,255,0.1); } -.c2:focus { +.c0:focus { border-color: transparent; box-shadow: 0 0 0 3px rgba(3,102,214,0.3); } -.c2:active { +.c0:active { background-color: #edeff2; box-shadow: inset 0px 2px 0px rgba(149,157,165,0.1); border-color: #d1d5da; } -.c2:disabled { +.c0:disabled { color: #959da5; background-color: #fafbfc; border-color: #eaecef; } -.c0 > summary { - list-style: none; -} - -.c0 > summary::-webkit-details-marker { - display: none; -} - .c1 { - position: relative; - display: inline-block; -} - -.c3 { border: 4px solid transparent; margin-left: 12px; border-top-color: currentcolor; @@ -191,22 +120,15 @@ exports[`Dropdown matches the snapshots 2`] = ` width: 0; } -
- - hi -
-
- hello! -
+ hi +
+
`; exports[`Dropdown.Item matches the snapshots 1`] = ` diff --git a/src/__tests__/__snapshots__/FilterListItem.js.snap b/src/__tests__/__snapshots__/FilterListItem.js.snap index 6fb22ed9084..935d9f62ad5 100644 --- a/src/__tests__/__snapshots__/FilterListItem.js.snap +++ b/src/__tests__/__snapshots__/FilterListItem.js.snap @@ -36,314 +36,6 @@ exports[`FilterList.Item renders the given "as" prop 1`] = ` `; diff --git a/src/__tests__/__snapshots__/SubNavLink.js.snap b/src/__tests__/__snapshots__/SubNavLink.js.snap index c18f1a79131..dd39055d0a8 100644 --- a/src/__tests__/__snapshots__/SubNavLink.js.snap +++ b/src/__tests__/__snapshots__/SubNavLink.js.snap @@ -63,314 +63,6 @@ exports[`SubNav.Link renders the given "as" prop 1`] = ` `; diff --git a/src/__tests__/__snapshots__/TextInput.js.snap b/src/__tests__/__snapshots__/TextInput.js.snap index 14ac35370d0..5bd69d2daf3 100644 --- a/src/__tests__/__snapshots__/TextInput.js.snap +++ b/src/__tests__/__snapshots__/TextInput.js.snap @@ -53,7 +53,7 @@ exports[`TextInput renders 1`] = ` box-shadow: inset 0px 2px 0px rgba(225,228,232,0.2),rgba(3,102,214,0.3) 0px 0px 0px 0.2em; } -@media (max-width:768px) { +@media (min-width:768px) { .c0 { font-size: 14px; } @@ -125,7 +125,7 @@ exports[`TextInput renders block 1`] = ` box-shadow: inset 0px 2px 0px rgba(225,228,232,0.2),rgba(3,102,214,0.3) 0px 0px 0px 0.2em; } -@media (max-width:768px) { +@media (min-width:768px) { .c0 { font-size: 14px; } @@ -200,7 +200,7 @@ exports[`TextInput renders large 1`] = ` box-shadow: inset 0px 2px 0px rgba(225,228,232,0.2),rgba(3,102,214,0.3) 0px 0px 0px 0.2em; } -@media (max-width:768px) { +@media (min-width:768px) { .c0 { font-size: 14px; } @@ -277,7 +277,7 @@ exports[`TextInput renders small 1`] = ` box-shadow: inset 0px 2px 0px rgba(225,228,232,0.2),rgba(3,102,214,0.3) 0px 0px 0px 0.2em; } -@media (max-width:768px) { +@media (min-width:768px) { .c0 { font-size: 14px; } @@ -347,7 +347,7 @@ exports[`TextInput should render a password input 1`] = ` box-shadow: inset 0px 2px 0px rgba(225,228,232,0.2),rgba(3,102,214,0.3) 0px 0px 0px 0.2em; } -@media (max-width:768px) { +@media (min-width:768px) { .c0 { font-size: 14px; } diff --git a/src/__tests__/__snapshots__/UnderlineNavLink.js.snap b/src/__tests__/__snapshots__/UnderlineNavLink.js.snap index 7060a25b054..b3bd51bac03 100644 --- a/src/__tests__/__snapshots__/UnderlineNavLink.js.snap +++ b/src/__tests__/__snapshots__/UnderlineNavLink.js.snap @@ -40,314 +40,6 @@ exports[`UnderlineNav.Link renders the given "as" prop 1`] = ` `; diff --git a/src/index.js b/src/index.js index c94c997caad..8081a1fe0b2 100644 --- a/src/index.js +++ b/src/index.js @@ -19,6 +19,7 @@ export {default as ButtonDanger} from './ButtonDanger' export {default as ButtonGroup} from './ButtonGroup' export {default as ButtonOutline} from './ButtonOutline' export {default as ButtonPrimary} from './ButtonPrimary' +export {default as ButtonTableList} from './ButtonTableList' export {default as Button} from './Button' export {default as Caret} from './Caret' export {default as CircleBadge} from './CircleBadge' @@ -34,9 +35,11 @@ export {default as Heading} from './Heading' export {default as Label} from './Label' export {default as LabelGroup} from './LabelGroup' export {default as Link} from './Link' +export {default as Pagination} from './Pagination' export {default as PointerBox} from './PointerBox' export {default as Popover} from './Popover' export {default as ProgressBar} from './ProgressBar' +export {default as SelectMenu} from './SelectMenu' export {default as SideNav} from './SideNav' export {default as StateLabel} from './StateLabel' export {default as StyledOcticon} from './StyledOcticon' diff --git a/src/theme.js b/src/theme.js index 79bbd79b2ac..cccbade424e 100644 --- a/src/theme.js +++ b/src/theme.js @@ -1,6 +1,7 @@ import {black, white, gray, blue, green, orange, purple, red, yellow} from 'primer-colors' import {lineHeights} from 'primer-typography' +// General const colors = { bodytext: gray[9], black, @@ -52,6 +53,59 @@ const colors = { accent: orange[5] } +const breakpoints = ['544px', '768px', '1012px', '1280px'] + +const fonts = { + normal: fontStack([ + '-apple-system', + 'BlinkMacSystemFont', + 'Segoe UI', + 'Helvetica', + 'Arial', + 'sans-serif', + 'Apple Color Emoji', + 'Segoe UI Emoji', + 'Segoe UI Symbol' + ]), + mono: fontStack(['SFMono-Regular', 'Consolas', 'Liberation Mono', 'Menlo', 'Courier', 'monospace']) +} + +const fontWeights = { + light: 300, + normal: 400, + semibold: 500, + bold: 600 +} + +const borders = [0, '1px solid'] + +const radii = ['0', '3px', '6px', '100px'] + +const shadows = { + small: '0 1px 1px rgba(27, 31, 35, 0.1)', + medium: '0 1px 5px rgba(27, 31, 35, 0.15)', + large: '0 1px 15px rgba(27, 31, 35, 0.15)', + 'extra-large': '0 10px 50px rgba(27, 31, 35, 0.07)', + formControl: 'inset 0px 2px 0px rgba(225, 228, 232, 0.2)', + formControlDisabled: 'inset 0px 2px 0px rgba(220, 227, 237, 0.3)', + formControlFocus: 'rgba(3, 102, 214, 0.3) 0px 0px 0px 0.2em', + primaryShadow: '0px 1px 0px rgba(20, 70, 32, 0.1), inset 0px 2px 0px rgba(255, 255, 255, 0.03)', + primaryActiveShadow: 'inset 0px 1px 0px rgba(20, 70, 32, 0.2)' +} + +const sizes = { + small: '544px', + medium: '768px', + large: '1012px', + xlarge: '1280px' +} + +const fontSizes = ['12px', '14px', '16px', '20px', '24px', '32px', '40px', '48px'] + +const space = ['0', '4px', '8px', '16px', '24px', '32px', '40px', '48px', '64px', '80px', '96px', '112px', '128px'] + +// Components + const buttons = { default: { color: { @@ -158,52 +212,51 @@ const popovers = { } } +const pagination = { + fontSize: '13px', + fontWeight: fontWeights.bold, + borderRadius: radii[1], + colors: { + normal: { + fg: colors.blue[5], + bg: colors.white, + border: colors.border.gray + }, + disabled: { + fg: colors.gray[3], + bg: colors.gray[0], + border: colors.border.gray + }, + hover: { + fg: colors.blue[5], + bg: colors.gray[1], + border: colors.border.gray + }, + selected: { + fg: colors.white, + bg: colors.blue[5], + border: colors.blue[5] + } + } +} + const theme = { - breakpoints: ['544px', '768px', '1012px', '1280px'], - maxWidths: { - small: '544px', - medium: '768px', - large: '1012px', - xlarge: '1280px' - }, - fonts: { - normal: fontStack([ - '-apple-system', - 'BlinkMacSystemFont', - 'Segoe UI', - 'Helvetica', - 'Arial', - 'sans-serif', - 'Apple Color Emoji', - 'Segoe UI Emoji', - 'Segoe UI Symbol' - ]), - mono: fontStack(['SFMono-Regular', 'Consolas', 'Liberation Mono', 'Menlo', 'Courier', 'monospace']) - }, - fontWeights: { - light: 300, - normal: 400, - semibold: 500, - bold: 600 - }, + // General + borders, + breakpoints, colors, - borders: [0, '1px solid'], - fontSizes: ['12px', '14px', '16px', '20px', '24px', '32px', '40px', '48px'], + fonts, + fontSizes, + fontWeights, lineHeights, - radii: ['0', '3px', '6px', '100px'], - shadows: { - small: '0 1px 1px rgba(27, 31, 35, 0.1)', - medium: '0 1px 5px rgba(27, 31, 35, 0.15)', - large: '0 1px 15px rgba(27, 31, 35, 0.15)', - 'extra-large': '0 10px 50px rgba(27, 31, 35, 0.07)', - formControl: 'inset 0px 2px 0px rgba(225, 228, 232, 0.2)', - formControlDisabled: 'inset 0px 2px 0px rgba(220, 227, 237, 0.3)', - formControlFocus: 'rgba(3, 102, 214, 0.3) 0px 0px 0px 0.2em', - primaryShadow: '0px 1px 0px rgba(20, 70, 32, 0.1), inset 0px 2px 0px rgba(255, 255, 255, 0.03)', - primaryActiveShadow: 'inset 0px 1px 0px rgba(20, 70, 32, 0.2)' - }, - space: ['0', '4px', '8px', '16px', '24px', '32px', '40px', '48px', '64px', '80px', '96px', '112px', '128px'], + radii, + shadows, + sizes, + space, + + // Components buttons, + pagination, popovers } diff --git a/src/utils/test-matchers.js b/src/utils/test-matchers.js index 0aea935954a..ff70965ae10 100644 --- a/src/utils/test-matchers.js +++ b/src/utils/test-matchers.js @@ -52,7 +52,13 @@ expect.extend({ }, toSetDefaultTheme(Component) { - const wrapper = mount() + let comp + if (Component.type) { + comp = Component + } else { + comp = + } + const wrapper = mount(comp) const pass = this.equals(wrapper.prop('theme'), theme) return { pass, diff --git a/src/utils/testing.js b/src/utils/testing.js index 0fc519a067e..c75252c531c 100644 --- a/src/utils/testing.js +++ b/src/utils/testing.js @@ -30,6 +30,15 @@ export function render(component) { return renderer.create(component).toJSON() } +/** + * Render the component (a React.createElement() or JSX expression) + * using react-test-renderer and return the root node + * ``` + */ +export function renderRoot(component) { + return renderer.create(component).root +} + /** * Get the HTML class names rendered by the component instance * as an array. diff --git a/yarn.lock b/yarn.lock index 1b07f2339c0..08ac3cf2613 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2239,6 +2239,14 @@ "@types/minimatch" "*" "@types/node" "*" +"@types/hoist-non-react-statics@*": + version "3.3.1" + resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f" + integrity sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA== + dependencies: + "@types/react" "*" + hoist-non-react-statics "^3.3.0" + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0": version "2.0.1" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz#42995b446db9a48a11a07ec083499a860e9138ff" @@ -2318,6 +2326,13 @@ dependencies: "@types/react" "*" +"@types/react-native@*": + version "0.61.10" + resolved "https://registry.yarnpkg.com/@types/react-native/-/react-native-0.61.10.tgz#d010b9093322bc781151638f74124f58fc87cc90" + integrity sha512-z+RWEFfdwHnOLpq70DO//4mjyNqoZypdR3uBqpBB82t2HJg2YbY4j6XIow7sFqeO/r8XibgV9UFEVG2tYIRlSA== + dependencies: + "@types/react" "*" + "@types/react@*": version "16.9.17" resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.17.tgz#58f0cc0e9ec2425d1441dd7b623421a867aa253e" @@ -2339,6 +2354,16 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" integrity sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw== +"@types/styled-components@^4.4.0": + version "4.4.2" + resolved "https://registry.yarnpkg.com/@types/styled-components/-/styled-components-4.4.2.tgz#709fa7afd7dc0963b8316a0159240f0fe19a026d" + integrity sha512-dngFx2PuGoy0MGE68eHayAmJvLSqWrnTe9w+DnQruu8PS+waWEsKmoBRhkzL2h2pK1OJhzJhVfuiz+oZa4etpA== + dependencies: + "@types/hoist-non-react-statics" "*" + "@types/react" "*" + "@types/react-native" "*" + csstype "^2.2.0" + "@types/styled-system@5.1.2": version "5.1.2" resolved "https://registry.yarnpkg.com/@types/styled-system/-/styled-system-5.1.2.tgz#d75c40bc4a3bb0d0022eb3dcae58854129e9dd32" @@ -5749,6 +5774,13 @@ hmac-drbg@^1.0.0: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" +hoist-non-react-statics@^3.3.0: + version "3.3.2" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" + integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== + dependencies: + react-is "^16.7.0" + hosted-git-info@^2.1.4: version "2.8.5" resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.5.tgz#759cfcf2c4d156ade59b0b2dfabddc42a6b9c70c" @@ -8920,7 +8952,7 @@ react-is@16.10.2: resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.10.2.tgz#984120fd4d16800e9a738208ab1fba422d23b5ab" integrity sha512-INBT1QEgtcCCgvccr5/86CfD71fw9EPmDxgiJX4I2Ddr6ZsV6iFXsuby+qWJPtmNuMY0zByTsG4468P7nHuNWA== -react-is@^16.10.2, react-is@^16.6.0, react-is@^16.8.1, react-is@^16.8.4, react-is@^16.8.6, react-is@^16.9.0: +react-is@^16.10.2, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.4, react-is@^16.8.6, react-is@^16.9.0: version "16.12.0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.12.0.tgz#2cc0fe0fba742d97fd527c42a13bec4eeb06241c" integrity sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q==