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, { css } from "styled-components"; import { s } from "@shared/styles"; import Button, { Inner } from "~/components/Button"; import Text from "~/components/Text"; import useMenuHeight from "~/hooks/useMenuHeight"; import useMobile from "~/hooks/useMobile"; import useOnClickOutside from "~/hooks/useOnClickOutside"; import { fadeAndScaleIn } from "~/styles/animations"; import { Position, Background as ContextMenuBackground, Backdrop, Placement, } from "./ContextMenu"; import { MenuAnchorCSS } from "./ContextMenu/MenuItem"; import Separator from "./ContextMenu/Separator"; import { LabelText } from "./Input"; export type Option = { label: string | JSX.Element; value: string; description?: string; divider?: boolean; }; export type Props = { id?: string; name?: string; value?: string | null; label?: React.ReactNode; nude?: boolean; ariaLabel: string; short?: boolean; disabled?: boolean; className?: string; labelHidden?: boolean; icon?: React.ReactNode; options: Option[]; /** @deprecated Removing soon, do not use. */ note?: React.ReactNode; onChange?: (value: string | null) => void; style?: React.CSSProperties; /** * Set to true if this component is rendered inside a Modal. * The Modal will take care of preventing body scroll behaviour. */ skipBodyScroll?: boolean; }; export interface InputSelectRef { value: string | null; focus: () => void; blur: () => void; } interface InnerProps extends React.HTMLAttributes { placement: Placement; } const getOptionFromValue = (options: Option[], value: string | null) => options.find((option) => option.value === value); const InputSelect = (props: Props, ref: React.RefObject) => { const { value = null, label, className, labelHidden, options, short, ariaLabel, onChange, disabled, note, icon, nude, skipBodyScroll, ...rest } = props; const select = useSelectState({ gutter: 0, modal: true, selectedValue: value, }); const popover = useSelectPopover({ ...select, hideOnClickOutside: false, preventBodyScroll: skipBodyScroll ? false : true, disabled, }); const isMobile = useMobile(); const previousValue = React.useRef(value); const selectedRef = React.useRef(null); const buttonRef = React.useRef(null); const contentRef = React.useRef(null); const minWidth = buttonRef.current?.offsetWidth || 0; const margin = 8; const menuMaxHeight = useMenuHeight({ visible: select.visible, elementRef: select.unstable_disclosureRef, margin, }); const maxHeight = Math.min( menuMaxHeight ?? 0, window.innerHeight - (buttonRef.current?.getBoundingClientRect().bottom ?? 0) - margin ); const wrappedLabel = {label}; const selectedValueIndex = options.findIndex( (opt) => opt.value === select.selectedValue ); // Custom click outside handling rather than using `hideOnClickOutside` from reakit so that we can // prevent event bubbling. useOnClickOutside( contentRef, (event) => { if (buttonRef.current?.contains(event.target as Node)) { return; } if (select.visible) { event.stopPropagation(); event.preventDefault(); select.hide(); } }, { capture: true } ); React.useImperativeHandle(ref, () => ({ focus: () => { buttonRef.current?.focus(); }, blur: () => { buttonRef.current?.blur(); }, value: select.selectedValue, })); React.useEffect(() => { previousValue.current = value; // Update the selected value if it changes from the outside – both of these lines are needed // for correct functioning select.selectedValue = value; select.setSelectedValue(value); }, [value]); React.useEffect(() => { if (previousValue.current === select.selectedValue) { return; } previousValue.current = select.selectedValue; onChange?.(select.selectedValue); }, [onChange, select.selectedValue]); React.useLayoutEffect(() => { if (select.visible) { requestAnimationFrame(() => { if (contentRef.current) { contentRef.current.scrollTop = selectedValueIndex * 32; } }); } }, [select.visible, selectedValueIndex]); function labelForOption(opt: Option) { return ( <> {opt.label} {opt.description && ( <>   – {opt.description} )} ); } const option = getOptionFromValue(options, select.selectedValue); return ( <> {label && (labelHidden ? ( {wrappedLabel} ) : ( wrappedLabel ))} {(popoverProps: InnerProps) => { const topAnchor = popoverProps.style?.top === "0"; const rightAnchor = popoverProps.placement === "bottom-end"; return ( {select.visible ? options.map((opt) => { const isSelected = select.selectedValue === opt.value; const Icon = isSelected ? CheckmarkIcon : Spacer; return ( {opt.divider && }   {labelForOption(opt)} ); }) : null} ); }} {note && ( {note} )} {select.visible && isMobile && } ); }; const Background = styled(ContextMenuBackground)` animation: ${fadeAndScaleIn} 200ms ease; `; const Placeholder = styled.span` color: ${s("placeholder")}; `; const Spacer = styled.div` width: 24px; height: 24px; flex-shrink: 0; `; const StyledButton = styled(Button)<{ $nude?: boolean }>` font-weight: normal; text-transform: none; margin-bottom: 16px; display: block; width: 100%; cursor: default; &:hover:not(:disabled) { background: ${s("buttonNeutralBackground")}; } ${(props) => props.$nude && css` border-color: transparent; box-shadow: none; `} ${Inner} { line-height: 28px; padding-left: 12px; padding-right: 4px; } svg { justify-self: flex-end; margin-left: auto; } `; export const StyledSelectOption = styled(SelectOption)` ${MenuAnchorCSS} /* overriding the styles from MenuAnchorCSS because we use   here */ svg:not(:last-child) { margin-right: 0px; } `; const Wrapper = styled.label<{ short?: boolean }>` display: block; max-width: ${(props) => (props.short ? "350px" : "100%")}; `; export const Positioner = styled(Position)` &.focus-visible { ${StyledSelectOption} { &[aria-selected="true"] { color: ${(props) => props.theme.white}; background: ${s("accent")}; box-shadow: none; cursor: var(--pointer); svg { fill: ${(props) => props.theme.white}; } } } } `; export default React.forwardRef(InputSelect);