Files
outline/app/components/InputSelect.tsx
2024-05-16 16:45:09 -07:00

361 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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?: string;
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;
};
export interface InputSelectRef {
value: string | null;
focus: () => void;
blur: () => void;
}
interface InnerProps extends React.HTMLAttributes<HTMLDivElement> {
placement: Placement;
}
const getOptionFromValue = (options: Option[], value: string | null) =>
options.find((option) => option.value === value);
const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
const {
value = null,
label,
className,
labelHidden,
options,
short,
ariaLabel,
onChange,
disabled,
note,
icon,
nude,
...rest
} = props;
const select = useSelectState({
gutter: 0,
modal: true,
selectedValue: value,
});
const popover = useSelectPopover({
...select,
hideOnClickOutside: false,
preventBodyScroll: true,
disabled,
});
const isMobile = useMobile();
const previousValue = React.useRef<string | null>(value);
const selectedRef = React.useRef<HTMLDivElement>(null);
const buttonRef = React.useRef<HTMLButtonElement>(null);
const contentRef = React.useRef<HTMLDivElement>(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 = <LabelText>{label}</LabelText>;
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 && (
<>
&nbsp;
<Text as="span" type="tertiary" size="small" ellipsis>
{opt.description}
</Text>
</>
)}
</>
);
}
const option = getOptionFromValue(options, select.selectedValue);
return (
<>
<Wrapper short={short}>
{label &&
(labelHidden ? (
<VisuallyHidden>{wrappedLabel}</VisuallyHidden>
) : (
wrappedLabel
))}
<Select {...select} disabled={disabled} {...rest} ref={buttonRef}>
{(buttonProps) => (
<StyledButton
neutral
disclosure
className={className}
icon={icon}
$nude={nude}
{...buttonProps}
>
{option ? (
labelForOption(option)
) : (
<Placeholder>Select a {ariaLabel.toLowerCase()}</Placeholder>
)}
</StyledButton>
)}
</Select>
<SelectPopover {...select} {...popover} aria-label={ariaLabel}>
{(popoverProps: InnerProps) => {
const topAnchor = popoverProps.style?.top === "0";
const rightAnchor = popoverProps.placement === "bottom-end";
return (
<Positioner {...popoverProps}>
<Background
dir="auto"
ref={contentRef}
topAnchor={topAnchor}
rightAnchor={rightAnchor}
hiddenScrollbars
maxWidth={400}
style={
maxHeight && topAnchor
? {
maxHeight,
minWidth,
}
: {
minWidth,
}
}
>
{select.visible
? options.map((opt) => {
const isSelected = select.selectedValue === opt.value;
const Icon = isSelected ? CheckmarkIcon : Spacer;
return (
<>
{opt.divider && <Separator />}
<StyledSelectOption
{...select}
value={opt.value}
key={opt.value}
ref={isSelected ? selectedRef : undefined}
>
<Icon />
&nbsp;
{labelForOption(opt)}
</StyledSelectOption>
</>
);
})
: null}
</Background>
</Positioner>
);
}}
</SelectPopover>
</Wrapper>
{note && (
<Text as="p" type="secondary" size="small">
{note}
</Text>
)}
{select.visible && isMobile && <Backdrop />}
</>
);
};
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 &nbsp; 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);