feat(form): add ghost option variant

- Keep option buttons transparent by default and subtle on hover
- Use the ghost style for inactive auto-import filters so the active one stands out
- Keep OptionButton aligned with the existing button variant API
This commit is contained in:
Antti Kettunen 2026-05-23 22:06:37 +03:00
parent 5a227e3ace
commit 7bd6e0ba09
No known key found for this signature in database
GPG key ID: C6B2A3D250359BD7
4 changed files with 34 additions and 28 deletions

View file

@ -420,7 +420,21 @@
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
} }
&[data-variant='ghost'] {
border-color: transparent;
background: transparent;
color: rgba(255, 255, 255, 0.58);
}
&[data-variant='ghost']:hover:not(:disabled) {
transform: none;
border-color: rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.85);
}
&[data-selected='true'] { &[data-selected='true'] {
color: #fff;
border-color: rgba(var(--accent-light-rgb), 0.5); border-color: rgba(var(--accent-light-rgb), 0.5);
background: rgba(var(--accent-rgb), 0.18); background: rgba(var(--accent-rgb), 0.18);
box-shadow: 0 0 0 1px rgba(var(--accent-light-rgb), 0.08); box-shadow: 0 0 0 1px rgba(var(--accent-light-rgb), 0.08);
@ -598,7 +612,6 @@
} }
&[data-variant='ghost']:hover:not(:disabled) { &[data-variant='ghost']:hover:not(:disabled) {
transform: none;
border-color: rgba(255, 255, 255, 0.14); border-color: rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.07); background: rgba(255, 255, 255, 0.07);
color: #fff; color: #fff;
@ -610,7 +623,7 @@
} }
&[data-variant='ghost']:disabled { &[data-variant='ghost']:disabled {
opacity: 0.5; opacity: 0.55;
} }
} }

View file

@ -172,21 +172,22 @@ describe('form primitives', () => {
expect(screen.getByText('12')).toHaveAttribute('data-tone', 'warning'); expect(screen.getByText('12')).toHaveAttribute('data-tone', 'warning');
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Save' })).toHaveAttribute( expect(screen.getByRole('button', { name: 'Save' })).toHaveAttribute('data-variant', 'primary');
'data-variant',
'primary',
);
}); });
it('supports compact option button groups', () => { it('supports compact option button groups', () => {
const { container } = render( const { container } = render(
<OptionButtonGroup size="sm"> <OptionButtonGroup size="sm">
<OptionButton selected>All</OptionButton> <OptionButton selected>All</OptionButton>
<OptionButton>Pending</OptionButton> <OptionButton variant="ghost">Pending</OptionButton>
</OptionButtonGroup>, </OptionButtonGroup>,
); );
expect(container.querySelector('[data-size="sm"]')).toBeInTheDocument(); expect(container.querySelector('[data-size="sm"]')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Pending' })).toHaveAttribute(
'data-variant',
'ghost',
);
}); });
it('supports compact select sizing', () => { it('supports compact select sizing', () => {

View file

@ -89,12 +89,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
ref, ref,
) { ) {
return ( return (
<select <select ref={ref} className={clsx(styles.select, className)} data-size={size} {...props} />
ref={ref}
className={clsx(styles.select, className)}
data-size={size}
{...props}
/>
); );
}); });
@ -186,7 +181,10 @@ export const RangeInput = forwardRef<HTMLDivElement, RangeInputProps>(function R
<Slider.Control className={styles.rangeControl}> <Slider.Control className={styles.rangeControl}>
<Slider.Track className={styles.rangeTrack}> <Slider.Track className={styles.rangeTrack}>
<Slider.Indicator className={styles.rangeIndicator} /> <Slider.Indicator className={styles.rangeIndicator} />
<Slider.Thumb aria-label={typeof label === 'string' ? label : undefined} className={styles.rangeThumb} /> <Slider.Thumb
aria-label={typeof label === 'string' ? label : undefined}
className={styles.rangeThumb}
/>
</Slider.Track> </Slider.Track>
</Slider.Control> </Slider.Control>
</Slider.Root> </Slider.Root>
@ -249,11 +247,7 @@ export interface OptionButtonGroupProps {
size?: OptionButtonGroupSize; size?: OptionButtonGroupSize;
} }
export function OptionButtonGroup({ export function OptionButtonGroup({ className, children, size = 'md' }: OptionButtonGroupProps) {
className,
children,
size = 'md',
}: OptionButtonGroupProps) {
return ( return (
<div className={clsx(styles.optionButtonGroup, className)} data-size={size}> <div className={clsx(styles.optionButtonGroup, className)} data-size={size}>
{children} {children}
@ -261,14 +255,17 @@ export function OptionButtonGroup({
); );
} }
export type OptionButtonVariant = 'default' | 'ghost';
export interface OptionButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'value'> { export interface OptionButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'value'> {
className?: string; className?: string;
selected?: boolean; selected?: boolean;
variant?: OptionButtonVariant;
value?: string; value?: string;
} }
export const OptionButton = forwardRef<HTMLButtonElement, OptionButtonProps>(function OptionButton( export const OptionButton = forwardRef<HTMLButtonElement, OptionButtonProps>(function OptionButton(
{ className, children, selected = false, type = 'button', ...props }, { className, children, selected = false, type = 'button', variant = 'default', ...props },
ref, ref,
) { ) {
return ( return (
@ -277,6 +274,7 @@ export const OptionButton = forwardRef<HTMLButtonElement, OptionButtonProps>(fun
pressed={selected} pressed={selected}
className={clsx(styles.optionButton, className)} className={clsx(styles.optionButton, className)}
data-selected={selected ? 'true' : undefined} data-selected={selected ? 'true' : undefined}
data-variant={variant}
type={type} type={type}
{...props} {...props}
> >
@ -298,14 +296,7 @@ export const Badge = forwardRef<HTMLSpanElement, BadgeProps>(function Badge(
{ className, tone = 'neutral', ...props }, { className, tone = 'neutral', ...props },
ref, ref,
) { ) {
return ( return <span ref={ref} className={clsx(styles.badge, className)} data-tone={tone} {...props} />;
<span
ref={ref}
className={clsx(styles.badge, className)}
data-tone={tone}
{...props}
/>
);
}); });
type BaseButtonProps = ComponentPropsWithoutRef<typeof BaseButton>; type BaseButtonProps = ComponentPropsWithoutRef<typeof BaseButton>;

View file

@ -258,6 +258,7 @@ export function AutoImportPanel({
<OptionButton <OptionButton
key={filter} key={filter}
selected={autoFilter === filter} selected={autoFilter === filter}
variant={autoFilter === filter ? 'default' : 'ghost'}
onClick={() => onFilterChange(filter)} onClick={() => onFilterChange(filter)}
> >
<span>{getAutoImportFilterLabel(filter)}</span> <span>{getAutoImportFilterLabel(filter)}</span>