Skip to content
Merged
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
2 changes: 2 additions & 0 deletions skills/tedi-react/references/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -1803,6 +1803,8 @@ Import from `@tedi-design-system/react/community`. These are community-contribut

### FileUpload — **DEPRECATED** (use TEDI-Ready FileUpload)

> The community `FileUpload` (`@tedi-design-system/react/community`) is **⚠️ DEPRECATED** in favour of the TEDI-Ready component (same name; import from `/tedi` instead of `/community`).

- `id: string`, `name: string`, `accept?`, `multiple?`, `maxSize?`
- `files?`, `defaultFiles?`, `onChange?`, `onDelete?`

Expand Down
3 changes: 3 additions & 0 deletions src/community/components/form/file-upload/file-upload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
file: File;
}

/**
* @deprecated Use FileUpload from `@tedi-design-system/react/tedi` instead.
*/
export interface FileUploadProps extends FormLabelProps {
/**
* Additional classes.
Expand Down Expand Up @@ -118,13 +121,13 @@
*/
export const FileUpload = (props: FileUploadProps): JSX.Element => {
const { getLabel } = useLabels();
const {

Check warning on line 124 in src/community/components/form/file-upload/file-upload.tsx

View workflow job for this annotation

GitHub Actions / Build (feat/33-timeline-new-tedi-ready-component)

'className' is assigned a value but never used. Allowed unused vars must match /^_/u
id,
name,
accept,
multiple,
onChange,
className,

Check warning on line 130 in src/community/components/form/file-upload/file-upload.tsx

View workflow job for this annotation

GitHub Actions / build

'className' is assigned a value but never used. Allowed unused vars must match /^_/u
defaultFiles,
onDelete,
files,
Expand Down
8 changes: 7 additions & 1 deletion src/tedi/components/form/file-upload/file-upload.module.scss
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
@use '@tedi-design-system/core/bootstrap-utility/breakpoints';
@use '@tedi-design-system/core/mixins';

$container-height: 2.5rem;
$container-height-small: 2rem;
Expand All @@ -8,7 +9,12 @@ $container-height-small: 2rem;
align-items: center;

& input[type='file'] {
display: none;
@include mixins.visually-hidden;
}

& input[type='file']:focus-visible ~ .tedi-file-upload__button {
outline: 2px solid var(--form-input-border-active);
outline-offset: 2px;
}
}

Expand Down
91 changes: 82 additions & 9 deletions src/tedi/components/form/file-upload/file-upload.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import React from 'react';

import { FileUploadFile } from '../../../helpers';
Expand Down Expand Up @@ -40,14 +40,62 @@ describe('FileUpload component', () => {
expect(defaultProps.onChange).toHaveBeenCalledWith([expect.objectContaining({ name: 'test.jpg' })]);
});

it('clears the input value after selection so the same file can be re-selected', () => {
render(<FileUpload {...defaultProps} />);
const input = screen.getByLabelText(/Upload files/i) as HTMLInputElement;
const file = new File(['dummy content'], 'test.jpg', { type: 'image/jpeg' });

fireEvent.change(input, { target: { files: [file] } });
expect(input.value).toBe('');
});

it('associates the helper text with the add button as a description', () => {
render(<FileUpload {...defaultProps} helper={{ text: 'Some hint', id: 'my-helper' }} />);
const addButton = screen.getByRole('button', { name: /file-upload.add/i });
expect(addButton).toHaveAttribute('aria-describedby', 'my-helper');
});

it('gives each file remove button an accessible name including the file name', () => {
render(
<FileUpload
{...defaultProps}
defaultFiles={[
{ name: 'a.jpg', id: '1' },
{ name: 'b.png', id: '2' },
]}
/>
);
expect(screen.getByRole('button', { name: /remove a.jpg/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /remove b.png/i })).toBeInTheDocument();
});

it('exposes a failed file to screen readers, not by colour alone', () => {
render(<FileUpload {...defaultProps} defaultFiles={[{ name: 'bad.txt', id: '1', isValid: false }]} />);
expect(screen.getByText(/file-upload.failed/i)).toBeInTheDocument();
});

it('moves focus to the add button after removing a file', () => {
render(
<FileUpload
{...defaultProps}
defaultFiles={[
{ name: 'a.jpg', id: '1' },
{ name: 'b.png', id: '2' },
]}
/>
);
fireEvent.click(screen.getByRole('button', { name: /remove a.jpg/i }));
expect(screen.getByRole('button', { name: /file-upload.add/i })).toHaveFocus();
});

it('rejects files with invalid extensions', async () => {
render(<FileUpload {...defaultProps} />);
const input = screen.getByLabelText(/Upload files/i);
const file = new File(['dummy content'], 'test.txt', { type: 'text/plain' });
fireEvent.change(input, { target: { files: [file] } });
await waitFor(() => {
expect(defaultProps.onChange).not.toHaveBeenCalled();
expect(screen.getByText(/file-upload.extension-rejected/i)).toBeInTheDocument();
expect(screen.getAllByText(/file-upload.extension-rejected/i)[0]).toBeInTheDocument();
});
});

Expand All @@ -58,7 +106,7 @@ describe('FileUpload component', () => {
Object.defineProperty(file, 'size', { value: 6 * 1024 * 1024 });
fireEvent.change(input, { target: { files: [file] } });
expect(defaultProps.onChange).not.toHaveBeenCalled();
expect(screen.getByText(/file-upload.size-rejected/i)).toBeInTheDocument();
expect(screen.getAllByText(/file-upload.size-rejected/i)[0]).toBeInTheDocument();
});

it('does not render close button when there is only one file', () => {
Expand Down Expand Up @@ -110,7 +158,7 @@ describe('FileUpload component', () => {
const input = screen.getByLabelText(/Upload files/i);
const file = new File(['dummy content'], 'test.txt', { type: 'text/plain' });
fireEvent.change(input, { target: { files: [file] } });
expect(screen.getByText(/file-upload.extension-rejected/i)).toBeInTheDocument();
expect(screen.getAllByText(/file-upload.extension-rejected/i)[0]).toBeInTheDocument();
});

it('should display error message for files exceeding max size', () => {
Expand All @@ -119,7 +167,7 @@ describe('FileUpload component', () => {
const file = new File(['a'.repeat(6 * 1024 * 1024)], 'large.jpg', { type: 'image/jpeg' });
Object.defineProperty(file, 'size', { value: 6 * 1024 * 1024 });
fireEvent.change(input, { target: { files: [file] } });
expect(screen.getByText(/file-upload.size-rejected/i)).toBeInTheDocument();
expect(screen.getAllByText(/file-upload.size-rejected/i)[0]).toBeInTheDocument();
});

it('handles empty accept prop', () => {
Expand Down Expand Up @@ -156,8 +204,8 @@ describe('FileUpload component', () => {
];

fireEvent.change(input, { target: { files: invalidFiles } });
expect(screen.getByText(/file-upload.extension-rejected/i)).toBeInTheDocument();
expect(screen.getByText(/file-upload.size-rejected/i)).toBeInTheDocument();
expect(screen.getAllByText(/file-upload.extension-rejected/i)[0]).toBeInTheDocument();
expect(screen.getAllByText(/file-upload.size-rejected/i)[0]).toBeInTheDocument();
});

it('calls handleClear when clicked', () => {
Expand Down Expand Up @@ -235,7 +283,7 @@ describe('FileUpload component', () => {
fireEvent.change(input, { target: { files: [largeFile] } });

expect(defaultProps.onChange).not.toHaveBeenCalled();
expect(screen.getByText(/file-upload.size-rejected/i)).toBeInTheDocument();
expect(screen.getAllByText(/file-upload.size-rejected/i)[0]).toBeInTheDocument();
});

it('should update innerFiles when files prop is not provided', () => {
Expand Down Expand Up @@ -341,7 +389,7 @@ describe('FileUpload component', () => {

await waitFor(() => {
expect(screen.getByText('new.jpg')).toBeInTheDocument();
expect(screen.getByText(/file-upload.extension-rejected/i)).toBeInTheDocument();
expect(screen.getAllByText(/file-upload.extension-rejected/i)[0]).toBeInTheDocument();
});

const clearButton = screen.getByRole('button', { name: /clear/i });
Expand All @@ -351,4 +399,29 @@ describe('FileUpload component', () => {
expect(screen.queryByText('new.jpg')).not.toBeInTheDocument();
});
});

it('resets the live region so an identical consecutive announcement is re-announced', () => {
jest.useFakeTimers();
try {
render(<FileUpload {...defaultProps} />);
const input = screen.getByLabelText(/Upload files/i);
const liveRegion = screen.getByRole('status');

fireEvent.change(input, { target: { files: [new File(['a'], 'a.jpg', { type: 'image/jpeg' })] } });
expect(liveRegion).toBeEmptyDOMElement();
act(() => {
jest.advanceTimersByTime(100);
});
expect(liveRegion).toHaveTextContent('file-upload.success-added');

fireEvent.change(input, { target: { files: [new File(['b'], 'b.jpg', { type: 'image/jpeg' })] } });
expect(liveRegion).toBeEmptyDOMElement();
act(() => {
jest.advanceTimersByTime(100);
});
expect(liveRegion).toHaveTextContent('file-upload.success-added');
} finally {
jest.useRealTimers();
}
});
});
64 changes: 41 additions & 23 deletions src/tedi/components/form/file-upload/file-upload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,29 +138,49 @@ export const FileUpload = (props: FileUploadProps): JSX.Element => {
const generatedId = React.useId();
const inputGroup = useOptionalInputGroup?.();
const shouldHideLabel = inputGroup?.hasExternalLabel;
const resolvedId = props.id ?? inputGroup?.inputId ?? generatedId;
const resolvedId = id ?? inputGroup?.inputId ?? generatedId;

const inputRef = React.useRef<HTMLInputElement>(null);
const addButtonRef = React.useRef<HTMLButtonElement>(null);

const fileUploadBEM = cn(styles['tedi-file-upload'], { [styles['tedi-file-upload--disabled']]: disabled }, className);
const helperId = helper?.id ?? (helper || uploadErrorHelper ? `${resolvedId}-helper` : undefined);
const hasUploadError = uploadErrorHelper?.type === 'error';
const baseHelper = helper ?? (hasUploadError ? undefined : uploadErrorHelper);
const errorHelper = hasUploadError ? uploadErrorHelper : undefined;
const baseHelperId = baseHelper ? helper?.id ?? `${resolvedId}-helper` : undefined;
const errorHelperId = errorHelper ? `${resolvedId}-error` : undefined;
const describedBy = [baseHelperId, errorHelperId].filter(Boolean).join(' ') || undefined;

const getFiles = React.useMemo(() => {
return !!files && !!onChange ? files : innerFiles;
}, [files, innerFiles, onChange]);

const focusAddButton = () => addButtonRef.current?.focus();

const handleFileRemove = (file: FileUploadFile) => {
onFileRemove(file);
focusAddButton();
};

const handleClearAndFocus = () => {
handleClear();
focusAddButton();
};

const getFileElement = (file: FileUploadFile, index: number) => {
const fileLabel = file.isValid === false ? `${file.name} (${getLabel('file-upload.failed')})` : file.name;
const isFailed = file.isValid === false;

return (
<li key={index}>
<li key={file.id ?? index}>
<Tag
color={file.isValid === false ? 'danger' : 'primary'}
onClose={!file.isLoading && !disabled && !readOnly ? () => onFileRemove(file) : undefined}
role="presentation"
color={isFailed ? 'danger' : 'primary'}
onClose={!file.isLoading && !disabled && !readOnly ? () => handleFileRemove(file) : undefined}
isLoading={file.isLoading}
aria-label={fileLabel}
closeButtonProps={{ title: `${getLabel('remove')} ${file.name}` }}
>
{file.name}
{isFailed && <span className="sr-only"> ({getLabel('file-upload.failed')})</span>}
</Tag>
</li>
);
Expand All @@ -169,21 +189,18 @@ export const FileUpload = (props: FileUploadProps): JSX.Element => {
const showFiles = () => {
if (getFiles.length > 1) {
return (
<ul className={cn(styles['tedi-file-upload__items'], styles['tedi-file-upload__truncate-list'])}>
<ul className={styles['tedi-file-upload__items']}>
{getFiles.map((file, index) => getFileElement(file, index))}
</ul>
);
} else if (getFiles.length === 1) {
const singleFile = getFiles[0];
const singleLabel =
singleFile.isValid === false ? `${singleFile.name} (${getLabel('file-upload.failed')})` : singleFile.name;
const isFailed = singleFile.isValid === false;

return (
<Text
aria-label={singleLabel}
className={cn(styles['tedi-file-upload__items'], styles['tedi-file-upload__truncate'])}
>
<Text className={cn(styles['tedi-file-upload__items'], styles['tedi-file-upload__items--truncate'])}>
{singleFile.name}
{isFailed && <span className="sr-only"> ({getLabel('file-upload.failed')})</span>}
</Text>
);
}
Expand All @@ -205,7 +222,7 @@ export const FileUpload = (props: FileUploadProps): JSX.Element => {
)}
</div>

<div aria-live="polite" aria-atomic="true" className="sr-only">
<div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
{announcement}
</div>

Expand Down Expand Up @@ -239,7 +256,7 @@ export const FileUpload = (props: FileUploadProps): JSX.Element => {
multiple={multiple}
disabled={disabled}
aria-invalid={!!uploadErrorHelper && uploadErrorHelper.type === 'error'}
aria-describedby={helperId}
aria-describedby={describedBy}
/>
{hasClearButton && getFiles.length > 0 && !disabled && (
<>
Expand All @@ -248,24 +265,26 @@ export const FileUpload = (props: FileUploadProps): JSX.Element => {
visualType="neutral"
iconLeft="close"
disabled={disabled}
onClick={handleClear}
onClick={handleClearAndFocus}
className={styles['tedi-file-upload__button']}
>
{getLabel('clear')}
</Button>
) : (
<ClosingButton onClick={handleClear} iconSize={18} title={getLabel('clear')} />
<ClosingButton onClick={handleClearAndFocus} iconSize={18} title={getLabel('clear')} />
)}
<Separator axis="vertical" height={1.5} spacing={0.5} color="primary" />
</>
)}
<Button
ref={addButtonRef}
visualType="neutral"
iconLeft="file_upload"
disabled={disabled}
onClick={() => inputRef.current?.click()}
className={styles['tedi-file-upload__button']}
size={size}
aria-describedby={describedBy}
>
{getLabel('file-upload.add')}
</Button>
Expand All @@ -275,13 +294,12 @@ export const FileUpload = (props: FileUploadProps): JSX.Element => {
</div>
</div>
)}
{helper ? (
<FeedbackText {...helper} id={helperId} />
) : uploadErrorHelper ? (
<FeedbackText {...uploadErrorHelper} id={helperId} />
) : null}
{baseHelper && <FeedbackText {...baseHelper} id={baseHelperId} />}
{errorHelper && <FeedbackText {...errorHelper} id={errorHelperId} />}
</>
);
};

FileUpload.displayName = 'FileUpload';

export default FileUpload;
6 changes: 5 additions & 1 deletion src/tedi/components/form/textfield/textfield.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,11 @@ $input-padding-right-map: (
}

@each $size in default, small, large {
$selector: if($size == default, '.tedi-textfield', '.tedi-textfield--#{$size}');
$selector: '.tedi-textfield';

@if $size != default {
$selector: '.tedi-textfield--#{$size}';
}
#{$selector} {
$padding: get-padding-right($size, true, false);

Expand Down
12 changes: 11 additions & 1 deletion src/tedi/components/tags/tag/tag.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,15 @@ export interface TagProps extends BreakpointSupport<TagBreakpointProps> {
* @default false
*/
isLoading?: boolean;
/**
* Overrides the Tag's implicit live-region role. Tags default to `role="status"`
* so they are announced by assistive tech. When a Tag is rendered as static
* content inside a list — and additions/removals are announced elsewhere — pass
* e.g. `role="presentation"` so it is not read as a live status, which otherwise
* makes some screen readers (e.g. JAWS) announce the content twice on focus.
* @default 'status'
*/
role?: React.AriaRole;
}

export const Tag = (props: TagProps): JSX.Element => {
Expand All @@ -69,6 +78,7 @@ export const Tag = (props: TagProps): JSX.Element => {
isLoading = false,
color = 'primary',
ellipsis = false,
role = 'status',
...rest
} = getCurrentBreakpointProps<TagProps>(props);

Expand All @@ -81,7 +91,7 @@ export const Tag = (props: TagProps): JSX.Element => {
);

return (
<div className={tagBEM} role="status" aria-live={isLoading ? 'polite' : undefined} {...rest}>
<div className={tagBEM} role={role} aria-live={isLoading ? 'polite' : undefined} {...rest}>
{color === 'danger' && (
<div className={styles['tedi-tag__icon-wrapper']}>
<Icon name="info" color="danger" size={16} className={styles['tedi-tag__icon--error']} />
Expand Down
Loading
Loading