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
12 changes: 12 additions & 0 deletions src/tedi/components/content/table/table.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,18 @@ describe('Table', () => {
expect(screen.queryByRole('cell', { name: 'Jüri' })).not.toBeInTheDocument();
});

it('resets the column filter from the clear button', () => {
render(<Table<Person> id="t-filter-clear" data={data} columns={columns} enableColumnFilters />);

fireEvent.change(screen.getByLabelText('Filter Name'), { target: { value: 'Anna' } });
expect(screen.queryByRole('cell', { name: 'Jüri' })).not.toBeInTheDocument();

fireEvent.click(screen.getByTitle(/clear/i));

expect(screen.getByLabelText('Filter Name')).toHaveValue('');
expect(screen.getByRole('cell', { name: 'Jüri' })).toBeInTheDocument();
});

it('forwards meta.filterProps (e.g. maxLength) to the built-in column filter input', () => {
const filterColumns: ColumnDef<Person>[] = [
{ id: 'name', header: 'Name', accessorKey: 'name', meta: { filterProps: { input: { maxLength: 40 } } } },
Expand Down
19 changes: 19 additions & 0 deletions src/tedi/components/form/textarea/textarea.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';

import { useBreakpointProps } from '../../../helpers';
import { TextFieldForwardRef } from '../textfield/textfield';
import Textarea, { TextareaProps } from './textarea';

import '@testing-library/jest-dom';
Expand Down Expand Up @@ -32,6 +34,23 @@ describe('Textarea component', () => {
expect(textarea).toHaveAttribute('name', 'testTextarea');
});

it('forwards the ref to a callback ref', () => {
const ref = jest.fn();
render(<Textarea {...defaultProps} ref={ref} />);
expect(ref).toHaveBeenCalledWith(expect.objectContaining({ input: expect.any(HTMLTextAreaElement) }));
});

it('forwards the ref to an object ref', () => {
const ref = React.createRef<TextFieldForwardRef>();
render(<Textarea {...defaultProps} ref={ref} />);
expect(ref.current?.input).toBeInstanceOf(HTMLTextAreaElement);
});

it('shows no clear button, even with a value', () => {
render(<Textarea {...defaultProps} value="Some text" onChange={jest.fn()} />);
expect(screen.queryByTitle(/clear/i)).not.toBeInTheDocument();
});

it('applies the correct CSS classes', () => {
render(<Textarea {...defaultProps} className="custom-class" />);
const wrapper = screen.getByRole('textbox').closest('div[data-name="textarea"]');
Expand Down
8 changes: 4 additions & 4 deletions src/tedi/components/form/textarea/textarea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,8 @@ export const Textarea = forwardRef<TextFieldForwardRef, TextareaProps>((props, r
const value = React.useMemo(() => externalValue ?? innerValue, [externalValue, innerValue]);

const calculateHeight = React.useCallback(() => {
if (!autoGrow || !textareaRef.current) return;

const textarea = textareaRef.current;
// Only called with autoGrow on, after the textarea has mounted and set the ref.
const textarea = textareaRef.current as HTMLTextAreaElement;

const originalOverflow = textarea.style.overflow;

Expand Down Expand Up @@ -119,7 +118,7 @@ export const Textarea = forwardRef<TextFieldForwardRef, TextareaProps>((props, r
textarea.style.overflow = originalOverflow;
// Only scroll once content overflows (past maxRows, or clipped by maxHeight); 1px tolerance for rounding
textarea.style.overflowY = textarea.scrollHeight - textarea.clientHeight > 1 ? 'auto' : 'hidden';
}, [autoGrow, minRows, maxRows]);
}, [minRows, maxRows]);

useEffect(() => {
if (autoGrow) {
Expand Down Expand Up @@ -221,6 +220,7 @@ export const Textarea = forwardRef<TextFieldForwardRef, TextareaProps>((props, r
[styles['tedi-textarea__input--auto-grow']]: autoGrow,
})}
isTextArea={true}
isClearable={false}
className={cn(styles['tedi-textarea'], className)}
value={value}
onChange={handleInputChange}
Expand Down
89 changes: 89 additions & 0 deletions src/tedi/components/form/textfield/textfield.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';

import { useBreakpointProps } from '../../../helpers';
import InputGroup from '../input-group/input-group';
import TextField, { TextFieldProps } from './textfield';

import '@testing-library/jest-dom';
Expand Down Expand Up @@ -66,6 +68,93 @@ describe('TextField component', () => {
expect(input).toHaveValue('');
});

it('calls onChange, onChangeEvent and onClear once when cleared', () => {
const handleChange = jest.fn();
const handleChangeEvent = jest.fn();
const handleClear = jest.fn();
render(
<TextField {...defaultProps} onChange={handleChange} onChangeEvent={handleChangeEvent} onClear={handleClear} />
);
const input = screen.getByPlaceholderText(/enter text/i);
fireEvent.change(input, { target: { value: 'To Be Cleared' } });
handleChange.mockClear();
handleChangeEvent.mockClear();

fireEvent.click(screen.getByTitle(/clear/i));

expect(handleChange).toHaveBeenCalledTimes(1);
expect(handleChange).toHaveBeenCalledWith('');
expect(handleChangeEvent).toHaveBeenCalledTimes(1);
expect(handleChangeEvent.mock.calls[0][0].target.value).toBe('');
expect(handleClear).toHaveBeenCalledTimes(1);
expect(input).toHaveValue('');
});

it('clears a controlled field through onChange', () => {
const Controlled = () => {
const [value, setValue] = React.useState('Controlled');
return <TextField {...defaultProps} value={value} onChange={setValue} />;
};
render(<Controlled />);
fireEvent.click(screen.getByTitle(/clear/i));
expect(screen.getByPlaceholderText(/enter text/i)).toHaveValue('');
});

it('generates an id when none is given', () => {
render(<TextField label="Generated" />);
const input = screen.getByRole('textbox');
expect(input.id).not.toBe('');
expect(screen.getByText('Generated')).toHaveAttribute('for', input.id);
});

it('takes the id from the input group when none is given', () => {
// Search renders a TextField inside an InputGroup this way; the group provides the id.
render(
<InputGroup id="group-id" label="Group">
<TextField label="In group" />
</InputGroup>
);
expect(screen.getByRole('textbox')).toHaveAttribute('id', 'group-id');
});

it.each([
['large', 24],
['small', 16],
['default', 18],
] as const)('sizes the icon for size=%s', (size, iconSize) => {
render(<TextField {...defaultProps} size={size} icon="search" />);
expect(screen.getByText('search')).toHaveClass(`tedi-icon--size-${iconSize}`);
});

it('disables the clear button of a disabled field', () => {
const handleClear = jest.fn();
render(<TextField {...defaultProps} value="x" disabled onClear={handleClear} />);
const clearButton = screen.getByTitle(/clear/i);
expect(clearButton).toBeDisabled();
fireEvent.click(clearButton);
expect(handleClear).not.toHaveBeenCalled();
expect(screen.getByPlaceholderText(/enter text/i)).toHaveValue('x');
});

it('is clearable by default', () => {
render(<TextField {...defaultProps} value="x" />);
expect(screen.getByTitle(/clear/i)).toBeInTheDocument();
});

it('shows no clear button with isClearable={false}', () => {
render(<TextField {...defaultProps} value="x" isClearable={false} />);
expect(screen.queryByTitle(/clear/i)).not.toBeInTheDocument();
});

it.each([
['readOnly', { readOnly: true }],
['input.readOnly', { input: { readOnly: true } }],
])('shows no clear button when read-only through %s', (_, props) => {
render(<TextField {...defaultProps} value="x" {...props} />);
expect(screen.getByPlaceholderText(/enter text/i)).toHaveAttribute('readonly');
expect(screen.queryByTitle(/clear/i)).not.toBeInTheDocument();
});

it('adds the reveal-on-hover modifier only with showClearOnInteraction and a filled clearable field', () => {
const container = () => document.querySelector('[data-name="textfield"]') as HTMLElement;
const noop = () => undefined;
Expand Down
22 changes: 13 additions & 9 deletions src/tedi/components/form/textfield/textfield.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,9 @@ type TextFieldBreakpointProps = {
* When `true`, displays a clear (×) button on the right side when the field has a value.
*
* Clicking the button clears the input and calls `onClear` (if provided).
* Hidden while the field is read-only.
*
* @default false
* @default true
*/
isClearable?: boolean;
/**
Expand Down Expand Up @@ -277,7 +278,7 @@ export const TextField = forwardRef<TextFieldForwardRef, TextFieldProps>((props,
size = 'default',
placeholder,
isArrowsHidden = true,
isClearable,
isClearable = true,
Comment thread
ly-tempel-bitweb marked this conversation as resolved.
showClearOnInteraction,
onClear,
onChange,
Expand All @@ -297,7 +298,7 @@ export const TextField = forwardRef<TextFieldForwardRef, TextFieldProps>((props,
startSlot,
endSlot,
...rest
} = getCurrentBreakpointProps<TextFieldProps>(props) || {};
} = getCurrentBreakpointProps<TextFieldProps>(props);

const { getLabel } = useLabels();

Expand All @@ -307,7 +308,8 @@ export const TextField = forwardRef<TextFieldForwardRef, TextFieldProps>((props,
const [innerValue, setInnerValue] = React.useState(externalValue ?? defaultValue ?? '');

const value = externalValue ?? innerValue;
const showClearButton = Boolean(isClearable && value && !readOnly);
const isReadOnly = readOnly || input?.readOnly;
const showClearButton = Boolean(isClearable && value && !isReadOnly);

const inputGroup = useOptionalInputGroup?.();
const generatedId = React.useId();
Expand Down Expand Up @@ -350,10 +352,12 @@ export const TextField = forwardRef<TextFieldForwardRef, TextFieldProps>((props,
);

const clearInput = useCallback(() => {
if (!isControlled) setInnerValue('');
onChange?.('');
// The clear button only renders once the field has mounted, so the element is there to clear.
const element = fieldRef.current as FieldElement;
Object.getOwnPropertyDescriptor(Object.getPrototypeOf(element), 'value')?.set?.call(element, '');
element.dispatchEvent(new Event('input', { bubbles: true }));
onClear?.();
}, [isControlled, onChange, onClear]);
}, [onClear]);

const renderIcon = useCallback(() => {
if (!icon) return null;
Expand Down Expand Up @@ -414,7 +418,7 @@ export const TextField = forwardRef<TextFieldForwardRef, TextFieldProps>((props,
{showClearButton && icon && (
<Separator color="primary" axis="vertical" className={styles['tedi-textfield__separator']} />
)}
{icon && renderIcon()}
{renderIcon()}
</div>
);
}, [showClearButton, icon, renderClearButton, renderIcon]);
Expand All @@ -429,7 +433,7 @@ export const TextField = forwardRef<TextFieldForwardRef, TextFieldProps>((props,
onChange={handleChange}
onChangeEvent={onChangeEvent}
disabled={disabled}
readOnly={readOnly || input?.readOnly}
readOnly={isReadOnly}
required={required}
invalid={isInvalid}
placeholder={placeholder}
Expand Down
Loading