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
291 lines
7.5 KiB
TypeScript
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 />
|
|
)}
|
|
|
|
</>
|
|
)}
|
|
{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;
|