Files
outline/app/components/primitives/InputSelect.tsx
T
Tom Moor eefa8d4222 Add year headings to compare version select (#12138)
* Add year headings to compare version select

* Address review feedback on heading options

Use stable keys for heading options and set explicit displayName.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 19:04:35 -04:00

174 lines
5.1 KiB
TypeScript

import * as InputSelectPrimitive from "@radix-ui/react-select";
import * as React from "react";
import styled from "styled-components";
import Text from "@shared/components/Text";
import { depths, s } from "@shared/styles";
import type { Props as ButtonProps } from "~/components/Button";
import { fadeAndSlideDown, fadeAndSlideUp } from "~/styles/animations";
import {
SelectItemIndicator,
SelectItem as SelectItemWrapper,
SelectButton,
} from "./components/InputSelect";
/** Root InputSelect component - all the other components are rendered inside it. */
const InputSelectRoot = InputSelectPrimitive.Root;
/** InputSelect's trigger. */
export type TriggerButtonProps = {
/** When true, "nude" variant of Button is rendered. */
nude?: boolean;
/** Optional css class names to pass to the trigger. */
className?: string;
} & Pick<ButtonProps<unknown>, "borderOnHover">;
type InputSelectTriggerProps = {
placeholder: string;
/** When provided, overrides the selected value rendered inside the trigger. */
displayValue?: React.ReactNode;
} & TriggerButtonProps &
React.ComponentPropsWithoutRef<typeof InputSelectPrimitive.Trigger>;
const InputSelectTrigger = React.forwardRef<
React.ElementRef<typeof InputSelectPrimitive.Trigger>,
InputSelectTriggerProps
>((props, ref) => {
const { placeholder, children, nude, displayValue, ...buttonProps } = props;
return (
<InputSelectPrimitive.Trigger ref={ref} asChild>
<SelectButton neutral disclosure $nude={nude} {...buttonProps}>
{displayValue !== undefined ? (
<>{displayValue}</>
) : (
<InputSelectPrimitive.Value placeholder={placeholder} />
)}
</SelectButton>
</InputSelectPrimitive.Trigger>
);
});
InputSelectTrigger.displayName = InputSelectPrimitive.Trigger.displayName;
/** InputSelect's content - renders the options in a scrollable element. */
type ContentProps = Omit<
React.ComponentPropsWithoutRef<typeof InputSelectPrimitive.Content>,
"position"
>;
const InputSelectContent = React.forwardRef<
React.ElementRef<typeof InputSelectPrimitive.Content>,
ContentProps
>((props, ref) => {
const { children, ...rest } = props;
return (
<InputSelectPrimitive.Portal>
<StyledContent ref={ref} position={"popper"} {...rest}>
<InputSelectPrimitive.Viewport style={{ overscrollBehavior: "none" }}>
{children}
</InputSelectPrimitive.Viewport>
</StyledContent>
</InputSelectPrimitive.Portal>
);
});
InputSelectContent.displayName = InputSelectPrimitive.Content.displayName;
/** Individual InputSelect option rendered in the menu. */
const InputSelectItem = React.forwardRef<
React.ElementRef<typeof InputSelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof InputSelectPrimitive.Item>
>((props, ref) => {
const { children, ...rest } = props;
return (
<InputSelectPrimitive.Item ref={ref} {...rest} asChild>
<SelectItemWrapper>
<InputSelectPrimitive.ItemText>
{children}
</InputSelectPrimitive.ItemText>
<InputSelectPrimitive.ItemIndicator asChild>
<SelectItemIndicator />
</InputSelectPrimitive.ItemIndicator>
</SelectItemWrapper>
</InputSelectPrimitive.Item>
);
});
InputSelectItem.displayName = InputSelectPrimitive.Item.displayName;
/** Horizontal separator rendered between the options. */
const InputSelectSeparator = React.forwardRef<
React.ElementRef<typeof InputSelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof InputSelectPrimitive.Separator>
>((props, ref) => (
<InputSelectPrimitive.Separator ref={ref} asChild>
<Separator {...props} />
</InputSelectPrimitive.Separator>
));
InputSelectSeparator.displayName = InputSelectPrimitive.Separator.displayName;
const Separator = styled.hr`
margin: 6px 0;
`;
/** Non-selectable heading rendered to group options in the menu. */
const InputSelectHeading = React.forwardRef<
HTMLSpanElement,
{ children?: React.ReactNode }
>(({ children }, ref) => (
<InputSelectPrimitive.Group>
<InputSelectPrimitive.Label asChild>
<Heading ref={ref}>{children}</Heading>
</InputSelectPrimitive.Label>
</InputSelectPrimitive.Group>
));
InputSelectHeading.displayName = "InputSelectHeading";
const Heading = styled(Text).attrs({
type: "tertiary",
size: "xsmall",
weight: "bold",
})`
display: block;
padding-block: 8px 4px;
padding-inline: 8px;
text-transform: uppercase;
letter-spacing: 0.04em;
`;
/** Styled components. */
const StyledContent = styled(InputSelectPrimitive.Content)`
z-index: ${depths.menu};
min-width: var(--radix-select-trigger-width);
max-width: 400px;
min-height: 44px;
max-height: 350px;
padding: 4px;
border-radius: 6px;
background: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
transform-origin: 50% 0;
&[data-side="bottom"] {
animation: ${fadeAndSlideDown} 200ms ease;
}
&[data-side="top"] {
animation: ${fadeAndSlideUp} 200ms ease;
}
@media print {
display: none;
}
`;
export {
InputSelectRoot,
InputSelectTrigger,
InputSelectContent,
InputSelectItem,
InputSelectSeparator,
InputSelectHeading,
};