From 40e09dd829bc6e51744c1e23bc17e76577c414f7 Mon Sep 17 00:00:00 2001 From: Saumya Pandey Date: Thu, 7 Oct 2021 10:18:43 +0530 Subject: [PATCH] fix: Implement custom Select Input (#2571) --- app/components/ContextMenu/MenuItem.js | 9 +- app/components/ContextMenu/index.js | 31 +-- app/components/InputSelect.js | 255 ++++++++++++----- app/components/InputSelectPermission.js | 20 +- app/components/InputSelectRole.js | 5 +- app/components/List/Item.js | 7 +- app/hooks/useMenuHeight.js | 32 +++ app/scenes/CollectionEdit.js | 5 +- app/scenes/CollectionNew.js | 4 +- .../CollectionGroupMemberListItem.js | 10 +- .../components/MemberListItem.js | 10 +- app/scenes/CollectionPermissions/index.js | 4 +- app/scenes/Invite.js | 6 +- app/scenes/Settings/Profile.js | 242 ++++++++--------- package.json | 3 +- yarn.lock | 257 +++++++++++++++++- 16 files changed, 636 insertions(+), 264 deletions(-) create mode 100644 app/hooks/useMenuHeight.js diff --git a/app/components/ContextMenu/MenuItem.js b/app/components/ContextMenu/MenuItem.js index 357c3e68..48a67bfe 100644 --- a/app/components/ContextMenu/MenuItem.js +++ b/app/components/ContextMenu/MenuItem.js @@ -2,7 +2,7 @@ import { CheckmarkIcon } from "outline-icons"; import * as React from "react"; import { MenuItem as BaseMenuItem } from "reakit/Menu"; -import styled from "styled-components"; +import styled, { css } from "styled-components"; import breakpoint from "styled-components-breakpoint"; import MenuIconWrapper from "../MenuIconWrapper"; @@ -88,12 +88,12 @@ const Spacer = styled.svg` flex-shrink: 0; `; -export const MenuAnchor = styled.a` +export const MenuAnchorCSS = css` display: flex; margin: 0; border: 0; padding: 12px; - padding-left: ${(props) => 12 + props.level * 10}px; + padding-left: ${(props) => 12 + (props.level || 0) * 10}px; width: 100%; min-height: 32px; background: none; @@ -138,5 +138,8 @@ export const MenuAnchor = styled.a` font-size: 14px; `}; `; +export const MenuAnchor = styled.a` + ${MenuAnchorCSS} +`; export default MenuItem; diff --git a/app/components/ContextMenu/index.js b/app/components/ContextMenu/index.js index c215051d..cb6cae86 100644 --- a/app/components/ContextMenu/index.js +++ b/app/components/ContextMenu/index.js @@ -4,9 +4,8 @@ import { Portal } from "react-portal"; import { Menu } from "reakit/Menu"; import styled from "styled-components"; import breakpoint from "styled-components-breakpoint"; -import useMobile from "hooks/useMobile"; +import useMenuHeight from "hooks/useMenuHeight"; import usePrevious from "hooks/usePrevious"; -import useWindowSize from "hooks/useWindowSize"; import { fadeIn, fadeAndSlideUp, @@ -35,9 +34,7 @@ export default function ContextMenu({ ...rest }: Props) { const previousVisible = usePrevious(rest.visible); - const [maxHeight, setMaxHeight] = React.useState(undefined); - const isMobile = useMobile(); - const { height: windowHeight } = useWindowSize(); + const maxHeight = useMenuHeight(rest.visible, rest.unstable_disclosureRef); const backgroundRef = React.useRef(); React.useEffect(() => { @@ -55,21 +52,6 @@ export default function ContextMenu({ // sets the menu height based on the available space between the disclosure/ // trigger and the bottom of the window - React.useLayoutEffect(() => { - const padding = 8; - - if (rest.visible && !isMobile) { - setMaxHeight( - rest.unstable_disclosureRef?.current - ? windowHeight - - rest.unstable_disclosureRef.current.getBoundingClientRect() - .bottom - - padding - : undefined - ); - } - }, [rest.visible, rest.unstable_disclosureRef, windowHeight, isMobile]); - return ( <> @@ -104,7 +86,7 @@ export default function ContextMenu({ ); } -const Backdrop = styled.div` +export const Backdrop = styled.div` animation: ${fadeIn} 200ms ease-in-out; position: fixed; top: 0; @@ -119,7 +101,7 @@ const Backdrop = styled.div` `}; `; -const Position = styled.div` +export const Position = styled.div` position: absolute; z-index: ${(props) => props.theme.depths.menu}; @@ -133,7 +115,7 @@ const Position = styled.div` `}; `; -const Background = styled.div` +export const Background = styled.div` animation: ${mobileContextMenu} 200ms ease; transform-origin: 50% 100%; max-width: 100%; @@ -154,8 +136,7 @@ const Background = styled.div` ${breakpoint("tablet")` animation: ${(props) => props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease; - transform-origin: ${(props) => - props.rightAnchor === "bottom-end" ? "75%" : "25%"} 0; + transform-origin: ${(props) => (props.rightAnchor ? "75%" : "25%")} 0; max-width: 276px; background: ${(props) => props.theme.menuBackground}; box-shadow: ${(props) => props.theme.menuShadow}; diff --git a/app/components/InputSelect.js b/app/components/InputSelect.js index a673f3c8..848cbb81 100644 --- a/app/components/InputSelect.js +++ b/app/components/InputSelect.js @@ -1,80 +1,91 @@ // @flow -import { observable } from "mobx"; -import { observer } from "mobx-react"; +import { + Select, + SelectOption, + useSelectState, + useSelectPopover, + SelectPopover, +} from "@renderlesskit/react"; +import { CheckmarkIcon } from "outline-icons"; import * as React from "react"; import { VisuallyHidden } from "reakit/VisuallyHidden"; -import styled from "styled-components"; -import breakpoint from "styled-components-breakpoint"; -import { Outline, LabelText } from "./Input"; - -const Select = styled.select` - border: 0; - flex: 1; - padding: 4px 0; - margin: 0 12px; - outline: none; - background: none; - color: ${(props) => props.theme.text}; - height: 30px; - font-size: 14px; - - option { - background: ${(props) => props.theme.buttonNeutralBackground}; - } - - &:disabled, - &::placeholder { - color: ${(props) => props.theme.placeholder}; - } - - ${breakpoint("mobile", "tablet")` - font-size: 16px; - `}; -`; - -const Wrapper = styled.label` - display: block; - max-width: ${(props) => (props.short ? "350px" : "100%")}; -`; +import styled, { css } from "styled-components"; +import Button, { Inner } from "components/Button"; +import { Position, Background, Backdrop } from "./ContextMenu"; +import { MenuAnchorCSS } from "./ContextMenu/MenuItem"; +import { LabelText } from "./Input"; +import useMenuHeight from "hooks/useMenuHeight"; export type Option = { label: string, value: string }; export type Props = { value?: string, label?: string, + nude?: boolean, + ariaLabel: string, short?: boolean, className?: string, labelHidden?: boolean, options: Option[], - onBlur?: () => void, - onFocus?: () => void, + onChange: (string) => Promise | void, }; -@observer -class InputSelect extends React.Component { - @observable focused: boolean = false; +const getOptionFromValue = (options: Option[], value) => { + return options.find((option) => option.value === value) || {}; +}; - handleBlur = () => { - this.focused = false; - }; +const InputSelect = (props: Props) => { + const { + value, + label, + className, + labelHidden, + options, + short, + ariaLabel, + onChange, + nude, + } = props; - handleFocus = () => { - this.focused = true; - }; + const select = useSelectState({ + gutter: 0, + modal: true, + selectedValue: value, + }); - render() { - const { - label, - className, - labelHidden, - options, - short, - ...rest - } = this.props; + const popOver = useSelectPopover({ + ...select, + hideOnClickOutside: true, + preventBodyScroll: true, + }); - const wrappedLabel = {label}; + const previousValue = React.useRef(value); + const buttonRef = React.useRef(); + const minWidth = buttonRef.current?.offsetWidth || 0; - return ( + const maxHeight = useMenuHeight( + select.visible, + select.unstable_disclosureRef + ); + + React.useEffect(() => { + if (previousValue.current === select.selectedValue) return; + + previousValue.current = select.selectedValue; + async function load() { + await onChange(select.selectedValue); + } + load(); + }, [onChange, select.selectedValue]); + + const wrappedLabel = {label}; + + const selectedValueIndex = options.findIndex( + (option) => option.value === select.selectedValue + ); + + return ( + <> {label && (labelHidden ? ( @@ -82,18 +93,126 @@ class InputSelect extends React.Component { ) : ( wrappedLabel ))} - - - + + + {(props) => { + const topAnchor = props.style.top === "0"; + const rightAnchor = props.placement === "bottom-end"; + + // offset top of select to place selected item under the cursor + if (selectedValueIndex !== -1) { + props.style.top = `-${(selectedValueIndex + 1) * 32}px`; + } + + return ( + + + {select.visible || select.animating + ? options.map((option) => ( + + {select.selectedValue !== undefined && ( + <> + {select.selectedValue === option.value ? ( + + ) : ( + + )} +   + + )} + {option.label} + + )) + : null} + + + ); + }} + - ); + {(select.visible || select.animating) && } + + ); +}; + +const Spacer = styled.svg` + width: 24px; + height: 24px; + flex-shrink: 0; +`; + +const StyledButton = styled(Button)` + font-weight: normal; + text-transform: none; + margin-bottom: 16px; + display: block; + width: 100%; + + ${(props) => + props.nude && + css` + border-color: transparent; + box-shadow: none; + `} + + ${Inner} { + line-height: 28px; + padding-left: 16px; + padding-right: 8px; + justify-content: space-between; } -} +`; + +export const StyledSelectOption = styled(SelectOption)` + ${MenuAnchorCSS} +`; + +const Wrapper = styled.label` + display: block; + max-width: ${(props) => (props.short ? "350px" : "100%")}; +`; + +const Positioner = styled(Position)` + &.focus-visible { + ${StyledSelectOption} { + &[aria-selected="true"] { + color: ${(props) => props.theme.white}; + background: ${(props) => props.theme.primary}; + box-shadow: none; + cursor: pointer; + + svg { + fill: ${(props) => props.theme.white}; + } + } + } + } +`; export default InputSelect; diff --git a/app/components/InputSelectPermission.js b/app/components/InputSelectPermission.js index f462408b..c03d5f23 100644 --- a/app/components/InputSelectPermission.js +++ b/app/components/InputSelectPermission.js @@ -4,19 +4,33 @@ import { useTranslation } from "react-i18next"; import InputSelect, { type Props, type Option } from "./InputSelect"; export default function InputSelectPermission( - props: $Rest }> + props: $Rest<$Exact, {| options: Array