diff --git a/apps/www/src/content/docs/components/input-field/demo.ts b/apps/www/src/content/docs/components/input-field/demo.ts index 7e7916b90..65d89a6b7 100644 --- a/apps/www/src/content/docs/components/input-field/demo.ts +++ b/apps/www/src/content/docs/components/input-field/demo.ts @@ -73,6 +73,19 @@ export const disabledDemo = { />` }; +export const disabledChipsDemo = { + type: 'code', + code: ` console.log("Remove Tag1") }, + { label: "Tag2", onRemove: () => console.log("Remove Tag2") } + ]} +/>` +}; + export const widthDemo = { type: 'code', code: ` +### Disabled with Chips + +When disabled, chips become non-interactive and their dismiss buttons are hidden. + + + ### Custom Width Input field with custom width. diff --git a/apps/www/src/content/docs/components/input-field/props.ts b/apps/www/src/content/docs/components/input-field/props.ts index e383ab565..ed5a14eaf 100644 --- a/apps/www/src/content/docs/components/input-field/props.ts +++ b/apps/www/src/content/docs/components/input-field/props.ts @@ -5,7 +5,7 @@ export interface InputFieldProps { */ size?: 'small' | 'large'; - /** Whether the input is disabled. */ + /** Whether the input is disabled. When true, chips are also disabled and their dismiss buttons are hidden. */ disabled?: boolean; /** Icon element to display at the start of input. */ @@ -40,6 +40,9 @@ export interface InputFieldProps { */ variant?: 'default' | 'borderless'; + /** Ref to the outer container div. */ + containerRef?: React.RefObject; + /** Additional CSS class names. */ className?: string; } diff --git a/packages/raystack/components/chip/__tests__/chip.test.tsx b/packages/raystack/components/chip/__tests__/chip.test.tsx index 0f786a3b4..a15348866 100644 --- a/packages/raystack/components/chip/__tests__/chip.test.tsx +++ b/packages/raystack/components/chip/__tests__/chip.test.tsx @@ -165,6 +165,35 @@ describe('Chip', () => { }); }); + describe('Disabled State', () => { + it('sets data-disabled attribute when disabled', () => { + render(Disabled Chip); + + const chip = screen.getByRole('status'); + expect(chip).toHaveAttribute('data-disabled'); + }); + + it('does not set data-disabled when not disabled', () => { + render(Active Chip); + + const chip = screen.getByRole('status'); + expect(chip).not.toHaveAttribute('data-disabled'); + }); + + it('does not call onClick when disabled', () => { + const onClick = vi.fn(); + render( + + Disabled Chip + + ); + + const chip = screen.getByRole('status'); + fireEvent.click(chip); + expect(onClick).not.toHaveBeenCalled(); + }); + }); + describe('Accessibility', () => { it('uses status role by default', () => { render(Test Chip); diff --git a/packages/raystack/components/chip/chip.module.css b/packages/raystack/components/chip/chip.module.css index ff131ee85..4c01ce966 100644 --- a/packages/raystack/components/chip/chip.module.css +++ b/packages/raystack/components/chip/chip.module.css @@ -115,6 +115,11 @@ height: var(--rs-space-4); } +.chip[data-disabled] { + pointer-events: none; + opacity: 0.5; +} + .dismiss-button { background: none; border: none; diff --git a/packages/raystack/components/chip/chip.tsx b/packages/raystack/components/chip/chip.tsx index dfe459601..424298a4f 100644 --- a/packages/raystack/components/chip/chip.tsx +++ b/packages/raystack/components/chip/chip.tsx @@ -37,6 +37,7 @@ type ChipProps = VariantProps & { onClick?: () => void; role?: string; ariaLabel?: string; + disabled?: boolean; 'data-state'?: string; }; @@ -53,6 +54,7 @@ export const Chip = ({ onClick, role = 'status', ariaLabel, + disabled, 'data-state': dataState }: ChipProps) => { const handleDismiss = (e: React.MouseEvent) => { @@ -67,7 +69,8 @@ export const Chip = ({ aria-label={ ariaLabel || (typeof children === 'string' ? children : undefined) } - onClick={onClick} + onClick={disabled ? undefined : onClick} + data-disabled={disabled || undefined} data-state={dataState} > {leadingIcon && ( diff --git a/packages/raystack/components/input-field/__tests__/input-field.test.tsx b/packages/raystack/components/input-field/__tests__/input-field.test.tsx index 8e869ce9c..393e1137e 100644 --- a/packages/raystack/components/input-field/__tests__/input-field.test.tsx +++ b/packages/raystack/components/input-field/__tests__/input-field.test.tsx @@ -32,19 +32,19 @@ describe('InputField', () => { it('sets custom width', () => { const { container } = render(); - const wrapper = container.querySelector(`.${styles.inputWrapper}`); + const wrapper = container.querySelector(`.${styles['input-wrapper']}`); expect(wrapper).toHaveStyle({ width: '300px' }); }); it('sets numeric width as pixels', () => { const { container } = render(); - const wrapper = container.querySelector(`.${styles.inputWrapper}`); + const wrapper = container.querySelector(`.${styles['input-wrapper']}`); expect(wrapper).toHaveStyle({ width: '400px' }); }); it('defaults to 100% width', () => { const { container } = render(); - const wrapper = container.querySelector(`.${styles.inputWrapper}`); + const wrapper = container.querySelector(`.${styles['input-wrapper']}`); expect(wrapper).toHaveStyle({ width: '100%' }); }); @@ -58,13 +58,13 @@ describe('InputField', () => { describe('Sizes', () => { it('renders large size by default', () => { const { container } = render(); - const wrapper = container.querySelector(`.${styles.inputWrapper}`); + const wrapper = container.querySelector(`.${styles['input-wrapper']}`); expect(wrapper).toHaveClass(styles['size-large']); }); it('renders small size when specified', () => { const { container } = render(); - const wrapper = container.querySelector(`.${styles.inputWrapper}`); + const wrapper = container.querySelector(`.${styles['input-wrapper']}`); expect(wrapper).toHaveClass(styles['size-small']); }); }); @@ -72,13 +72,13 @@ describe('InputField', () => { describe('Variants', () => { it('renders default variant by default', () => { const { container } = render(); - const wrapper = container.querySelector(`.${styles.inputWrapper}`); + const wrapper = container.querySelector(`.${styles['input-wrapper']}`); expect(wrapper).toHaveClass(styles['variant-default']); }); it('renders borderless variant when specified', () => { const { container } = render(); - const wrapper = container.querySelector(`.${styles.inputWrapper}`); + const wrapper = container.querySelector(`.${styles['input-wrapper']}`); expect(wrapper).toHaveClass(styles['variant-borderless']); }); }); @@ -161,6 +161,35 @@ describe('InputField', () => { render(); expect(screen.getByText('+2')).toBeInTheDocument(); }); + + it('does not render dismiss button on chips when disabled', () => { + const handleRemove = vi.fn(); + const chips = [{ label: 'Tag1', onRemove: handleRemove }]; + render(); + const dismissButton = screen.queryByRole('button', { + name: 'Remove Tag1' + }); + expect(dismissButton).not.toBeInTheDocument(); + }); + + it('chips are non-interactive when disabled', () => { + const handleRemove = vi.fn(); + const chips = [{ label: 'Tag1', onRemove: handleRemove }]; + const { container } = render(); + const chip = container.querySelector(`.${styles.chip}`); + expect(chip).toHaveAttribute('data-disabled'); + }); + + it('chips remain interactive when not disabled', () => { + const handleRemove = vi.fn(); + const chips = [{ label: 'Tag1', onRemove: handleRemove }]; + render(); + const dismissButton = screen.getByRole('button', { + name: 'Remove Tag1' + }); + fireEvent.click(dismissButton); + expect(handleRemove).toHaveBeenCalledTimes(1); + }); }); describe('Event Handling', () => { diff --git a/packages/raystack/components/input-field/input-field.module.css b/packages/raystack/components/input-field/input-field.module.css index e4b29f69b..4ed256024 100644 --- a/packages/raystack/components/input-field/input-field.module.css +++ b/packages/raystack/components/input-field/input-field.module.css @@ -1,6 +1,6 @@ /* Note: If making changes here, possibly also need to make changes in text-area.module.css for design consistency. */ -.inputWrapper { +.input-wrapper { display: flex; align-items: center; width: 100%; @@ -13,32 +13,32 @@ overflow: hidden; } -.inputWrapper:hover { +.input-wrapper:hover { border-color: var(--rs-color-border-base-focus); background: var(--rs-color-background-base-primary-hover); } -.inputWrapper:focus-within, -.inputWrapper:has(.input-field[data-active="true"]) { +.input-wrapper:focus-within, +.input-wrapper:has(.input-field[data-active="true"]) { border-color: var(--rs-color-border-accent-emphasis); background-color: var(--rs-color-background-base-primary); outline: none; } -.inputWrapper[data-invalid] { +.input-wrapper[data-invalid] { border-color: var(--rs-color-border-danger-primary); } -.inputWrapper[data-invalid]:hover, -.inputWrapper[data-invalid]:focus-within { +.input-wrapper[data-invalid]:hover, +.input-wrapper[data-invalid]:focus-within { border-color: var(--rs-color-border-danger-emphasis-hover); } -.inputWrapper[data-disabled] { +.input-wrapper[data-disabled] { opacity: 0.5; } -.inputWrapper[data-disabled]:hover { +.input-wrapper[data-disabled]:hover { border-color: var(--rs-color-border-base-tertiary); background: var(--rs-color-background-base-primary); } @@ -50,7 +50,6 @@ width: var(--rs-space-5); height: var(--rs-space-5); color: var(--rs-color-foreground-base-secondary); - pointer-events: none; margin-left: var(--rs-space-3); pointer-events: auto; } @@ -62,7 +61,6 @@ width: var(--rs-space-5); height: var(--rs-space-5); color: var(--rs-color-foreground-base-secondary); - pointer-events: none; margin-right: var(--rs-space-3); pointer-events: auto; } diff --git a/packages/raystack/components/input-field/input-field.tsx b/packages/raystack/components/input-field/input-field.tsx index 9f56860f9..5cfce4aff 100644 --- a/packages/raystack/components/input-field/input-field.tsx +++ b/packages/raystack/components/input-field/input-field.tsx @@ -5,7 +5,7 @@ import { Chip } from '../chip'; import { useFieldContext } from '../field'; import styles from './input-field.module.css'; -const inputWrapper = cva(styles.inputWrapper, { +const inputWrapper = cva(styles['input-wrapper'], { variants: { size: { small: styles['size-small'], @@ -77,9 +77,10 @@ export function InputField({ {chip.label}