diff --git a/skills/tedi-react/references/components.md b/skills/tedi-react/references/components.md index 1d23a5b2c..b4b9bd070 100644 --- a/skills/tedi-react/references/components.md +++ b/skills/tedi-react/references/components.md @@ -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?` diff --git a/src/community/components/form/file-upload/file-upload.tsx b/src/community/components/form/file-upload/file-upload.tsx index 1f130867a..605cb689a 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. 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 96c810c0f..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'; @@ -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); @@ -47,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(); }); }); @@ -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', () => { @@ -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', () => { @@ -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', () => { @@ -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', () => { @@ -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', () => { @@ -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 }); @@ -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(); + 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 308fe6871..1c85589e6 100644 --- a/src/tedi/components/form/file-upload/file-upload.tsx +++ b/src/tedi/components/form/file-upload/file-upload.tsx @@ -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(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); + 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 ( -
  • +
  • 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 && ({getLabel('file-upload.failed')})}
  • ); @@ -169,21 +189,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')})} ); } @@ -205,7 +222,7 @@ export const FileUpload = (props: FileUploadProps): JSX.Element => { )} -
      +
      {announcement}
      @@ -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 && ( <> @@ -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')} ) : ( - + )} )} @@ -275,13 +294,12 @@ export const FileUpload = (props: FileUploadProps): JSX.Element => {
      )} - {helper ? ( - - ) : uploadErrorHelper ? ( - - ) : null} + {baseHelper && } + {errorHelper && } ); }; +FileUpload.displayName = 'FileUpload'; + export default FileUpload; 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); 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 a73e38bd2..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,13 +119,34 @@ export const useFileUpload = (props: UseFileUploadProps) => { const [announcement, setAnnouncement] = React.useState(''); const isMounted = React.useRef(true); + const announceShowTimer = React.useRef>(); + const announceHideTimer = React.useRef>(); useEffect(() => { return () => { isMounted.current = false; + if (announceShowTimer.current) clearTimeout(announceShowTimer.current); + if (announceHideTimer.current) clearTimeout(announceHideTimer.current); }; }, []); + const announce = React.useCallback( + (message: string) => { + if (!message || !isMounted.current) return; + 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] + ); + const fileInputRef = React.useRef(null); const validFileType = (file: File) => { @@ -197,36 +220,22 @@ 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.filter((file) => !actualFiles.includes(file)).length; + announce(getLabel('file-upload.success-added', count.toString())); } - }, announcementTimeout); + } + event.target.value = ''; if (fileInputRef.current) { fileInputRef.current.value = ''; } @@ -246,6 +255,8 @@ export const useFileUpload = (props: UseFileUploadProps) => { setUploadErrorHelper(getDefaultHelpers({ accept, maxSize }, getLabel)); } + announce(getLabel('file-upload.removed', file.name ?? '')); + if (fileInputRef.current) { fileInputRef.current.value = ''; } @@ -261,6 +272,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 1b80fe04b..09b4cf9d2 100644 --- a/src/tedi/providers/label-provider/labels-map.ts +++ b/src/tedi/providers/label-provider/labels-map.ts @@ -466,6 +466,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'],