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
9 changes: 9 additions & 0 deletions .changeset/11924-readonly-card-describedby.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@object-ui/app-shell': patch
---

A read-only package's form designer no longer tells a screen reader how to drag a field card (objectui#11924).

In the Form tab of a read-only package, a field card cannot be dragged, but it still pointed `aria-describedby` at the canvas's drag instructions, so a screen reader read "To pick up a field, press Space or Enter. …" (or the zh translation) for a card that does not move. A read-only card now carries no description; a writable card keeps the instructions.

Selecting a field, the card's name and its place in the tab order are unchanged. Nothing is added to the package entry: no export, prop, type member or language-pack key.
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* A read-only package's form designer field card describes no drag —
* objectui#11924.
*
* The defect: dnd-kit's `useDraggable` writes `aria-describedby` onto every
* card, pointing at the canvas's drag instructions ("To pick up a field, press
* Space or Enter. …"), and `useSortable`'s `attributes` cannot take it away. A
* read-only package's card has its sensors off and neither says nor looks
* draggable (objectui#11781, objectui#11872), yet a screen reader still read it
* how to drag it, in EN and in ZH.
*
* The instrument is the card's computed accessible description, through
* jest-dom's `toHaveAccessibleDescription`, which follows `aria-describedby`
* into the hidden instructions node. The writable control below proves the
* instrument sees that node: a writable card's description IS the
* instructions, in both locales.
*
* The instructions are pinned by KEY, resolved through the designer's table,
* so a copy edit does not redden this file and a missing row does (`t` echoes
* a missing key).
*/

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { I18nProvider, createI18n } from '@object-ui/i18n';
import { ObjectFormDesigner } from './ObjectFormDesigner';
import { t, tFormat } from '../metadata-admin/i18n';

const INSTRUCTIONS_KEY = 'engine.studio.formDnd.instructions';

const DRAFT = {
name: 'account',
fields: [
{ name: 'name', type: 'text', label: 'Name' },
{ name: 'phone', type: 'text', label: 'Phone', group: 'contact' },
{ name: 'email', type: 'text', label: 'Email', group: 'contact' },
],
fieldGroups: [{ key: 'contact', label: 'Contact' }],
};
const LABELS = ['Name', 'Phone', 'Email'];

type Locale = 'en-US' | 'zh-CN';
const LOCALES: ReadonlyArray<readonly [Locale, 'en' | 'zh']> = [
['en-US', 'en'],
['zh-CN', 'zh'],
];

/** The row for `key` in `locale`, which must exist: `t` echoes a missing key. */
function row(key: string, locale: Locale): string {
const text = t(key, locale);
expect(text, `no ${locale} row for ${key}`).not.toBe(key);
return text;
}

function renderDesigner({
lang = 'en',
readOnly = false,
onSelectField = () => {},
}: { lang?: 'en' | 'zh'; readOnly?: boolean; onSelectField?: (name: string) => void } = {}) {
const designer = (
<ObjectFormDesigner
draft={DRAFT}
systemFieldNames={new Set()}
onChange={() => {}}
onSelectField={onSelectField}
readOnly={readOnly}
/>
);
if (lang === 'en') return render(designer);
const zh = createI18n({ defaultLanguage: 'zh', detectBrowserLanguage: false, resources: { zh: {} } });
return render(<I18nProvider instance={zh}>{designer}</I18nProvider>);
}

/** Each field card, found by the accessible name the card itself carries. */
function cards(locale: Locale, readOnly: boolean): HTMLElement[] {
const nameKey = readOnly ? 'engine.studio.designer.fieldAriaReadOnly' : 'engine.studio.designer.fieldAria';
return LABELS.map((label) => screen.getByRole('button', { name: tFormat(nameKey, locale, { label }) }));
}

afterEach(cleanup);

describe('a read-only form designer card describes no drag (objectui#11924)', () => {
it.each(LOCALES)('%s: a read-only card carries no drag instructions in its description', (locale, lang) => {
renderDesigner({ lang, readOnly: true });
const instructions = row(INSTRUCTIONS_KEY, locale);
for (const card of cards(locale, true)) {
expect(card).not.toHaveAttribute('aria-describedby');
expect(card).not.toHaveAccessibleDescription(instructions);
// Neither locale's instructions, so a fallback to the other row is red too.
for (const [other] of LOCALES) {
expect(card).not.toHaveAccessibleDescription(expect.stringContaining(row(INSTRUCTIONS_KEY, other)));
}
}
});

it.each(LOCALES)('control, %s: a writable card is described by the drag instructions', (locale, lang) => {
renderDesigner({ lang });
const instructions = row(INSTRUCTIONS_KEY, locale);
for (const card of cards(locale, false)) {
expect(card).toHaveAttribute('aria-describedby');
expect(card).toHaveAccessibleDescription(instructions);
}
});

it('control: a read-only card is still a focusable button that selects its field on click', () => {
const onSelectField = vi.fn();
renderDesigner({ readOnly: true, onSelectField });
const phone = screen.getByRole('button', {
name: tFormat('engine.studio.designer.fieldAriaReadOnly', 'en-US', { label: 'Phone' }),
});
expect(phone).toHaveAttribute('tabindex', '0');
phone.focus();
expect(phone).toHaveFocus();
fireEvent.click(phone);
expect(onSelectField).toHaveBeenCalledWith('phone');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,11 @@ function SortableField({
onClick={onSelect}
{...attributes}
{...listeners}
// objectui#11924 — dnd-kit points every card's `aria-describedby` at the
// canvas's drag instructions, and `useSortable` cannot take it away
// (`disabled` keeps it and adds `aria-disabled`, though a click still
// selects). A read-only card cannot be dragged, so it names none.
aria-describedby={readOnly ? undefined : attributes['aria-describedby']}
aria-label={
readOnly
? tFormat('engine.studio.designer.fieldAriaReadOnly', locale, { label })
Expand Down
Loading