Files
outline/app/components/InputSelect.tsx
Tom Moor 15b1069bcc chore: Move to Typescript (#2783)
This PR moves the entire project to Typescript. Due to the ~1000 ignores this will lead to a messy codebase for a while, but the churn is worth it – all of those ignore comments are places that were never type-safe previously.

closes #1282
2021-11-29 06:40:55 -08:00

291 lines
7.5 KiB
TypeScript

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 scrollIntoView from "smooth-scroll-into-view-if-needed";
import styled, { css } from "styled-components";
import Button, { Inner } from "~/components/Button";
import HelpText from "~/components/HelpText";
import useMenuHeight from "~/hooks/useMenuHeight";
import { Position, Background, Backdrop, Placement } from "./ContextMenu";
import { MenuAnchorCSS } from "./ContextMenu/MenuItem";
import { LabelText } from "./Input";
export type Option = {
label: string;
value: string;
};
export type Props = {
value?: string;
label?: string;
nude?: boolean;
ariaLabel: string;
short?: boolean;
disabled?: boolean;
className?: string;
labelHidden?: boolean;
icon?: React.ReactNode;
options: Option[];
note?: React.ReactNode;
onChange: (value: string | null) => void;
};
const getOptionFromValue = (
options: Option[],
value: string | undefined | null
) => {
return options.find((option) => option.value === value);
};
const InputSelect = (props: Props) => {
const {
value,
label,
className,
labelHidden,
options,
short,
ariaLabel,
onChange,
disabled,
note,
icon,
} = props;
const select = useSelectState({
gutter: 0,
modal: true,
selectedValue: value,
animated: 200,
});
const popOver = useSelectPopover({
...select,
hideOnClickOutside: true,
preventBodyScroll: true,
disabled,
});
const previousValue = React.useRef<string | undefined | null>(value);
const contentRef = React.useRef<HTMLDivElement>(null);
const selectedRef = React.useRef<HTMLDivElement>(null);
const buttonRef = React.useRef<HTMLButtonElement>(null);
const [offset, setOffset] = React.useState(0);
const minWidth = buttonRef.current?.offsetWidth || 0;
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 = <LabelText>{label}</LabelText>;
const selectedValueIndex = options.findIndex(
(option) => option.value === select.selectedValue
);
// Ensure selected option is visible when opening the input
React.useEffect(() => {
if (!select.animating && selectedRef.current) {
scrollIntoView(selectedRef.current, {
scrollMode: "if-needed",
behavior: "auto",
block: "start",
});
}
}, [select.animating]);
React.useLayoutEffect(() => {
if (select.visible) {
const offset = Math.round(
(selectedRef.current?.getBoundingClientRect().top || 0) -
(contentRef.current?.getBoundingClientRect().top || 0)
);
setOffset(offset);
}
}, [select.visible]);
return (
<>
<Wrapper short={short}>
{label &&
(labelHidden ? (
<VisuallyHidden>{wrappedLabel}</VisuallyHidden>
) : (
wrappedLabel
))}
<Select {...select} disabled={disabled} ref={buttonRef}>
{(props) => (
<StyledButton
neutral
disclosure
className={className}
icon={icon}
{...props}
>
{getOptionFromValue(options, select.selectedValue)?.label || (
<Placeholder>Select a {ariaLabel.toLowerCase()}</Placeholder>
)}
</StyledButton>
)}
</Select>
<SelectPopover {...select} {...popOver} aria-label={ariaLabel}>
{(
props: React.HTMLAttributes<HTMLDivElement> & {
placement: Placement;
}
) => {
if (!props.style) {
props.style = {};
}
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 = `-${offset + 32}px`;
}
return (
<Positioner {...props}>
<Background
dir="auto"
ref={contentRef}
topAnchor={topAnchor}
rightAnchor={rightAnchor}
style={
maxHeight && topAnchor
? {
maxHeight,
minWidth,
}
: {
minWidth,
}
}
>
{select.visible || select.animating
? options.map((option) => (
<StyledSelectOption
{...select}
value={option.value}
key={option.value}
$animating={select.animating}
ref={
select.selectedValue === option.value
? selectedRef
: undefined
}
>
{select.selectedValue !== undefined && (
<>
{select.selectedValue === option.value ? (
<CheckmarkIcon color="currentColor" />
) : (
<Spacer />
)}
&nbsp;
</>
)}
{option.label}
</StyledSelectOption>
))
: null}
</Background>
</Positioner>
);
}}
</SelectPopover>
</Wrapper>
{note && <HelpText small>{note}</HelpText>}
{(select.visible || select.animating) && <Backdrop />}
</>
);
};
const Placeholder = styled.span`
color: ${(props) => props.theme.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%;
${(props) =>
props.nude &&
css`
border-color: transparent;
box-shadow: none;
`}
${Inner} {
line-height: 28px;
padding-left: 16px;
padding-right: 8px;
}
svg {
justify-self: flex-end;
margin-left: auto;
}
`;
export const StyledSelectOption = styled(SelectOption)<{ $animating: boolean }>`
${MenuAnchorCSS}
${(props) =>
props.$animating &&
css`
pointer-events: none;
`}
`;
const Wrapper = styled.label<{ short?: boolean }>`
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;