Files
twenty/packages/twenty-website/src/ui/Select.tsx
T
Abdullah. 569d887d1e [Website] Cut over to the rebuilt site (#21825)
Renaming the package so any further PRs directed to the website are
targeted to the reworked code instead of diverging. Once merged, I will
start preparing this for deployment to dev to test before releasing to
prod. Any improvements will also be applied to this package.

I avoided making significant changes to API routes so nothing breaks,
but will test it thoroughly today to confirm. That said, everything is
ported - double checked.

Big diff PR, impossible to review, but last one! No more rebuilds.
2026-06-19 10:22:46 +02:00

371 lines
9.4 KiB
TypeScript

'use client';
import { styled } from '@linaria/react';
import {
type CSSProperties,
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import { ChevronDown, Search } from '@/icons';
import {
buildSchemeDeclarations,
color,
FONT_WEIGHT,
fontFamily,
fontSize,
radius,
type Scheme,
semanticColor,
SHADOW,
spacing,
Z_INDEX,
} from '@/tokens';
export type SelectOption<TValue extends string> = {
label: string;
value: TValue;
};
const Root = styled.div`
position: relative;
width: 100%;
`;
const Trigger = styled.button`
align-items: center;
background: none;
border: 1px solid ${semanticColor.lineStrong};
border-radius: ${radius(2)};
color: ${semanticColor.ink};
cursor: pointer;
display: flex;
font-family: ${fontFamily('sans')};
font-size: ${fontSize(4)};
font-weight: ${FONT_WEIGHT.regular};
gap: ${spacing(2)};
height: clamp(40px, 5.5vh, 56px);
justify-content: space-between;
padding: 0 ${spacing(2)} 0 ${spacing(3)};
width: 100%;
&[data-invalid] {
border-color: ${color('error')};
}
&:focus-visible {
border-color: ${color('blue')};
outline: none;
}
`;
const Value = styled.span`
flex: 1 1 auto;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
&[data-empty] {
color: ${semanticColor.inkSubtle};
}
`;
const Chevron = styled.span`
align-items: center;
color: ${semanticColor.inkMuted};
display: flex;
flex: none;
`;
// Portaled to <body>, so it re-declares the scheme from the scheme prop (the
// portal escapes the consumer's scheme vars — NotchedCardShape precedent).
const Popup = styled.div`
background: ${semanticColor.surface};
border: 1px solid ${color('blue')};
border-radius: ${radius(2)};
box-shadow: ${SHADOW.popupDark};
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
position: fixed;
z-index: ${Z_INDEX.portal};
&[data-scheme='light'] {
${buildSchemeDeclarations('light')}
}
&[data-scheme='muted'] {
${buildSchemeDeclarations('muted')}
}
&[data-scheme='dark'] {
${buildSchemeDeclarations('dark')}
}
`;
const SearchRow = styled.div`
align-items: center;
border-bottom: 1px solid ${semanticColor.lineStrong};
color: ${semanticColor.inkMuted};
display: flex;
flex-shrink: 0;
gap: ${spacing(2)};
padding: ${spacing(2)} ${spacing(3)};
`;
const SearchControl = styled.input`
background: none;
border: none;
color: ${semanticColor.ink};
flex: 1 1 auto;
font-family: ${fontFamily('sans')};
font-size: ${fontSize(4)};
&::placeholder {
color: ${semanticColor.inkSubtle};
}
&:focus {
outline: none;
}
`;
const OptionList = styled.div`
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
padding: ${spacing(1)};
`;
const Option = styled.button`
background: none;
border: none;
border-radius: ${radius(1)};
color: ${semanticColor.ink};
cursor: pointer;
font-family: ${fontFamily('sans')};
font-size: ${fontSize(4)};
padding: ${spacing(2)} ${spacing(3)};
text-align: left;
width: 100%;
&[data-selected] {
background: ${color('blue-40')};
}
&:hover {
background: ${color('blue-20')};
}
&:focus-visible {
outline: 2px solid ${color('blue')};
outline-offset: -2px;
}
`;
const EmptyState = styled.span`
color: ${semanticColor.inkSubtle};
font-family: ${fontFamily('sans')};
font-size: ${fontSize(4)};
padding: ${spacing(3)};
`;
type PopupPosition = {
left: number;
width: number;
maxHeight: number;
top?: number;
bottom?: number;
};
const POPUP_GAP_PX = 4;
const VIEWPORT_MARGIN_PX = 8;
const POPUP_MAX_HEIGHT_PX = 320;
function labelMatches(label: string, query: string): boolean {
if (query === '') return true;
return label.toLowerCase().includes(query.toLowerCase());
}
// A single-select dropdown: a button trigger + a portaled popup. `searchable`
// adds an in-popup filter row. The consumer works in plain value strings.
export function Select<TValue extends string>({
ariaLabel,
emptyLabel,
invalid = false,
onValueChange,
options,
placeholder,
scheme = 'light',
searchable = false,
searchPlaceholder,
value,
}: {
ariaLabel: string;
emptyLabel?: string;
invalid?: boolean;
onValueChange: (value: TValue) => void;
options: readonly SelectOption<TValue>[];
placeholder: string;
scheme?: Scheme;
searchable?: boolean;
searchPlaceholder?: string;
value: TValue | '';
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [position, setPosition] = useState<PopupPosition | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const popupRef = useRef<HTMLDivElement>(null);
const updatePosition = useCallback(() => {
const anchor = rootRef.current;
if (anchor === null) return;
const rect = anchor.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom - VIEWPORT_MARGIN_PX;
const spaceAbove = rect.top - VIEWPORT_MARGIN_PX;
const openUp =
spaceBelow < Math.min(POPUP_MAX_HEIGHT_PX, 220) &&
spaceAbove > spaceBelow;
const available = Math.max(0, openUp ? spaceAbove : spaceBelow);
setPosition({
left: rect.left,
width: rect.width,
maxHeight: Math.min(POPUP_MAX_HEIGHT_PX, available),
...(openUp
? { bottom: window.innerHeight - rect.top + POPUP_GAP_PX }
: { top: rect.bottom + POPUP_GAP_PX }),
});
}, []);
useEffect(() => {
if (!open) {
setQuery('');
setPosition(null);
return;
}
updatePosition();
const reposition = () => updatePosition();
window.addEventListener('scroll', reposition, true);
window.addEventListener('resize', reposition);
const handlePointer = (event: MouseEvent) => {
const target = event.target as Node;
if (rootRef.current?.contains(target) === true) return;
if (popupRef.current?.contains(target) === true) return;
setOpen(false);
};
const handleKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', handlePointer);
document.addEventListener('keydown', handleKey);
return () => {
window.removeEventListener('scroll', reposition, true);
window.removeEventListener('resize', reposition);
document.removeEventListener('mousedown', handlePointer);
document.removeEventListener('keydown', handleKey);
};
}, [open, updatePosition]);
const selectedOption = options.find((option) => option.value === value);
const visibleOptions = searchable
? options.filter((option) => labelMatches(option.label, query))
: options;
const popupStyle: CSSProperties | undefined =
position === null
? undefined
: {
left: position.left,
width: position.width,
maxHeight: position.maxHeight,
...(position.top !== undefined
? { top: position.top }
: { bottom: position.bottom }),
};
const triggerLabel: ReactNode =
value === '' || selectedOption === undefined
? placeholder
: selectedOption.label;
return (
<Root ref={rootRef}>
<Trigger
aria-expanded={open}
aria-haspopup="listbox"
aria-label={ariaLabel}
data-invalid={invalid ? '' : undefined}
onClick={() => setOpen((previous) => !previous)}
type="button"
>
<Value
data-empty={
value === '' || selectedOption === undefined ? '' : undefined
}
>
{triggerLabel}
</Value>
<Chevron aria-hidden>
<ChevronDown sizePx={18} />
</Chevron>
</Trigger>
{open && popupStyle !== undefined && typeof document !== 'undefined'
? createPortal(
<Popup
aria-label={ariaLabel}
data-scheme={scheme}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
ref={popupRef}
role="listbox"
style={popupStyle}
>
{searchable ? (
<SearchRow>
<Search sizePx={16} />
<SearchControl
autoFocus
onChange={(event) => setQuery(event.target.value)}
placeholder={searchPlaceholder}
type="text"
value={query}
/>
</SearchRow>
) : null}
<OptionList>
{visibleOptions.length === 0 ? (
<EmptyState>{emptyLabel ?? '—'}</EmptyState>
) : (
visibleOptions.map((option) => (
<Option
aria-selected={value === option.value}
data-selected={value === option.value ? '' : undefined}
key={option.value}
onClick={() => {
onValueChange(option.value);
setOpen(false);
}}
role="option"
type="button"
>
{option.label}
</Option>
))
)}
</OptionList>
</Popup>,
document.body,
)
: null}
</Root>
);
}