diff --git a/web/app/components/base/ui/select/__tests__/index.spec.tsx b/web/app/components/base/ui/select/__tests__/index.spec.tsx index 7744a0e22c..37d38b93e4 100644 --- a/web/app/components/base/ui/select/__tests__/index.spec.tsx +++ b/web/app/components/base/ui/select/__tests__/index.spec.tsx @@ -3,16 +3,18 @@ import { describe, expect, it, vi } from 'vitest' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../index' const renderOpenSelect = ({ + rootProps = {}, triggerProps = {}, contentProps = {}, onValueChange, }: { + rootProps?: Record triggerProps?: Record contentProps?: Record onValueChange?: (value: string | null) => void } = {}) => { return render( - @@ -109,6 +111,107 @@ describe('Select wrappers', () => { const clearButton = screen.getByRole('button', { name: /clear selection/i }) expect(() => fireEvent.click(clearButton)).not.toThrow() }) + + it('should apply regular size variant classes by default', () => { + renderOpenSelect() + + const trigger = screen.getByRole('combobox', { name: 'city select' }) + expect(trigger.className).toMatch(/system-sm-regular/) + expect(trigger.className).toMatch(/rounded-lg/) + }) + + it('should apply small size variant classes when size is small', () => { + renderOpenSelect({ + triggerProps: { size: 'small' }, + }) + + const trigger = screen.getByRole('combobox', { name: 'city select' }) + expect(trigger.className).toMatch(/system-xs-regular/) + expect(trigger.className).toMatch(/rounded-md/) + }) + + it('should apply large size variant classes when size is large', () => { + renderOpenSelect({ + triggerProps: { size: 'large' }, + }) + + const trigger = screen.getByRole('combobox', { name: 'city select' }) + expect(trigger.className).toMatch(/system-md-regular/) + }) + + it('should apply disabled styling via data attributes when disabled', () => { + renderOpenSelect({ + triggerProps: { disabled: true }, + }) + + const trigger = screen.getByRole('combobox', { name: 'city select' }) + expect(trigger).toHaveAttribute('data-disabled') + expect(trigger.className).toContain('data-[disabled]:bg-components-input-bg-disabled') + }) + + it('should apply disabled placeholder color class for compound state', () => { + renderOpenSelect({ + triggerProps: { disabled: true }, + }) + + const trigger = screen.getByRole('combobox', { name: 'city select' }) + expect(trigger.className).toContain('data-[disabled]:data-[placeholder]:text-components-input-text-disabled') + }) + + it('should show error icon and apply destructive styling when variant is destructive', () => { + renderOpenSelect({ + triggerProps: { variant: 'destructive' }, + }) + + const trigger = screen.getByRole('combobox', { name: 'city select' }) + expect(trigger.className).toContain('border-components-input-border-destructive') + expect(trigger.className).toContain('bg-components-input-bg-destructive') + const errorIcon = trigger.querySelector('.i-ri-error-warning-line') + expect(errorIcon).toBeInTheDocument() + }) + + it('should hide clear button when variant is destructive even if clearable', () => { + renderOpenSelect({ + triggerProps: { clearable: true, variant: 'destructive' }, + }) + + expect(screen.queryByRole('button', { name: /clear selection/i })).not.toBeInTheDocument() + }) + + it('should apply readonly styling via data attributes when Root is readOnly', () => { + renderOpenSelect({ + rootProps: { readOnly: true }, + }) + + const trigger = screen.getByRole('combobox', { name: 'city select' }) + expect(trigger).toHaveAttribute('data-readonly') + expect(trigger.className).toContain('data-[readonly]:bg-transparent') + }) + + it('should hide arrow icon via CSS when Root is readOnly', () => { + renderOpenSelect({ + rootProps: { readOnly: true }, + }) + + const trigger = screen.getByRole('combobox', { name: 'city select' }) + const iconWrapper = trigger.querySelector('[class*="group-data-[readonly]:hidden"]') + expect(iconWrapper).toBeInTheDocument() + }) + + it('should set aria-hidden on decorative icons', () => { + renderOpenSelect() + + const trigger = screen.getByRole('combobox', { name: 'city select' }) + const arrowIcon = trigger.querySelector('.i-ri-arrow-down-s-line') + expect(arrowIcon).toHaveAttribute('aria-hidden', 'true') + }) + + it('should include placeholder color class via data attribute', () => { + renderOpenSelect() + + const trigger = screen.getByRole('combobox', { name: 'city select' }) + expect(trigger.className).toContain('data-[placeholder]:text-components-input-text-placeholder') + }) }) describe('SelectContent', () => { diff --git a/web/app/components/base/ui/select/index.tsx b/web/app/components/base/ui/select/index.tsx index 04de5efaaf..6c875abb37 100644 --- a/web/app/components/base/ui/select/index.tsx +++ b/web/app/components/base/ui/select/index.tsx @@ -1,7 +1,9 @@ 'use client' +import type { VariantProps } from 'class-variance-authority' import type { Placement } from '@/app/components/base/ui/placement' import { Select as BaseSelect } from '@base-ui/react/select' +import { cva } from 'class-variance-authority' import * as React from 'react' import { parsePlacement } from '@/app/components/base/ui/placement' import { cn } from '@/utils/classnames' @@ -12,61 +14,110 @@ export const SelectGroup = BaseSelect.Group export const SelectGroupLabel = BaseSelect.GroupLabel export const SelectSeparator = BaseSelect.Separator +export const selectTriggerVariants = cva( + '', + { + variants: { + size: { + small: 'h-6 gap-px rounded-md px-[5px] py-0 system-xs-regular', + regular: 'h-8 gap-0.5 rounded-lg px-2 py-1 system-sm-regular', + large: 'h-9 gap-0.5 rounded-[10px] px-2.5 py-1 system-md-regular', + }, + variant: { + default: '', + destructive: 'border border-components-input-border-destructive bg-components-input-bg-destructive shadow-xs hover:border-components-input-border-destructive hover:bg-components-input-bg-destructive', + }, + }, + defaultVariants: { + size: 'regular', + variant: 'default', + }, + }, +) + +const contentPadding: Record = { + small: 'px-[3px] py-1', + regular: 'p-1', + large: 'px-1.5 py-1', +} + type SelectTriggerProps = React.ComponentPropsWithoutRef & { clearable?: boolean onClear?: () => void loading?: boolean -} +} & VariantProps export function SelectTrigger({ className, children, + size = 'regular', + variant = 'default', clearable = false, onClear, loading = false, ...props }: SelectTriggerProps) { - const showClear = clearable && !loading + const paddingClass = contentPadding[size ?? 'regular'] + const isDestructive = variant === 'destructive' + + let trailingIcon: React.ReactNode = null + if (loading) { + trailingIcon = ( +