addons/components/ButtonDropdown.tsxblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import type {ReactNode} from 'react';
b69ab319
b69ab3110import * as stylex from '@stylexjs/stylex';
b69ab3111import {Button, buttonStyles} from './Button';
b69ab3112import {Icon} from './Icon';
b69ab3113import {colors, spacing} from './theme/tokens.stylex';
b69ab3114import {Tooltip, type TooltipProps} from './Tooltip';
b69ab3115
b69ab3116export const styles = stylex.create({
b69ab3117 container: {
b69ab3118 display: 'flex',
b69ab3119 alignItems: 'stretch',
b69ab3120 position: 'relative',
b69ab3121 '::before': {
b69ab3122 content: '',
b69ab3123 position: 'absolute',
b69ab3124 width: '100%',
b69ab3125 height: '100%',
b69ab3126 top: 0,
b69ab3127 left: 0,
b69ab3128 pointerEvents: 'none',
b69ab3129 },
b69ab3130 },
b69ab3131 button: {
b69ab3132 borderBottomRightRadius: 0,
b69ab3133 borderTopRightRadius: 0,
b69ab3134 },
b69ab3135 chevron: {
b69ab3136 color: 'var(--button-secondary-foreground)',
b69ab3137 position: 'absolute',
b69ab3138 top: 'calc(50% - 0.5em)',
b69ab3139 right: 'calc(var(--halfpad) - 1px)',
b69ab3140 pointerEvents: 'none',
b69ab3141 },
b69ab3142 select: {
b69ab3143 backgroundColor: {
b69ab3144 default: 'var(--button-secondary-background)',
b69ab3145 ':hover': 'var(--button-secondary-hover-background)',
b69ab3146 },
b69ab3147 color: 'var(--button-secondary-foreground)',
b69ab3148 opacity: {
b69ab3149 default: 1,
b69ab3150 ':disabled': 0.5,
b69ab3151 },
b69ab3152 cursor: {
b69ab3153 default: 'pointer',
b69ab3154 ':disabled': 'not-allowed',
b69ab3155 },
b69ab3156 width: '24px',
b69ab3157 borderRadius: '0px 2px 2px 0px',
b69ab3158 outline: {
b69ab3159 default: 'none',
b69ab3160 ':focus': '1px solid var(--focus-border)',
b69ab3161 },
b69ab3162 outlineOffset: '2px',
b69ab3163 verticalAlign: 'bottom',
b69ab3164 appearance: 'none',
b69ab3165 lineHeight: '0',
b69ab3166 border: '1px solid var(--button-border)',
b69ab3167 borderLeft: '1px solid var(--button-secondary-foreground)',
b69ab3168 },
b69ab3169 builtinButtonBorder: {
b69ab3170 borderLeft: 'unset',
b69ab3171 },
b69ab3172 iconButton: {
b69ab3173 borderTopRightRadius: 0,
b69ab3174 borderBottomRightRadius: 0,
b69ab3175 paddingRight: spacing.half,
b69ab3176 },
b69ab3177 iconSelect: {
b69ab3178 borderTopLeftRadius: 0,
b69ab3179 borderBottomLeftRadius: 0,
b69ab3180 borderLeftColor: colors.hoverDarken,
b69ab3181 },
b69ab3182 iconChevron: {
b69ab3183 color: 'var(--button-icon-foreground)',
b69ab3184 },
b69ab3185 chevronDisabled: {
b69ab3186 opacity: 0.5,
b69ab3187 },
b69ab3188});
b69ab3189
b69ab3190export function ButtonDropdown<T extends {label: ReactNode; id: string}>({
b69ab3191 options,
b69ab3192 kind,
b69ab3193 onClick,
b69ab3194 selected,
b69ab3195 onChangeSelected,
b69ab3196 buttonDisabled,
b69ab3197 pickerDisabled,
b69ab3198 icon,
b69ab3199 customSelectComponent,
b69ab31100 primaryTooltip,
b69ab31101 ...rest
b69ab31102}: {
b69ab31103 options: ReadonlyArray<T>;
b69ab31104 kind?: 'primary' | 'icon' | undefined;
b69ab31105 onClick: (selected: T, event: React.MouseEvent<HTMLButtonElement>) => unknown;
b69ab31106 selected: T;
b69ab31107 onChangeSelected: (newSelected: T) => unknown;
b69ab31108 buttonDisabled?: boolean;
b69ab31109 pickerDisabled?: boolean;
b69ab31110 /** Icon to place in the button */
b69ab31111 icon?: React.ReactNode;
b69ab31112 customSelectComponent?: React.ReactNode;
b69ab31113 primaryTooltip?: TooltipProps;
b69ab31114 'data-testId'?: string;
b69ab31115}) {
b69ab31116 const selectedOption = options.find(opt => opt.id === selected.id) ?? options[0];
b69ab31117 // const themeName = useAtomValue(themeNameState); // TODO
b69ab31118 // // Slightly hacky: in these themes, the border is too strong. Use the button border instead.
b69ab31119 // const useBuiltinBorder = ['Default Light Modern', 'Default Dark Modern'].includes(
b69ab31120 // themeName as string,
b69ab31121 // );
b69ab31122
b69ab31123 const buttonComponent = (
b69ab31124 <Button
b69ab31125 kind={kind}
b69ab31126 onClick={buttonDisabled ? undefined : e => onClick(selected, e)}
b69ab31127 disabled={buttonDisabled}
b69ab31128 xstyle={[styles.button, kind === 'icon' && styles.iconButton]}
b69ab31129 {...rest}>
b69ab31130 {icon ?? null} {selected.label}
b69ab31131 </Button>
b69ab31132 );
b69ab31133
b69ab31134 return (
b69ab31135 <div {...stylex.props(styles.container)}>
b69ab31136 {primaryTooltip ? <Tooltip {...primaryTooltip}>{buttonComponent}</Tooltip> : buttonComponent}
b69ab31137 {customSelectComponent ?? (
b69ab31138 <select
b69ab31139 {...stylex.props(
b69ab31140 styles.select,
b69ab31141 kind === 'icon' && buttonStyles.icon,
b69ab31142 kind === 'icon' && styles.iconSelect,
b69ab31143 // useBuiltinBorder && styles.builtinButtonBorder,
b69ab31144 )}
b69ab31145 disabled={pickerDisabled}
b69ab31146 value={selectedOption.id}
b69ab31147 onClick={e => e.stopPropagation()}
b69ab31148 onChange={event => {
b69ab31149 const matching = options.find(opt => opt.id === (event.target.value as T['id']));
b69ab31150 if (matching != null) {
b69ab31151 onChangeSelected(matching);
b69ab31152 }
b69ab31153 }}>
b69ab31154 {options.map(option => (
b69ab31155 <option key={option.id} value={option.id}>
b69ab31156 {option.label}
b69ab31157 </option>
b69ab31158 ))}
b69ab31159 </select>
b69ab31160 )}
b69ab31161 <Icon
b69ab31162 icon="chevron-down"
b69ab31163 {...stylex.props(
b69ab31164 styles.chevron,
b69ab31165 kind === 'icon' && styles.iconChevron,
b69ab31166 pickerDisabled && styles.chevronDisabled,
b69ab31167 )}
b69ab31168 />
b69ab31169 </div>
b69ab31170 );
b69ab31171}