From adb443fd1fc4f8a56b11fc005bdf783a484ffdac Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 14:46:29 +0000 Subject: [PATCH 1/2] fix(fields): ImageField names a nameless image with the translated fields.image.imageAlt, not the literal 'Image' ImageField 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 the alt, the enlarge button's label and the lightbox all stopped at the truthy English word on every locale, and the translated fields.image.imageAlt fallback never ran. Follow the image cell's displayableImagesOf: pass an empty fallback name, so a nameless image reaches the translated alt. A value with its own name keeps it. Claude-Session: https://claude.ai/code/session_01KUxVUa7e39aNjhkKi1gsoy Co-authored-by: Claude --- .../ImageField.imageAlt-10637.test.tsx | 222 ++++++++++++++++++ packages/fields/src/widgets/ImageField.tsx | 10 +- 2 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 packages/fields/src/widgets/ImageField.imageAlt-10637.test.tsx 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); From e051b020c4b70c946b98c9ca0a9b0136c0c02008 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 14:49:11 +0000 Subject: [PATCH 2/2] chore(changeset): declare the ImageField translated-alt fix as a patch for @object-ui/fields (objectui#10637) Claude-Session: https://claude.ai/code/session_01KUxVUa7e39aNjhkKi1gsoy Co-authored-by: Claude --- .changeset/10637-image-alt-translated.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .changeset/10637-image-alt-translated.md 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.