diff --git a/src/tedi/components/content/table/table.spec.tsx b/src/tedi/components/content/table/table.spec.tsx
index 209cd6f1f..557f66ab0 100644
--- a/src/tedi/components/content/table/table.spec.tsx
+++ b/src/tedi/components/content/table/table.spec.tsx
@@ -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(
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[] = [
{ id: 'name', header: 'Name', accessorKey: 'name', meta: { filterProps: { input: { maxLength: 40 } } } },
diff --git a/src/tedi/components/form/textarea/textarea.spec.tsx b/src/tedi/components/form/textarea/textarea.spec.tsx
index 03aadd257..034f2bc78 100644
--- a/src/tedi/components/form/textarea/textarea.spec.tsx
+++ b/src/tedi/components/form/textarea/textarea.spec.tsx
@@ -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';
@@ -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();
+ expect(ref).toHaveBeenCalledWith(expect.objectContaining({ input: expect.any(HTMLTextAreaElement) }));
+ });
+
+ it('forwards the ref to an object ref', () => {
+ const ref = React.createRef();
+ render();
+ expect(ref.current?.input).toBeInstanceOf(HTMLTextAreaElement);
+ });
+
+ it('shows no clear button, even with a value', () => {
+ render();
+ expect(screen.queryByTitle(/clear/i)).not.toBeInTheDocument();
+ });
+
it('applies the correct CSS classes', () => {
render();
const wrapper = screen.getByRole('textbox').closest('div[data-name="textarea"]');
diff --git a/src/tedi/components/form/textarea/textarea.tsx b/src/tedi/components/form/textarea/textarea.tsx
index 23548172c..0c2e3e195 100644
--- a/src/tedi/components/form/textarea/textarea.tsx
+++ b/src/tedi/components/form/textarea/textarea.tsx
@@ -76,9 +76,8 @@ export const Textarea = forwardRef((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;
@@ -119,7 +118,7 @@ export const Textarea = forwardRef((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) {
@@ -221,6 +220,7 @@ export const Textarea = forwardRef((props, r
[styles['tedi-textarea__input--auto-grow']]: autoGrow,
})}
isTextArea={true}
+ isClearable={false}
className={cn(styles['tedi-textarea'], className)}
value={value}
onChange={handleInputChange}
diff --git a/src/tedi/components/form/textfield/textfield.spec.tsx b/src/tedi/components/form/textfield/textfield.spec.tsx
index b1298b03f..b1ce0ebf8 100644
--- a/src/tedi/components/form/textfield/textfield.spec.tsx
+++ b/src/tedi/components/form/textfield/textfield.spec.tsx
@@ -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';
@@ -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(
+
+ );
+ 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 ;
+ };
+ render();
+ fireEvent.click(screen.getByTitle(/clear/i));
+ expect(screen.getByPlaceholderText(/enter text/i)).toHaveValue('');
+ });
+
+ it('generates an id when none is given', () => {
+ render();
+ 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(
+
+
+
+ );
+ 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();
+ expect(screen.getByText('search')).toHaveClass(`tedi-icon--size-${iconSize}`);
+ });
+
+ it('disables the clear button of a disabled field', () => {
+ const handleClear = jest.fn();
+ render();
+ 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();
+ expect(screen.getByTitle(/clear/i)).toBeInTheDocument();
+ });
+
+ it('shows no clear button with isClearable={false}', () => {
+ render();
+ 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();
+ 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;
diff --git a/src/tedi/components/form/textfield/textfield.tsx b/src/tedi/components/form/textfield/textfield.tsx
index 821fa8215..ad55be8dc 100644
--- a/src/tedi/components/form/textfield/textfield.tsx
+++ b/src/tedi/components/form/textfield/textfield.tsx
@@ -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;
/**
@@ -277,7 +278,7 @@ export const TextField = forwardRef((props,
size = 'default',
placeholder,
isArrowsHidden = true,
- isClearable,
+ isClearable = true,
showClearOnInteraction,
onClear,
onChange,
@@ -297,7 +298,7 @@ export const TextField = forwardRef((props,
startSlot,
endSlot,
...rest
- } = getCurrentBreakpointProps(props) || {};
+ } = getCurrentBreakpointProps(props);
const { getLabel } = useLabels();
@@ -307,7 +308,8 @@ export const TextField = forwardRef((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();
@@ -350,10 +352,12 @@ export const TextField = forwardRef((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;
@@ -414,7 +418,7 @@ export const TextField = forwardRef((props,
{showClearButton && icon && (
)}
- {icon && renderIcon()}
+ {renderIcon()}
);
}, [showClearButton, icon, renderClearButton, renderIcon]);
@@ -429,7 +433,7 @@ export const TextField = forwardRef((props,
onChange={handleChange}
onChangeEvent={onChangeEvent}
disabled={disabled}
- readOnly={readOnly || input?.readOnly}
+ readOnly={isReadOnly}
required={required}
invalid={isInvalid}
placeholder={placeholder}