Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/www/src/content/docs/components/input-field/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,19 @@ export const disabledDemo = {
/>`
};

export const disabledChipsDemo = {
type: 'code',
code: `<InputField
placeholder="Type and press Enter..."
disabled
width="560px"
chips={[
{ label: "Tag1", onRemove: () => console.log("Remove Tag1") },
{ label: "Tag2", onRemove: () => console.log("Remove Tag2") }
]}
/>`
};

export const widthDemo = {
type: 'code',
code: `<InputField
Expand Down
7 changes: 7 additions & 0 deletions apps/www/src/content/docs/components/input-field/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
prefixDemo,
iconDemo,
disabledDemo,
disabledChipsDemo,
widthDemo,
sizeDemo,
sizeChipDemo,
Expand Down Expand Up @@ -76,6 +77,12 @@ Input field in disabled state.

<Demo data={disabledDemo} />

### Disabled with Chips

When disabled, chips become non-interactive and their dismiss buttons are hidden.

<Demo data={disabledChipsDemo} />

### Custom Width

Input field with custom width.
Expand Down
5 changes: 4 additions & 1 deletion apps/www/src/content/docs/components/input-field/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -40,6 +40,9 @@ export interface InputFieldProps {
*/
variant?: 'default' | 'borderless';

/** Ref to the outer container div. */
containerRef?: React.RefObject<HTMLDivElement | null>;

/** Additional CSS class names. */
className?: string;
}
29 changes: 29 additions & 0 deletions packages/raystack/components/chip/__tests__/chip.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,35 @@ describe('Chip', () => {
});
});

describe('Disabled State', () => {
it('sets data-disabled attribute when disabled', () => {
render(<Chip disabled>Disabled Chip</Chip>);

const chip = screen.getByRole('status');
expect(chip).toHaveAttribute('data-disabled');
});

it('does not set data-disabled when not disabled', () => {
render(<Chip>Active Chip</Chip>);

const chip = screen.getByRole('status');
expect(chip).not.toHaveAttribute('data-disabled');
});

it('does not call onClick when disabled', () => {
const onClick = vi.fn();
render(
<Chip disabled onClick={onClick}>
Disabled Chip
</Chip>
);

const chip = screen.getByRole('status');
fireEvent.click(chip);
expect(onClick).not.toHaveBeenCalled();
});
});

describe('Accessibility', () => {
it('uses status role by default', () => {
render(<Chip>Test Chip</Chip>);
Expand Down
5 changes: 5 additions & 0 deletions packages/raystack/components/chip/chip.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,11 @@
height: var(--rs-space-4);
}

.chip[data-disabled] {
pointer-events: none;
opacity: 0.5;
}

.dismiss-button {
background: none;
border: none;
Expand Down
5 changes: 4 additions & 1 deletion packages/raystack/components/chip/chip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type ChipProps = VariantProps<typeof chip> & {
onClick?: () => void;
role?: string;
ariaLabel?: string;
disabled?: boolean;
'data-state'?: string;
};

Expand All @@ -53,6 +54,7 @@ export const Chip = ({
onClick,
role = 'status',
ariaLabel,
disabled,
'data-state': dataState
}: ChipProps) => {
const handleDismiss = (e: React.MouseEvent) => {
Expand All @@ -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}
Comment on lines +72 to +73
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Disabled state can still allow dismiss via keyboard path.

data-disabled + CSS pointer-events: none blocks pointer input, but the dismiss <button> still has an active click handler and no disabled attribute. That can still permit keyboard-triggered dismissal in disabled mode.

Proposed fix
 export const Chip = ({
@@
 }: ChipProps) => {
-  const handleDismiss = (e: React.MouseEvent) => {
+  const handleDismiss = (e: React.MouseEvent<HTMLButtonElement>) => {
     e.stopPropagation();
+    if (disabled) return;
     onDismiss?.();
   };
@@
       {isDismissible ? (
         <button
           onClick={handleDismiss}
+          disabled={disabled}
+          aria-disabled={disabled || undefined}
           className={styles['dismiss-button']}
           aria-label={`Remove ${
             typeof children === 'string' ? children : 'item'
           }`}
           type='button'
         >

Also applies to: 86-89

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/raystack/components/chip/chip.tsx` around lines 72 - 73, The chip's
disabled state only prevents pointer input via data-disabled/CSS but still
leaves the dismiss <button> interactive via keyboard because its onClick handler
remains and it lacks a disabled attribute; update the component(s) where you set
onClick and data-disabled (refer to onClick, data-disabled, and the dismiss
<button> render) to: when disabled is true, remove any click handlers (set
onClick to undefined), add the disabled attribute to the dismiss <button>, and
include aria-disabled="true" to convey state to assistive tech; apply the same
change to the other occurrence around lines 86-89 so keyboard events cannot
trigger dismissal when disabled.

data-state={dataState}
>
{leadingIcon && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,19 @@ describe('InputField', () => {

it('sets custom width', () => {
const { container } = render(<InputField width='300px' />);
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(<InputField width={400} />);
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(<InputField />);
const wrapper = container.querySelector(`.${styles.inputWrapper}`);
const wrapper = container.querySelector(`.${styles['input-wrapper']}`);
expect(wrapper).toHaveStyle({ width: '100%' });
});

Expand All @@ -58,27 +58,27 @@ describe('InputField', () => {
describe('Sizes', () => {
it('renders large size by default', () => {
const { container } = render(<InputField />);
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(<InputField size='small' />);
const wrapper = container.querySelector(`.${styles.inputWrapper}`);
const wrapper = container.querySelector(`.${styles['input-wrapper']}`);
expect(wrapper).toHaveClass(styles['size-small']);
});
});

describe('Variants', () => {
it('renders default variant by default', () => {
const { container } = render(<InputField />);
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(<InputField variant='borderless' />);
const wrapper = container.querySelector(`.${styles.inputWrapper}`);
const wrapper = container.querySelector(`.${styles['input-wrapper']}`);
expect(wrapper).toHaveClass(styles['variant-borderless']);
});
});
Expand Down Expand Up @@ -161,6 +161,35 @@ describe('InputField', () => {
render(<InputField chips={chips} maxChipsVisible={3} />);
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(<InputField chips={chips} disabled />);
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(<InputField chips={chips} disabled />);
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(<InputField chips={chips} />);
const dismissButton = screen.getByRole('button', {
name: 'Remove Tag1'
});
fireEvent.click(dismissButton);
expect(handleRemove).toHaveBeenCalledTimes(1);
});
});

describe('Event Handling', () => {
Expand Down
20 changes: 9 additions & 11 deletions packages/raystack/components/input-field/input-field.module.css
Original file line number Diff line number Diff line change
@@ -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%;
Expand All @@ -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);
}
Expand All @@ -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;
}
Expand All @@ -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;
}
Expand Down
7 changes: 4 additions & 3 deletions packages/raystack/components/input-field/input-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down Expand Up @@ -77,9 +77,10 @@ export function InputField({
<Chip
key={index}
variant='outline'
isDismissible={!!chip.onRemove}
onDismiss={chip.onRemove}
isDismissible={!disabled && !!chip.onRemove}
onDismiss={disabled ? undefined : chip.onRemove}
className={styles.chip}
disabled={disabled}
>
{chip.label}
</Chip>
Expand Down
Loading