diff --git a/.changeset/10637-image-alt-translated.md b/.changeset/10637-image-alt-translated.md new file mode 100644 index 0000000000..eb46ed90ac --- /dev/null +++ b/.changeset/10637-image-alt-translated.md @@ -0,0 +1,17 @@ +--- +'@object-ui/fields': patch +--- + +fix(fields): the image field widget names an image that carries no name of its own with the translated `fields.image.imageAlt`, not the literal `Image` (objectui#10637) + +`ImageField` read its images with the untranslated literal `'Image'` as the fallback name, so a +value with no name of its own (a bare `sys_file` id, or a `data:` URI) was named `Image` on every +locale. Its `` alt, the read-mode enlarge button's accessible name and the lightbox it opens +all read that word, and the translated `fields.image.imageAlt` fallback never ran. + +`ImageField` now reads its images with no fallback name, as the image cell has since +objectui#10493. A nameless image gets the translated alt ("Image 1" in `en`, numbered by +position) in read mode and edit mode, the enlarge button's name embeds that alt, and the lightbox +gets the translated alt and the translated preview title. A value that carries a name keeps it. +The cropper's output file name for a nameless image is now the widget's own fallback +(`image-0.png` for the first image), where it was `Image`. Stored values do not change. diff --git a/packages/fields/src/widgets/ImageField.imageAlt-10637.test.tsx b/packages/fields/src/widgets/ImageField.imageAlt-10637.test.tsx new file mode 100644 index 0000000000..7bd54d92b5 --- /dev/null +++ b/packages/fields/src/widgets/ImageField.imageAlt-10637.test.tsx @@ -0,0 +1,222 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#10637 — `ImageField` named a nameless image with the untranslated + * literal `'Image'`, so its alt text, its enlarge label and its lightbox never + * reached the translated `fields.image.imageAlt`. + * + * The widget read its images with `readFileValues(value, 'Image')`, and + * `readFileValue` puts that fallback name on every value that carries no name + * of its own (a bare `sys_file` id, a `data:` URI). So `img.name` was the + * truthy English word, and every `img.name || t('fields.image.imageAlt', …)` + * in the widget stopped at it, on every locale. The image CELL did the same + * and objectui#10493 retired the literal there; this file pins the widget + * following the cell: an EMPTY fallback name in, the translated string out. + * + * ── What the assertions compare against ─────────────────────────────────── + * - Every expected string is a PACK VALUE, read from the imported `en` / + * `zh` pack and interpolated here, never a hand-typed string. The two + * locales are both rendered and must differ, so one literal cannot pass + * both legs. + * - THE LIT CONTROL: a value that carries its own name keeps it, in the alt, + * the enlarge label and the lightbox. The caricature "every image is now + * named by the translated string" passes every nameless row and fails it. + */ + +import React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, cleanup, fireEvent, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { I18nProvider } from '@object-ui/i18n'; +import { en, zh } from '@object-ui/i18n/locales'; +import { UploadProvider, type UploadAdapter } from '@object-ui/providers'; +import { ImageField } from './ImageField'; +import type { FieldWidgetComponentProps } from './types'; + +afterEach(() => cleanup()); + +/** + * The three members of a locale pack this file reads. The packs are `as const`, + * so their types are literal and differ per locale: a structural type both + * satisfy, rather than `typeof en`. + */ +type Pack = { fields: { image: { imageAlt: string; enlarge: string; preview: string } } }; + +/** The translated alt, read from the pack: `fields.image.imageAlt` with `{{index}}` filled. */ +const packAlt = (pack: Pack, index: number) => pack.fields.image.imageAlt.replace('{{index}}', String(index)); + +/** The translated enlarge label, read from the pack: `fields.image.enlarge` with `{{name}}` filled. */ +const packEnlarge = (pack: Pack, name: string) => pack.fields.image.enlarge.replace('{{name}}', name); + +/** The card's reproduction value: a bare `sys_file` id, which carries no name. */ +const BARE_ID = 'file_a'; + +/** The other nameless shape: a `data:` URI names nothing since objectui#10493. */ +const DATA_URI = 'data:image/png;base64,iVBORw0KGgo='; + +/** The literal the widget used to name every nameless image with. */ +const LITERAL = 'Image'; + +function renderField( + value: unknown, + language: string, + { readonly = false, multiple = false }: { readonly?: boolean; multiple?: boolean } = {}, +) { + const field = { name: 'cover', type: 'image', multiple } as unknown as FieldWidgetComponentProps['field']; + return render( + + {}} readonly={readonly} /> + , + ); +} + +const imgsOf = (root: ParentNode) => Array.from(root.querySelectorAll('img')); + +/** The read-only thumbnails are buttons that open the lightbox. */ +const enlargeButtonsOf = (root: ParentNode) => Array.from(root.querySelectorAll('button[aria-label]')); + +describe('objectui#10637 — ImageField names a nameless image with the translated fields.image.imageAlt', () => { + it('THE PREMISE — the zh and en packs spell the alt differently, and zh never says Image', () => { + // Every locale leg below is only as strong as this: equal pack values + // would let one literal satisfy both, and a zh value holding the English + // word would let the literal pass the `not.toContain` checks. + expect(packAlt(zh, 1)).not.toBe(packAlt(en, 1)); + expect(packAlt(zh, 1)).not.toContain(LITERAL); + expect(packEnlarge(zh, packAlt(zh, 1))).not.toContain(LITERAL); + }); + + it('THE DEFECT — zh, read-only, a bare id: the alt and the enlarge label are the zh pack strings', () => { + const { container } = renderField(BARE_ID, 'zh', { readonly: true }); + const [img] = imgsOf(container); + expect(img?.getAttribute('src'), 'a bare id still draws from its download endpoint').toBe( + '/api/v1/storage/files/file_a', + ); + expect(img?.getAttribute('alt'), 'the alt is the translated fields.image.imageAlt').toBe(packAlt(zh, 1)); + const [button] = enlargeButtonsOf(container); + expect(button?.getAttribute('aria-label'), 'the enlarge label embeds the translated alt').toBe( + packEnlarge(zh, packAlt(zh, 1)), + ); + expect(img?.getAttribute('alt')).not.toContain(LITERAL); + expect(button?.getAttribute('aria-label')).not.toContain(LITERAL); + }); + + it('THE DEFECT — zh, the lightbox a bare-id thumbnail opens is named by the zh pack strings', () => { + const { container } = renderField(BARE_ID, 'zh', { readonly: true }); + fireEvent.click(enlargeButtonsOf(container)[0]); + const dialog = screen.getByRole('dialog', { name: zh.fields.image.preview }); + const [img] = imgsOf(dialog); + expect(img?.getAttribute('alt'), 'the lightbox image takes the same translated alt').toBe(packAlt(zh, 1)); + expect(dialog.textContent ?? '', 'the dialog names no untranslated literal').not.toContain(LITERAL); + }); + + it('THE DEFECT — zh, edit mode, a bare id: the thumbnail alt is the zh pack string', () => { + const { container } = renderField(BARE_ID, 'zh'); + const [img] = imgsOf(container); + expect(img?.getAttribute('alt')).toBe(packAlt(zh, 1)); + }); + + it('THE DEFECT — zh, a data-URI value reads the same translated alt', () => { + const { container } = renderField(DATA_URI, 'zh', { readonly: true }); + const [img] = imgsOf(container); + expect(img?.getAttribute('src'), 'the stored value is drawn unchanged').toBe(DATA_URI); + expect(img?.getAttribute('alt')).toBe(packAlt(zh, 1)); + expect(enlargeButtonsOf(container)[0]?.getAttribute('aria-label')).toBe(packEnlarge(zh, packAlt(zh, 1))); + }); + + it('THE DEFECT — zh, several nameless images are numbered by position, in both modes and in the lightbox', () => { + const value = [BARE_ID, 'file_b']; + const readonlyView = renderField(value, 'zh', { readonly: true, multiple: true }); + expect(imgsOf(readonlyView.container).map((img) => img.getAttribute('alt'))).toEqual([ + packAlt(zh, 1), + packAlt(zh, 2), + ]); + expect(enlargeButtonsOf(readonlyView.container).map((b) => b.getAttribute('aria-label'))).toEqual([ + packEnlarge(zh, packAlt(zh, 1)), + packEnlarge(zh, packAlt(zh, 2)), + ]); + fireEvent.click(enlargeButtonsOf(readonlyView.container)[1]); + const dialog = screen.getByRole('dialog', { name: zh.fields.image.preview }); + expect(imgsOf(dialog)[0]?.getAttribute('alt')).toBe(packAlt(zh, 2)); + cleanup(); + + const editView = renderField(value, 'zh', { multiple: true }); + expect(imgsOf(editView.container).map((img) => img.getAttribute('alt'))).toEqual([ + packAlt(zh, 1), + packAlt(zh, 2), + ]); + }); + + it('THE en LEG — en, read-only, a bare id: the alt and the enlarge label are the en pack strings', () => { + const { container } = renderField(BARE_ID, 'en', { readonly: true }); + const [img] = imgsOf(container); + expect(img?.getAttribute('alt')).toBe(packAlt(en, 1)); + expect(enlargeButtonsOf(container)[0]?.getAttribute('aria-label')).toBe(packEnlarge(en, packAlt(en, 1))); + }); + + it('THE LIT CONTROL — a value with its own name keeps it in the alt, the enlarge label and the lightbox', () => { + const value = { id: BARE_ID, name: 'cover.png', url: 'https://cdn.example/cover.png', mimeType: 'image/png' }; + const { container } = renderField(value, 'zh', { readonly: true }); + const [img] = imgsOf(container); + expect(img?.getAttribute('alt')).toBe('cover.png'); + const [button] = enlargeButtonsOf(container); + expect(button?.getAttribute('aria-label')).toBe(packEnlarge(zh, 'cover.png')); + fireEvent.click(button); + const dialog = screen.getByRole('dialog', { name: 'cover.png' }); + expect(imgsOf(dialog)[0]?.getAttribute('alt')).toBe('cover.png'); + }); + + it('THE LIT CONTROL — an https URL is still named by its last segment', () => { + const { container } = renderField('https://cdn.example.com/a/photo.png', 'zh', { readonly: true }); + expect(imgsOf(container)[0]?.getAttribute('alt')).toBe('photo.png'); + }); + + it('THE LIT CONTROL — a just-uploaded image stored as a bare id keeps its upload name', async () => { + // The upload is submitted as a bare id, which carries no name; the widget's + // recent-upload views fill the name back in by id. The empty fallback name + // must not reach past them. + const adapter: UploadAdapter = { + name: 'reference', + upload: async (f: File | Blob) => ({ + url: 'https://cdn.example/uploaded.png', + name: (f as File).name, + size: f.size, + mimeType: 'image/png', + meta: { fileId: 'file_new' }, + }), + }; + const submitted: unknown[] = []; + function Harness() { + const [value, setValue] = React.useState(null); + const field = { name: 'cover', type: 'image' } as unknown as FieldWidgetComponentProps['field']; + return ( + { + submitted.push(next); + setValue(next); + }} + /> + ); + } + const { container } = render( + + + + + , + ); + const input = container.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(input, { target: { files: [new File(['x'], 'cover.png', { type: 'image/png' })] } }); + await waitFor(() => expect(submitted).toEqual(['file_new'])); + const [img] = imgsOf(container); + expect(img?.getAttribute('src')).toBe('https://cdn.example/uploaded.png'); + expect(img?.getAttribute('alt')).toBe('cover.png'); + }); +}); diff --git a/packages/fields/src/widgets/ImageField.tsx b/packages/fields/src/widgets/ImageField.tsx index 3ec2f07ecd..ec515af10d 100644 --- a/packages/fields/src/widgets/ImageField.tsx +++ b/packages/fields/src/widgets/ImageField.tsx @@ -81,7 +81,15 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange // Derived value + memoized handlers must run before the readonly early return // so hook order stays stable across renders. const images = value ? (Array.isArray(value) ? value : [value]) : []; - const views = withRecentUploads(readFileValues(value, 'Image'), recent); + // An EMPTY fallback name, as the image cell's `displayableImagesOf` passes: + // a value that carries no name of its own (a bare id, a `data:` URI) stays + // nameless, so every reader below falls through to its own fallback — the + // translated `fields.image.imageAlt` for the alt, the enlarge label and the + // lightbox, and `image-${index}.png` for the cropper's output file name. + // The literal `'Image'` passed here used to name every such image + // `Image` on every locale, leaving the translated alt unreachable + // (objectui#10637). + const views = withRecentUploads(readFileValues(value, ''), recent); const remember = useCallback((result: any, originalName: string) => { const view = uploadResultView(result, originalName);