From 2d61688fcc42496366f94e19853bb108c13bdc1a Mon Sep 17 00:00:00 2001 From: mmeigs Date: Wed, 8 Feb 2023 10:43:22 -0500 Subject: [PATCH] Dop 3482 v selector (#8) --- src/components/VersionSelector/ArrowSvg.tsx | 17 ++ .../VersionSelector/CheckmarkSvg.tsx | 24 +++ src/components/VersionSelector/Option.tsx | 22 ++ .../VersionSelector/VersionSelector.tsx | 71 +++++++ src/components/VersionSelector/index.ts | 1 + .../VersionSelector/styled.elements.ts | 196 ++++++++++++++++++ src/components/VersionSelector/types.ts | 20 ++ .../VersionSelector/use-outside-click.tsx | 20 ++ .../__tests__/VersionSelector.test.tsx | 16 ++ .../VersionSelector.test.tsx.snap | 117 +++++++++++ .../__tests__/data/mockVersionData.json | 8 + 11 files changed, 512 insertions(+) create mode 100644 src/components/VersionSelector/ArrowSvg.tsx create mode 100644 src/components/VersionSelector/CheckmarkSvg.tsx create mode 100644 src/components/VersionSelector/Option.tsx create mode 100644 src/components/VersionSelector/VersionSelector.tsx create mode 100644 src/components/VersionSelector/index.ts create mode 100644 src/components/VersionSelector/styled.elements.ts create mode 100644 src/components/VersionSelector/types.ts create mode 100644 src/components/VersionSelector/use-outside-click.tsx create mode 100644 src/components/__tests__/VersionSelector.test.tsx create mode 100644 src/components/__tests__/__snapshots__/VersionSelector.test.tsx.snap create mode 100644 src/components/__tests__/data/mockVersionData.json diff --git a/src/components/VersionSelector/ArrowSvg.tsx b/src/components/VersionSelector/ArrowSvg.tsx new file mode 100644 index 00000000..9725cfe3 --- /dev/null +++ b/src/components/VersionSelector/ArrowSvg.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; + +export const ArrowSvg = (): JSX.Element => ( + + + +); diff --git a/src/components/VersionSelector/CheckmarkSvg.tsx b/src/components/VersionSelector/CheckmarkSvg.tsx new file mode 100644 index 00000000..e546b25b --- /dev/null +++ b/src/components/VersionSelector/CheckmarkSvg.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import { palette } from '@leafygreen-ui/palette'; +import styled from '../../styled-components'; + +const StyledSvg = styled.svg` + color: ${palette.blue.base}; + flex-shrink: 0; + margin-right: 6px; +`; + +const Checkmark = () => { + return ( + + + + ); +}; + +export default Checkmark; diff --git a/src/components/VersionSelector/Option.tsx b/src/components/VersionSelector/Option.tsx new file mode 100644 index 00000000..4b505e3f --- /dev/null +++ b/src/components/VersionSelector/Option.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import { StyledLi, StyledOptionText, StyledPlaceholder } from './styled.elements'; +import Checkmark from './CheckmarkSvg'; +import { OptionProps } from './types'; + +export const Option = ({ option, selected, onClick }: OptionProps) => { + const KEY_ENTER = 'ENTER'; + const KEY_SPACE = 'SPACE'; + + const handleKeyPress = (event: React.KeyboardEvent) => { + if (event.key === KEY_ENTER || event.key === KEY_SPACE) { + onClick(); + } + }; + + return ( + + {selected ? : } + {option} + + ); +}; diff --git a/src/components/VersionSelector/VersionSelector.tsx b/src/components/VersionSelector/VersionSelector.tsx new file mode 100644 index 00000000..73e429f9 --- /dev/null +++ b/src/components/VersionSelector/VersionSelector.tsx @@ -0,0 +1,71 @@ +import * as React from 'react'; +import { + ArrowIcon, + StyledWrapper, + StyledSelectWrapper, + StyledButton, + StyledLabel, + StyledDescription, + StyledMenuList, + StyledDisplay, + StyledDropdown, +} from './styled.elements'; +import { Option } from './Option'; +import { VersionSelectorProps } from './types'; +import { useOutsideClick } from './use-outside-click'; + +/** + * Version Selector Dropdown component based structurally and stylistically off LG Select + */ +const VersionSelectorComponent = ({ + resourceVersions, + active, + description, +}: VersionSelectorProps): JSX.Element => { + const initialSelectedIdx = resourceVersions.indexOf(active.resourceVersion); + const [open, setOpen] = React.useState(false); + const [selectedIdx, setSelectedIdx] = React.useState(initialSelectedIdx); + const menuListRef = React.useRef(null); + useOutsideClick(menuListRef, () => { + if (open) setOpen(false); + }); + + const handleClick = (idx: number) => { + setSelectedIdx(idx); + setOpen(false); + }; + + return ( + + + Version Selector: v{active.apiVersion} + {description && {description}} + setOpen(!open)}> + +
+
{resourceVersions[selectedIdx]}
+
+ +
+
+
+ + +
+ + {resourceVersions.map((option, i) => ( + +
+
+
+ ); +}; + +export const VersionSelector = React.memo(VersionSelectorComponent); diff --git a/src/components/VersionSelector/index.ts b/src/components/VersionSelector/index.ts new file mode 100644 index 00000000..eb580aa5 --- /dev/null +++ b/src/components/VersionSelector/index.ts @@ -0,0 +1 @@ +export * from './VersionSelector'; diff --git a/src/components/VersionSelector/styled.elements.ts b/src/components/VersionSelector/styled.elements.ts new file mode 100644 index 00000000..bfb4f71d --- /dev/null +++ b/src/components/VersionSelector/styled.elements.ts @@ -0,0 +1,196 @@ +import { palette } from '@leafygreen-ui/palette'; +import { transparentize } from 'polished'; +import styled, { css } from '../../styled-components'; +import { ArrowSvg } from './ArrowSvg'; +import { ArrowIconProps } from './types'; + +const transitionDuration = { + faster: 100, + default: 150, + slower: 300, +} as const; + +export const ArrowIcon = styled(ArrowSvg)` + position: absolute; + pointer-events: none; + z-index: 1; + top: 50%; + -webkit-transform: ${(props: ArrowIconProps) => + props.open ? `translateY(-50%) rotate(180deg)` : `translateY(-50%)`}; + -ms-transform: ${(props: ArrowIconProps) => + props.open ? `translateY(-50%) rotate(180deg)` : `translateY(-50%)`}; + transform: ${(props: ArrowIconProps) => + props.open ? `translateY(-50%) rotate(180deg)` : `translateY(-50%)`}; + right: 8px; + margin: auto; + text-align: center; +`; + +export const StyledWrapper = styled.div` + font-family: 'Euclid Circular A', Akzidenz, 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-size: 13px; + + width: 90%; + margin: 8px 5% 8px 5%; + position: relative; +`; + +export const StyledSelectWrapper = styled.div` + display: flex; + flex-direction: column; + + > label + button, + > p + button { + margin-top: 3px; + } +`; + +export const StyledButton = styled.button.attrs({ + 'aria-labelledby': 'View a different version of documentation.', +})` + position: relative; + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + height: 36px; + margin: 8px 0; + + background-color: white; + color: ${palette.black}; + border: 1px solid transparent; + border-radius: 6px; + border-color: ${palette.gray.base}; + + &:hover, + &:active { + color: ${palette.black}; + background-color: ${palette.white}; + box-shadow: 0 0 0 3px ${palette.gray.light2}; + } + + &:focus-visible { + box-shadow: 0 0 0 3px ${palette.blue.light1}; + border-color: rgba(255, 255, 255, 0); + } +`; + +export const StyledLabel = styled.label` + pointer-events: none; + line-height: 20px; + margin-bottom: 5px; + font-weight: bold; +`; + +export const StyledDescription = styled.p` + margin: 0; +`; + +export const StyledMenuList = styled.ul` + position: relative; + text-align: left; + width: 100%; + border-radius: 12px; + line-height: 16px; + list-style: none; + margin: 0; + padding: 8px 0px; + overflow: auto; + box-shadow: 0 4px 7px 0 ${transparentize(0.75, palette.black)}; + border: ${palette.gray.light2}; +`; + +export const StyledDisplay = styled.div` + display: grid; + grid-template-columns: 1fr 16px; + gap: 6px; + padding: 0 4px 0 12px; +`; + +export const disabledOptionStyle = css` + cursor: not-allowed; + color: ${palette.gray.base}; +`; + +export const enabledOptionStyle = css` + &:hover { + background-color: ${palette.gray.light2}; + } + + &:focus-visible { + color: ${palette.blue.dark2}; + background-color: ${palette.blue.light3}; + + &:before { + opacity: 1; + transform: scaleY(1); + background-color: ${palette.blue.base}; + } + } +`; + +export const StyledLi = styled.li.attrs<{ selected: boolean; disabled?: boolean }>( + ({ selected }) => ({ + role: 'option', + 'aria-selected': selected, + tabIndex: '0', + }), +)<{ selected: boolean; disabled?: boolean }>` + display: flex; + width: 100%; + outline: none; + overflow-wrap: anywhere; + transition: background-color ${transitionDuration.default}ms ease-in-out; + position: relative; + padding: 8px 12px; + cursor: pointer; + color: ${palette.gray.dark3}; + + font-weight: ${props => (props.selected ? `bold` : `normal`)}; + + &:before { + content: ''; + position: absolute; + transform: scaleY(0.3); + top: 7px; + bottom: 7px; + left: 0; + right: 0; + width: 4px; + border-radius: 0px 4px 4px 0px; + opacity: 0; + transition: all ${transitionDuration.default}ms ease-in-out; + } + + ${props => (props.disabled ? disabledOptionStyle : enabledOptionStyle)} +`; + +export const StyledOptionText = styled.span` + display: flex; + align-items: center; +`; + +export const openDropdownStyle = css` + position: absolute; + top: 70px; + left: 1px; + display: block; + width: 100%; + pointer-events: initial; + z-index: 2; + background-color: ${palette.white}; +`; + +export const StyledDropdown = styled.div.attrs<{ open: boolean }>({ + role: 'listbox', + 'aria-labelledby': 'View a different version of documentation.', + tabIndex: '-1', +})<{ open: boolean }>` + ${props => (props.open ? openDropdownStyle : `display: none;`)} +`; + +export const StyledPlaceholder = styled.span` + width: 16px; + height: 16px; + margin-right: 6px; +`; diff --git a/src/components/VersionSelector/types.ts b/src/components/VersionSelector/types.ts new file mode 100644 index 00000000..790a890b --- /dev/null +++ b/src/components/VersionSelector/types.ts @@ -0,0 +1,20 @@ +export interface ActiveVersionData { + resourceVersion: string; + apiVersion: string; +} +export interface VersionSelectorProps { + resourceVersions: string[]; + active: ActiveVersionData; + rootUrl: string; + description?: string; +} + +export interface OptionProps { + option: string; + selected: boolean; + onClick: () => void; +} + +export interface ArrowIconProps { + open: boolean; +} diff --git a/src/components/VersionSelector/use-outside-click.tsx b/src/components/VersionSelector/use-outside-click.tsx new file mode 100644 index 00000000..25dcb00c --- /dev/null +++ b/src/components/VersionSelector/use-outside-click.tsx @@ -0,0 +1,20 @@ +import { useEffect } from 'react'; + +/** + * Hook that fires event on clicks outside of the passed ref + */ +export function useOutsideClick(ref, callback) { + useEffect(() => { + function handleClickOutside(event) { + if (ref.current && !ref.current.contains(event.target)) { + callback(); + } + } + // Bind the event listener + document.addEventListener('mousedown', handleClickOutside); + return () => { + // Unbind the event listener on clean up + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [ref, callback]); +} diff --git a/src/components/__tests__/VersionSelector.test.tsx b/src/components/__tests__/VersionSelector.test.tsx new file mode 100644 index 00000000..2a504d6e --- /dev/null +++ b/src/components/__tests__/VersionSelector.test.tsx @@ -0,0 +1,16 @@ +/* eslint-disable import/no-internal-modules */ +import * as React from 'react'; +import { render } from 'enzyme'; +import { VersionSelector } from '../VersionSelector'; +import * as versionData from './data/mockVersionData.json'; + +describe('VersionSelector', () => { + it('should correctly render VersionSelector', () => { + const wrapper = render(); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.find('label').text()).toBe( + `Version Selector: v${versionData.active.apiVersion}`, + ); + expect(wrapper.find('button').text()).toBe(versionData.resourceVersions.slice(-1)[0]); + }); +}); diff --git a/src/components/__tests__/__snapshots__/VersionSelector.test.tsx.snap b/src/components/__tests__/__snapshots__/VersionSelector.test.tsx.snap new file mode 100644 index 00000000..ab7d9f66 --- /dev/null +++ b/src/components/__tests__/__snapshots__/VersionSelector.test.tsx.snap @@ -0,0 +1,117 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`VersionSelector should correctly render VersionSelector 1`] = ` +
+
+ + +
+
+
+
    +
  • + + + 2021-09-09 + +
  • +
  • + + + 2022-10-18 + +
  • +
  • + + + + + 2023-01-01 + +
  • +
+
+
+
+`; diff --git a/src/components/__tests__/data/mockVersionData.json b/src/components/__tests__/data/mockVersionData.json new file mode 100644 index 00000000..30da5fa4 --- /dev/null +++ b/src/components/__tests__/data/mockVersionData.json @@ -0,0 +1,8 @@ +{ + "active": { + "apiVersion": "2.0", + "resourceVersion": "2023-01-01" + }, + "rootUrl": "https://mongodb.com/docs/atlas/reference/api-resources-spec/v2", + "resourceVersions": ["2021-09-09", "2022-10-18", "2023-01-01"] +}