From 0bd8c618e323737a60bf10008ea5e145302768e3 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Fri, 3 Jul 2026 09:38:05 +0300 Subject: [PATCH 1/4] fix(file-upload): re-upload same file and A11y improvements #722 --- skills/tedi-react/references/components.md | 4 +- .../form/file-upload/file-upload.stories.tsx | 5 ++ .../form/file-upload/file-upload.tsx | 6 +++ .../form/file-upload/file-upload.spec.tsx | 48 +++++++++++++++++++ .../form/file-upload/file-upload.tsx | 43 +++++++++++------ src/tedi/helpers/hooks/use-file-upload.ts | 1 + 6 files changed, 91 insertions(+), 16 deletions(-) diff --git a/skills/tedi-react/references/components.md b/skills/tedi-react/references/components.md index 9990a8352..4774a73ff 100644 --- a/skills/tedi-react/references/components.md +++ b/skills/tedi-react/references/components.md @@ -1797,7 +1797,9 @@ Import from `@tedi-design-system/react/community`. These are community-contribut - `id: string`, `items: ChoiceGroupItemProps[]`, `inputType?: 'radio' | 'checkbox'` - `type?: 'light' | 'selector' | 'filter' | 'default'`, `value?`, `onChange?` -### FileUpload +### 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?` diff --git a/src/community/components/form/file-upload/file-upload.stories.tsx b/src/community/components/form/file-upload/file-upload.stories.tsx index b45eedcf9..2af718213 100644 --- a/src/community/components/form/file-upload/file-upload.stories.tsx +++ b/src/community/components/form/file-upload/file-upload.stories.tsx @@ -6,6 +6,11 @@ import FileUpload from './file-upload'; const meta: Meta = { component: FileUpload, title: 'Community/Form/FileUpload', + parameters: { + status: { + type: ['deprecated', 'ExistsInTediReady'], + }, + }, }; export default meta; diff --git a/src/community/components/form/file-upload/file-upload.tsx b/src/community/components/form/file-upload/file-upload.tsx index 228dde59e..ba8bc790a 100644 --- a/src/community/components/form/file-upload/file-upload.tsx +++ b/src/community/components/form/file-upload/file-upload.tsx @@ -25,6 +25,9 @@ export interface RejectedFile { file: File; } +/** + * @deprecated Use FileUpload from `@tedi-design-system/react/tedi` instead. + */ export interface FileUploadProps extends FormLabelProps { /** * Additional classes. @@ -113,6 +116,9 @@ const getUploadErrorHelperText = (rejectedFiles: RejectedFile[], getLabel: ILabe .join('. '); }; +/** + * @deprecated Use FileUpload from `@tedi-design-system/react/tedi` instead. + */ export const FileUpload = (props: FileUploadProps): JSX.Element => { const { getLabel } = useLabels(); const { diff --git a/src/tedi/components/form/file-upload/file-upload.spec.tsx b/src/tedi/components/form/file-upload/file-upload.spec.tsx index 96c810c0f..cc568da7f 100644 --- a/src/tedi/components/form/file-upload/file-upload.spec.tsx +++ b/src/tedi/components/form/file-upload/file-upload.spec.tsx @@ -40,6 +40,54 @@ 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(); + 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(); + 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( + + ); + 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(); + expect(screen.getByText(/file-upload.failed/i)).toBeInTheDocument(); + }); + + it('moves focus to the add button after removing a file', () => { + render( + + ); + 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(); const input = screen.getByLabelText(/Upload files/i); diff --git a/src/tedi/components/form/file-upload/file-upload.tsx b/src/tedi/components/form/file-upload/file-upload.tsx index 308fe6871..f5b3194c8 100644 --- a/src/tedi/components/form/file-upload/file-upload.tsx +++ b/src/tedi/components/form/file-upload/file-upload.tsx @@ -138,9 +138,10 @@ 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(null); + const addButtonRef = React.useRef(null); const fileUploadBEM = cn(styles['tedi-file-upload'], { [styles['tedi-file-upload--disabled']]: disabled }, className); const helperId = helper?.id ?? (helper || uploadErrorHelper ? `${resolvedId}-helper` : undefined); @@ -149,18 +150,31 @@ export const FileUpload = (props: FileUploadProps): JSX.Element => { 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 ( -
  • +
  • onFileRemove(file) : undefined} + 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 && ({getLabel('file-upload.failed')})}
  • ); @@ -169,21 +183,18 @@ export const FileUpload = (props: FileUploadProps): JSX.Element => { const showFiles = () => { if (getFiles.length > 1) { return ( -
      +
        {getFiles.map((file, index) => getFileElement(file, index))}
      ); } 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 ( - + {singleFile.name} + {isFailed && ({getLabel('file-upload.failed')})} ); } @@ -248,24 +259,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')} ) : ( - + )} )} diff --git a/src/tedi/helpers/hooks/use-file-upload.ts b/src/tedi/helpers/hooks/use-file-upload.ts index a73e38bd2..49d2c5cf1 100644 --- a/src/tedi/helpers/hooks/use-file-upload.ts +++ b/src/tedi/helpers/hooks/use-file-upload.ts @@ -227,6 +227,7 @@ export const useFileUpload = (props: UseFileUploadProps) => { } }, announcementTimeout); + event.target.value = ''; if (fileInputRef.current) { fileInputRef.current.value = ''; } From b4d60999a4229a3a7730faa65da028cb02d68b45 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:24:08 +0300 Subject: [PATCH 2/4] fix(text-field): replace deprecated sass if() with @if #722 --- src/tedi/components/form/textfield/textfield.module.scss | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/tedi/components/form/textfield/textfield.module.scss b/src/tedi/components/form/textfield/textfield.module.scss index 526cb669b..0500d9481 100644 --- a/src/tedi/components/form/textfield/textfield.module.scss +++ b/src/tedi/components/form/textfield/textfield.module.scss @@ -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); From 04ca935828c6688a2808d7cdf3e4d1acd94e7c28 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:54:16 +0300 Subject: [PATCH 3/4] fix(file-upload): Improve screen reader support for file actions #722 --- .../form/file-upload/file-upload.module.scss | 8 +++- .../form/file-upload/file-upload.spec.tsx | 16 +++---- .../form/file-upload/file-upload.tsx | 21 +++++---- src/tedi/components/tags/tag/tag.tsx | 12 ++++- src/tedi/helpers/hooks/use-file-upload.ts | 47 ++++++++++--------- .../providers/label-provider/labels-map.ts | 17 +++++++ 6 files changed, 80 insertions(+), 41 deletions(-) diff --git a/src/tedi/components/form/file-upload/file-upload.module.scss b/src/tedi/components/form/file-upload/file-upload.module.scss index 2c5315730..cb653f4a2 100644 --- a/src/tedi/components/form/file-upload/file-upload.module.scss +++ b/src/tedi/components/form/file-upload/file-upload.module.scss @@ -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; @@ -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; } } diff --git a/src/tedi/components/form/file-upload/file-upload.spec.tsx b/src/tedi/components/form/file-upload/file-upload.spec.tsx index cc568da7f..94c0b6e30 100644 --- a/src/tedi/components/form/file-upload/file-upload.spec.tsx +++ b/src/tedi/components/form/file-upload/file-upload.spec.tsx @@ -95,7 +95,7 @@ describe('FileUpload component', () => { 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(); }); }); @@ -106,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', () => { @@ -158,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', () => { @@ -167,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', () => { @@ -204,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', () => { @@ -283,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', () => { @@ -389,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 }); diff --git a/src/tedi/components/form/file-upload/file-upload.tsx b/src/tedi/components/form/file-upload/file-upload.tsx index f5b3194c8..736949719 100644 --- a/src/tedi/components/form/file-upload/file-upload.tsx +++ b/src/tedi/components/form/file-upload/file-upload.tsx @@ -144,7 +144,12 @@ export const FileUpload = (props: FileUploadProps): JSX.Element => { const addButtonRef = React.useRef(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; @@ -168,6 +173,7 @@ export const FileUpload = (props: FileUploadProps): JSX.Element => { return (
    • handleFileRemove(file) : undefined} isLoading={file.isLoading} @@ -216,7 +222,7 @@ export const FileUpload = (props: FileUploadProps): JSX.Element => { )} -
      +
      {announcement}
      @@ -250,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 && ( <> @@ -278,7 +284,7 @@ export const FileUpload = (props: FileUploadProps): JSX.Element => { onClick={() => inputRef.current?.click()} className={styles['tedi-file-upload__button']} size={size} - aria-describedby={helperId} + aria-describedby={describedBy} > {getLabel('file-upload.add')} @@ -288,11 +294,8 @@ export const FileUpload = (props: FileUploadProps): JSX.Element => {
      )} - {helper ? ( - - ) : uploadErrorHelper ? ( - - ) : null} + {baseHelper && } + {errorHelper && } ); }; diff --git a/src/tedi/components/tags/tag/tag.tsx b/src/tedi/components/tags/tag/tag.tsx index 2ec90246c..7292f35da 100644 --- a/src/tedi/components/tags/tag/tag.tsx +++ b/src/tedi/components/tags/tag/tag.tsx @@ -57,6 +57,15 @@ export interface TagProps extends BreakpointSupport { * @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 => { @@ -69,6 +78,7 @@ export const Tag = (props: TagProps): JSX.Element => { isLoading = false, color = 'primary', ellipsis = false, + role = 'status', ...rest } = getCurrentBreakpointProps(props); @@ -81,7 +91,7 @@ export const Tag = (props: TagProps): JSX.Element => { ); return ( -
      +
      {color === 'danger' && (
      diff --git a/src/tedi/helpers/hooks/use-file-upload.ts b/src/tedi/helpers/hooks/use-file-upload.ts index 49d2c5cf1..c0a1b1954 100644 --- a/src/tedi/helpers/hooks/use-file-upload.ts +++ b/src/tedi/helpers/hooks/use-file-upload.ts @@ -117,13 +117,27 @@ export const useFileUpload = (props: UseFileUploadProps) => { const [announcement, setAnnouncement] = React.useState(''); const isMounted = React.useRef(true); + const announcementTimer = React.useRef>(); useEffect(() => { return () => { isMounted.current = false; + if (announcementTimer.current) clearTimeout(announcementTimer.current); }; }, []); + const announce = React.useCallback( + (message: string) => { + if (!message || !isMounted.current) return; + setAnnouncement(message); + if (announcementTimer.current) clearTimeout(announcementTimer.current); + announcementTimer.current = setTimeout(() => { + if (isMounted.current) setAnnouncement(''); + }, announcementTimeout); + }, + [announcementTimeout] + ); + const fileInputRef = React.useRef(null); const validFileType = (file: File) => { @@ -197,35 +211,20 @@ export const useFileUpload = (props: UseFileUploadProps) => { setInnerFiles(newFiles); } onChange?.(newFiles); - - if (rejectedFiles.length === 0) { - const count = newFiles.length - actualFiles.length; - const message = - getLabel('file-upload.success-added', count.toString()) || `${count} file(s) added successfully`; - - if (isMounted.current) { - setAnnouncement(message); - } - } } if (rejectedFiles.length) { - const failedNames = rejectedFiles.map((r) => r.file.name).join(', '); - const errorMessage = getLabel('file-upload.failed-some', failedNames) || `Upload failed for: ${failedNames}`; - - if (isMounted.current) { - setAnnouncement(errorMessage); - } - setUploadErrorHelper({ type: 'error', text: getUploadErrorHelperText(rejectedFiles) }); + const errorText = getUploadErrorHelperText(rejectedFiles); + setUploadErrorHelper({ type: 'error', text: errorText }); + announce(errorText); } else { setUploadErrorHelper(getDefaultHelpers({ accept, maxSize }, getLabel)); - } - setTimeout(() => { - if (isMounted.current) { - setAnnouncement(''); + if (newFiles.length > 0) { + const count = newFiles.length - actualFiles.length; + announce(getLabel('file-upload.success-added', count.toString())); } - }, announcementTimeout); + } event.target.value = ''; if (fileInputRef.current) { @@ -247,6 +246,8 @@ export const useFileUpload = (props: UseFileUploadProps) => { setUploadErrorHelper(getDefaultHelpers({ accept, maxSize }, getLabel)); } + announce(getLabel('file-upload.removed', file.name ?? '')); + if (fileInputRef.current) { fileInputRef.current.value = ''; } @@ -262,6 +263,8 @@ export const useFileUpload = (props: UseFileUploadProps) => { onChange?.([]); setUploadErrorHelper(getDefaultHelpers({ accept, maxSize }, getLabel)); + announce(getLabel('file-upload.cleared')); + if (fileInputRef.current) { fileInputRef.current.value = ''; } diff --git a/src/tedi/providers/label-provider/labels-map.ts b/src/tedi/providers/label-provider/labels-map.ts index 06b907cb3..a45570493 100644 --- a/src/tedi/providers/label-provider/labels-map.ts +++ b/src/tedi/providers/label-provider/labels-map.ts @@ -438,6 +438,23 @@ export const labelsMap = validateDefaultLabels({ en: (files: string) => `File(s) ${files} have the wrong extension`, ru: (files: string) => `Файл(ы) ${files} имеют неправильное расширение`, }, + + 'file-upload.removed': { + description: 'Announced to screen readers when a file is removed', + components: ['FileUpload'], + et: (file: string) => `Fail ${file} eemaldatud`, + en: (file: string) => `File ${file} removed`, + ru: (file: string) => `Файл ${file} удалён`, + }, + + 'file-upload.cleared': { + description: 'Announced to screen readers when all files are removed', + components: ['FileUpload'], + et: 'Kõik failid eemaldatud', + en: 'All files removed', + ru: 'Все файлы удалены', + }, + 'file-dropzone.label': { description: 'Default label for dropzone', components: ['FileDropzone'], From b5d9005a6e7a9fab75a4f77c5b51059e23201617 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:24:29 +0300 Subject: [PATCH 4/4] fix(file-upload): cr fixes #722 --- .../form/file-upload/file-upload.spec.tsx | 27 ++++++++++++++++++- .../form/file-upload/file-upload.tsx | 2 ++ src/tedi/helpers/hooks/use-file-upload.ts | 25 +++++++++++------ 3 files changed, 45 insertions(+), 9 deletions(-) diff --git a/src/tedi/components/form/file-upload/file-upload.spec.tsx b/src/tedi/components/form/file-upload/file-upload.spec.tsx index 94c0b6e30..eda7b8645 100644 --- a/src/tedi/components/form/file-upload/file-upload.spec.tsx +++ b/src/tedi/components/form/file-upload/file-upload.spec.tsx @@ -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'; @@ -399,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(); + 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(); + } + }); }); diff --git a/src/tedi/components/form/file-upload/file-upload.tsx b/src/tedi/components/form/file-upload/file-upload.tsx index 736949719..1c85589e6 100644 --- a/src/tedi/components/form/file-upload/file-upload.tsx +++ b/src/tedi/components/form/file-upload/file-upload.tsx @@ -300,4 +300,6 @@ export const FileUpload = (props: FileUploadProps): JSX.Element => { ); }; +FileUpload.displayName = 'FileUpload'; + export default FileUpload; diff --git a/src/tedi/helpers/hooks/use-file-upload.ts b/src/tedi/helpers/hooks/use-file-upload.ts index c0a1b1954..79e029c86 100644 --- a/src/tedi/helpers/hooks/use-file-upload.ts +++ b/src/tedi/helpers/hooks/use-file-upload.ts @@ -93,6 +93,8 @@ const getDefaultHelpers = ( const generateId = () => `${Date.now()}-${Math.random().toString(36).slice(2)}`; +const ANNOUNCEMENT_RESET_DELAY = 100; + export const useFileUpload = (props: UseFileUploadProps) => { const { getLabel } = useLabels(); const { @@ -117,23 +119,30 @@ export const useFileUpload = (props: UseFileUploadProps) => { const [announcement, setAnnouncement] = React.useState(''); const isMounted = React.useRef(true); - const announcementTimer = React.useRef>(); + const announceShowTimer = React.useRef>(); + const announceHideTimer = React.useRef>(); useEffect(() => { return () => { isMounted.current = false; - if (announcementTimer.current) clearTimeout(announcementTimer.current); + if (announceShowTimer.current) clearTimeout(announceShowTimer.current); + if (announceHideTimer.current) clearTimeout(announceHideTimer.current); }; }, []); const announce = React.useCallback( (message: string) => { if (!message || !isMounted.current) return; - setAnnouncement(message); - if (announcementTimer.current) clearTimeout(announcementTimer.current); - announcementTimer.current = setTimeout(() => { - if (isMounted.current) setAnnouncement(''); - }, announcementTimeout); + setAnnouncement(''); + if (announceShowTimer.current) clearTimeout(announceShowTimer.current); + if (announceHideTimer.current) clearTimeout(announceHideTimer.current); + announceShowTimer.current = setTimeout(() => { + if (!isMounted.current) return; + setAnnouncement(message); + announceHideTimer.current = setTimeout(() => { + if (isMounted.current) setAnnouncement(''); + }, announcementTimeout); + }, ANNOUNCEMENT_RESET_DELAY); }, [announcementTimeout] ); @@ -221,7 +230,7 @@ export const useFileUpload = (props: UseFileUploadProps) => { setUploadErrorHelper(getDefaultHelpers({ accept, maxSize }, getLabel)); if (newFiles.length > 0) { - const count = newFiles.length - actualFiles.length; + const count = newFiles.filter((file) => !actualFiles.includes(file)).length; announce(getLabel('file-upload.success-added', count.toString())); } }