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/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 432a3aadf78..29e1666c0d2 100644 --- a/docs/src/@primer/gatsby-theme-doctocat/nav.yml +++ b/docs/src/@primer/gatsby-theme-doctocat/nav.yml @@ -70,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 43c048de4b6..f8d555b5490 100644 --- a/index.d.ts +++ b/index.d.ts @@ -98,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 @@ -288,6 +295,58 @@ 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 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 + } + export interface SideNavProps extends CommonProps, BorderProps, Omit, 'color'> { bordered?: boolean variant?: 'normal' | 'lightweight' @@ -447,7 +506,6 @@ declare module '@primer/components' { export const ProgressBar: React.FunctionComponent } - declare module '@primer/components/src/Box' { import {Box} from '@primer/components' export default Box @@ -478,6 +536,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 @@ -589,10 +652,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 002d5618cab..5531ae268d2 100644 --- a/package.json +++ b/package.json @@ -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/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__/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__/__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/index.js b/src/index.js index 1481979d276..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' @@ -38,6 +39,7 @@ 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/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==