Skip to content

Commit da1e09f

Browse files
fix(app-shell): flow screen date, datetime and boolean fields use the platform controls (#26685)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb9pHWxyHbH8tpqHHyYnynkk)_ Fixes #26579 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么:** 流程屏幕(screen flow)里的 `date` / `datetime` 字段改用记录表单同一个平台日期选择器(按界面语言显示格式、日历弹层、清除按钮);`boolean` 字段改用 ActionParamDialog 同款的行内勾选行(勾选框在左、标签在右、帮助文字在下)。 - **为什么改:** 流程屏幕是最后一个还在用浏览器原生日期输入框的表单面,中文界面下显示 `mm/dd/yyyy`;布尔字段是标签下面孤零零一个圆形勾选框,看起来像空的单选按钮。hotclm 的「发起合同」向导第一屏就是这样。 - **风险与回滚:** 写回流程的值形状不变(日期 `YYYY-MM-DD`,日期时间为 ISO 时间戳,清空为空串);只有 `datetime` 从前是自由文本框,现在写 ISO 时间戳。三个控件都经懒加载,首次出现有一帧占位。回滚即 revert 本 PR。 - **先看:** `ui/packages/app-shell/src/views/ScreenView.tsx` 中 `ScreenFieldInput` 新增的 date/datetime 与 boolean 两段,以及 `ScreenView` 里 `isBooleanScreenField` 的行内行布局。 - **两分钟验证:** 在 `ui/` 下运行 `pnpm exec vitest run packages/app-shell/src/views/ScreenView.platformControls-26579.test.tsx`,应见 13 个测试全部通过。 - **需要你做的:** 无,席位落地。 ## What changes `ScreenFieldInput` (`ui/packages/app-shell/src/views/ScreenView.tsx`) drew a flow screen's `date` field as the browser's native `type="date"` input, a `datetime` field as a bare text box, and a `boolean` / `checkbox` field as a lone `Checkbox` under the generic label. It now reuses the platform controls, with no third widget: - **`date` / `datetime`** render the record form's picker, `DatePickerField` / `DateTimePickerField` (objectui#12622), reached through `getLazyFieldWidget('date' | 'datetime')` exactly as `ActionParamDialog`'s date params reach it. The text box is in the display locale's format, with the calendar popover, clear and today / tomorrow shortcuts. The value handed to the runner is the stored shape: `YYYY-MM-DD` for `date` (as the native input wrote), an ISO instant for `datetime`, and `''` when cleared. - **`boolean` / `checkbox`** render `ActionParamDialog`'s inline row: the form's `BooleanField` in its checkbox face (`getLazyFieldWidget('boolean')` with `widget: 'checkbox'`, the field `paramToField` hands the dialog), the label beside the control and the help text under the row. The host passes the id, so the widget draws no second `sr-only` label. A boolean field that declares `options` is still a select with its label above. - `aria-required` and `aria-describedby` (the `inlineHelpText` link) ride the state channel to the new controls as before. - The `min` / `max` comment drops the date-input clause: no arm draws a native date input any more. The real producer is the one the card names, `ScreenFieldInput`; nothing outside `ScreenView.tsx` changes in source. `FlowRunner.tsx` is untouched. ## Why Record forms moved to the platform date picker (objectui#12622) and action dialogs, list filters and inline editors followed. Flow screens did not, so a screen flow (the hotclm 发起合同 wizard was the measured case) showed `mm/dd/yyyy` under zh-CN and a boolean that read like an empty radio button. ## Tests - New `ScreenView.platformControls-26579.test.tsx` (13 tests): the date picker replaces `input[type=date]`, shows `10/16/2026` in en and `2026/10/16` with the `YYYY/MM/DD` placeholder in zh, writes the ISO day for typed text and `''` on clear, and keeps `aria-required` and the help description; the date-time picker writes an ISO instant; the boolean row puts the label beside the checkbox (control first), a `checkbox` hint draws the same row, ticking answers `true` and clicking the label unticks to `false`, only a real `true` is checked, the required marker and help text sit where they should, one label per control, and a boolean with `options` stays a select (control). - Updated pins that read the old controls: `ScreenView.boundHelpReference-9248.test.tsx` (the date bound test now reads the picker's text box) and four tests across `ScreenView.requiredMarker-10367.test.tsx`, `ScreenPreview.test.tsx`, `ScreenPreview.debugRunScope-10743.test.tsx` and `__tests__/FlowRunner.recordLink-25872.test.tsx` that clicked the checkbox synchronously now wait for the lazy control with `findByRole`. Each file imports `@object-ui/fields` at module scope per ui/AGENTS.md's 测试纪律, so the lazy factory resolves before the `findBy` budget. ## Acceptance notes - `datetime` was a free text box before, so whatever was typed went to the flow as a string. It now writes an ISO instant, the shape the record form writes for a `datetime` field. No in-tree example declares a `datetime` screen field; the card's Expected names it. - The boolean control is the dialog's checkbox, not the record form's switch: the card's Expected allows either, and the checkbox keeps the `role="checkbox"` every existing flow-screen test and the runner's `required` check (an unticked `false` is an answer) already read. - The three controls are lazy, like the lookup and file arms beside them, so a test that clicks one in the same tick as the render must wait for it. The four tests above were the only ones in the flow-screen test set that did. ## Verification All readings at head `ac55d85e` unless stated. <details><summary>Package tests (ui, under os-verify-lock)</summary> - `cd ui && pnpm exec vitest run --maxWorkers=1` on the three ScreenView files (`platformControls-26579`, `boundHelpReference-9248`, `requiredMarker-10367`): `Test Files 3 passed (3)`, `Tests 30 passed (30)`, lock VERDICT command-exit 0. - `cd ui && pnpm exec vitest run --maxWorkers=2` on every ui test file that names `FlowRunner`, `ScreenView`, `ScreenPreview` or `ScreenFieldInput` (`git grep -l` over `packages/**` and `apps/**` test files: 114 files): `Test Files 114 passed (114)`, `Tests 1054 passed (1054)`, lock VERDICT command-exit 0 (held 6m19s). - Declared narrowing: the whole `packages/app-shell/` run (1757 test files) was stopped after about an hour, with three failures reported by then, all the synchronous checkbox clicks fixed above and green in the narrowed run. The full package is CI's `Test UI`. </details> <details><summary>Ablation (ScreenView.tsx, mutation proven on disk and restored by blob hash)</summary> - Boolean row: anchor `if (isBooleanScreenField(f)) {` replaced (anchor 1 to 0, blob `6e3f3192eb18` to `554245174c0a`): `Tests 2 failed | 11 passed (13)`, the "label BESIDE the checkbox" and "required state and marker, help text under the row" pins. Restored: blob == HEAD and `git diff HEAD` empty. - Date / date-time arm: anchor `if (kind === 'date' || kind === 'datetime') {` replaced (anchor 1 to 0, blob `6e3f3192eb18` to `1b5142458047`): `Tests 5 failed | 8 passed (13)`, every picker pin. The required-state and help pin stays green there, as it should: the native input carried both too. Restored the same way. - No build or `dist/` leg: vitest resolves `@object-ui/app-shell` to `src` through the ui alias map. </details> <details><summary>Type check and lint</summary> - `pnpm exec turbo run type-check --filter=@object-ui/app-shell --concurrency=2` under the lock: `Tasks: 64 successful, 64 total`; app-shell's script runs `tsc --noEmit` and `tsc -p tsconfig.test.json`, so the test files are compiled. - `cd ui && pnpm exec eslint --no-inline-config` on the touched ui files: 0 errors; `ScreenView.tsx` carries the same 15 warnings `main`'s copy does (pre-existing `react-refresh` and `no-explicit-any`). </details> <details><summary>Derived gates (dispatch-gates --commands, reconciled with --ran)</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 34 commands, each run once with its exit code captured before any pipe (the two lock-bound ones under `os-verify-lock.sh`); all 34 `exit 0`. The PR-body gates read this body through `GITHUB_EVENT_PATH`. - `dispatch-gates --ran`: `34 derived famil(ies) accounted for — 34 run, 0 NOT-MEASURED`. - Also run: `check-partof-closing-keyword.mjs` with `PR_BODY`: exit 0. `check-single-claim-paths.mjs` needs a PR number: left to CI's PR-body guards. - The type-check lanes, the 11 wide-population families and the artifact rosters the derivation does not place are CI's; their check-runs on this PR are the reading. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No `packages/spec` change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (eslint on the touched files: 0 errors, warning count unchanged from `main`.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jTWL2QojsqgqvgPRkw)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4373103 commit da1e09f

8 files changed

Lines changed: 326 additions & 40 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): flow screen date, date-time and boolean fields use the platform controls
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A screen flow's input screen was the last form surface still drawing the browser's native date input, so a `date` field showed the machine's format (`mm/dd/yyyy` under a Chinese console), a `datetime` field was a bare text box, and a `boolean` field was a lone checkbox on the line under its label, which read like an empty radio button.
11+
12+
What changes:
13+
14+
- A `date` or `datetime` screen field is now the record form's date picker: a text box in the display locale's format, a calendar popover, a clear button and the today / tomorrow shortcuts. The value it hands the flow is the stored shape: `YYYY-MM-DD` for a date and an ISO instant for a date-time, and `''` when cleared, as the native date input wrote.
15+
- A `boolean` (or `checkbox`) screen field is now the inline row the action-parameter dialog draws: the checkbox with its label beside it, and the help text under the row.
16+
17+
What to do: nothing. No flow metadata changes.

‎ui/packages/app-shell/src/views/ScreenView.boundHelpReference-9248.test.tsx‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,10 @@ describe('objectui#9248 — `min` / `max` land on the numeric input', () => {
5454
expect(input).not.toHaveAttribute('max');
5555
});
5656

57-
it('a bound declared on a non-numeric input is not written onto it (a date input would read it as a date)', () => {
58-
const { container } = renderScreen([{ name: 'due', label: 'Due', type: 'date', min: 1, max: 10 }]);
59-
const input = container.querySelector('#ff-due');
60-
expect(input).toHaveAttribute('type', 'date');
57+
it('a bound declared on a non-numeric input is not written onto it (the date picker reads no numeric bound)', async () => {
58+
renderScreen([{ name: 'due', label: 'Due', type: 'date', min: 1, max: 10 }]);
59+
const input = await screen.findByRole('textbox', { name: 'Due' });
60+
expect(input).toHaveAttribute('id', 'ff-due');
6161
expect(input).not.toHaveAttribute('min');
6262
expect(input).not.toHaveAttribute('max');
6363
});
@@ -123,15 +123,15 @@ describe('objectui#9248 — `inlineHelpText` is drawn under the control and desc
123123
expect(screen.getByText('Shown below')).toBeVisible();
124124
});
125125

126-
it('describes every control arm, not only the text input', () => {
126+
it('describes every control arm, not only the text input', async () => {
127127
renderScreen([
128128
{ name: 'body', label: 'Body', type: 'textarea', inlineHelpText: 'Body help' },
129129
{ name: 'agree', label: 'Agree', type: 'boolean', inlineHelpText: 'Agree help' },
130130
{ name: 'kind', label: 'Kind', options: [{ label: 'A', value: 'a' }], inlineHelpText: 'Kind help' },
131131
{ name: 'qty', label: 'Qty', type: 'number', inlineHelpText: 'Qty help' },
132132
]);
133133
expect(screen.getByRole('textbox', { name: 'Body' })).toHaveAccessibleDescription('Body help');
134-
expect(screen.getByRole('checkbox', { name: 'Agree' })).toHaveAccessibleDescription('Agree help');
134+
expect(await screen.findByRole('checkbox', { name: 'Agree' })).toHaveAccessibleDescription('Agree help');
135135
expect(screen.getByRole('combobox', { name: 'Kind' })).toHaveAccessibleDescription('Kind help');
136136
expect(screen.getByRole('spinbutton', { name: 'Qty' })).toHaveAccessibleDescription('Qty help');
137137
});
@@ -185,27 +185,29 @@ describe('objectui#9248 — a `type: \'lookup\'` field resolves its picker from
185185
});
186186

187187
describe('objectui#9248 — CONTROL: a field declaring none of the keys renders as before', () => {
188-
it('no bound, no description, no help element, no picker', () => {
188+
it('no bound, no description, no help element, no picker', async () => {
189189
const { container } = renderScreen([
190190
{ name: 'qty', label: 'Qty', type: 'number' },
191191
{ name: 'title', label: 'Title', type: 'text', placeholder: 'Type here' },
192192
{ name: 'agree', label: 'Agree', type: 'boolean' },
193193
{ name: 'acct', label: 'Account', type: 'text' },
194194
]);
195195
const qty = screen.getByRole('spinbutton', { name: 'Qty' });
196+
const agree = await screen.findByRole('checkbox', { name: 'Agree' });
196197
expect(qty).not.toHaveAttribute('min');
197198
expect(qty).not.toHaveAttribute('max');
198199
for (const control of [
199200
qty,
200201
screen.getByRole('textbox', { name: 'Title' }),
201-
screen.getByRole('checkbox', { name: 'Agree' }),
202+
agree,
202203
screen.getByRole('textbox', { name: 'Account' }),
203204
]) {
204205
expect(control).not.toHaveAttribute('aria-describedby');
205206
}
206207
expect(container.querySelector('[id$="-help"]')).toBeNull();
207208
expect(container.querySelector('[data-testid^="lookup-trigger"]')).toBeNull();
208-
// Each field is still exactly a label and its control.
209+
// Each stacked field is still exactly a label and its control (the
210+
// boolean is the inline row since objectstack#26579).
209211
for (const block of container.querySelectorAll('.space-y-1\\.5')) {
210212
expect(block.children).toHaveLength(2);
211213
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#26579 — a flow screen's `date` / `datetime` and `boolean` fields
5+
* draw the platform controls the record form and `ActionParamDialog` draw.
6+
*
7+
* A date field was the browser's native input (`mm/dd/yyyy` under zh-CN), a
8+
* date-time field a bare text box, and a boolean a lone checkbox on the line
9+
* under its label. Now a date or date-time field is the record form's picker
10+
* in the display locale's format, writing the stored shape the native input
11+
* wrote, and a boolean is `ActionParamDialog`'s inline row: the checkbox with
12+
* its label beside it and the help text under the row.
13+
*/
14+
15+
import { describe, it, expect, afterEach, vi } from 'vitest';
16+
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
17+
import { I18nProvider } from '@object-ui/i18n';
18+
// Module scope, per AGENTS.md 测试纪律: the date, date-time and boolean arms
19+
// reach their widgets through `React.lazy` factories inside `@object-ui/fields`;
20+
// importing the barrel here resolves them before any test's `findBy` budget.
21+
import '@object-ui/fields';
22+
import { ScreenView, type ScreenSpec } from './ScreenView';
23+
24+
afterEach(cleanup);
25+
26+
function specOf(fields: ScreenSpec['fields']): ScreenSpec {
27+
return { nodeId: 'n1', title: 'Step', fields } as ScreenSpec;
28+
}
29+
30+
function renderScreen(
31+
fields: ScreenSpec['fields'],
32+
extra: { values?: Record<string, unknown>; onValueChange?: (name: string, v: unknown) => void } = {},
33+
) {
34+
return render(
35+
<ScreenView screen={specOf(fields)} values={extra.values ?? {}} onValueChange={extra.onValueChange ?? (() => {})} />,
36+
);
37+
}
38+
39+
const typeAndCommit = (input: HTMLElement, text: string) => {
40+
fireEvent.change(input, { target: { value: text } });
41+
fireEvent.blur(input);
42+
};
43+
44+
describe('objectstack#26579 — a `date` screen field is the platform picker', () => {
45+
it('draws the locale-format text box and calendar, never input[type=date]', async () => {
46+
const { container } = renderScreen([{ name: 'start', label: 'Start', type: 'date' }], { values: { start: '2026-10-16' } });
47+
const input = await screen.findByRole('textbox', { name: 'Start' });
48+
expect(container.querySelector('input[type="date"]')).toBeNull();
49+
expect(input.closest('[data-slot="date-picker"]')).not.toBeNull();
50+
expect(input).toHaveValue('10/16/2026');
51+
expect(screen.getByTestId('date-picker-trigger')).toBeTruthy();
52+
});
53+
54+
it('a zh session reads and shows the zh format', async () => {
55+
render(
56+
<I18nProvider config={{ defaultLanguage: 'zh', detectBrowserLanguage: false }} persistLanguage={false}>
57+
<ScreenView screen={specOf([{ name: 'start', label: 'Start', type: 'date' }])} values={{ start: '2026-10-16' }} onValueChange={() => {}} />
58+
</I18nProvider>,
59+
);
60+
const input = await screen.findByRole('textbox', { name: 'Start' });
61+
expect(input).toHaveValue('2026/10/16');
62+
expect(input).toHaveAttribute('placeholder', 'YYYY/MM/DD');
63+
});
64+
65+
it('typed text is written as the ISO day the native input wrote', async () => {
66+
const onValueChange = vi.fn();
67+
renderScreen([{ name: 'start', label: 'Start', type: 'date' }], { onValueChange });
68+
typeAndCommit(await screen.findByRole('textbox', { name: 'Start' }), '10/20/2026');
69+
expect(onValueChange).toHaveBeenCalledWith('start', '2026-10-20');
70+
});
71+
72+
it('clearing writes the empty string, as the native input did', async () => {
73+
const onValueChange = vi.fn();
74+
renderScreen([{ name: 'start', label: 'Start', type: 'date' }], { values: { start: '2026-10-16' }, onValueChange });
75+
await screen.findByRole('textbox', { name: 'Start' });
76+
fireEvent.click(screen.getByTestId('date-picker-clear'));
77+
expect(onValueChange).toHaveBeenCalledWith('start', '');
78+
});
79+
80+
it('keeps the required state and the help text on the control', async () => {
81+
renderScreen([{ name: 'start', label: 'Start', type: 'date', required: true, inlineHelpText: 'Start help' }]);
82+
const input = await screen.findByRole('textbox', { name: 'Start' });
83+
expect(input).toHaveAttribute('aria-required', 'true');
84+
expect(input).toHaveAccessibleDescription('Start help');
85+
});
86+
});
87+
88+
describe('objectstack#26579 — a `datetime` screen field is the platform date-time picker', () => {
89+
it('draws the picker and writes an ISO instant', async () => {
90+
const onValueChange = vi.fn();
91+
const { container } = renderScreen([{ name: 'remind', label: 'Remind', type: 'datetime' }], { onValueChange });
92+
const input = await screen.findByRole('textbox', { name: 'Remind' });
93+
expect(input.closest('[data-slot="date-picker"]')).not.toBeNull();
94+
expect(container.querySelector('input[type="datetime-local"]')).toBeNull();
95+
typeAndCommit(input, '10/16/2026 14:30');
96+
expect(onValueChange).toHaveBeenCalledWith('remind', new Date(2026, 9, 16, 14, 30).toISOString());
97+
});
98+
});
99+
100+
describe('objectstack#26579 — a `boolean` screen field is the inline checkbox row', () => {
101+
it('draws the label BESIDE the checkbox, in one row', async () => {
102+
renderScreen([{ name: 'estimate', label: 'Estimated amount', type: 'boolean' }]);
103+
const box = await screen.findByRole('checkbox', { name: 'Estimated amount' });
104+
const label = screen.getByText('Estimated amount');
105+
const row = label.parentElement as HTMLElement;
106+
expect(row).toHaveClass('flex');
107+
expect(row.contains(box)).toBe(true);
108+
// The control comes first, the label after it.
109+
expect(box.compareDocumentPosition(label) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
110+
});
111+
112+
it('a `checkbox` widget hint draws the same row', async () => {
113+
renderScreen([{ name: 'submit_now', label: 'Submit now', type: 'checkbox' }]);
114+
const box = await screen.findByRole('checkbox', { name: 'Submit now' });
115+
expect((screen.getByText('Submit now').parentElement as HTMLElement).contains(box)).toBe(true);
116+
});
117+
118+
it('ticking answers `true` and unticking `false`; clicking the label toggles it', async () => {
119+
const onValueChange = vi.fn();
120+
const { rerender } = renderScreen([{ name: 'estimate', label: 'Estimated amount', type: 'boolean' }], { onValueChange });
121+
fireEvent.click(await screen.findByRole('checkbox', { name: 'Estimated amount' }));
122+
expect(onValueChange).toHaveBeenLastCalledWith('estimate', true);
123+
rerender(
124+
<ScreenView
125+
screen={specOf([{ name: 'estimate', label: 'Estimated amount', type: 'boolean' }])}
126+
values={{ estimate: true }}
127+
onValueChange={onValueChange}
128+
/>,
129+
);
130+
expect(screen.getByRole('checkbox', { name: 'Estimated amount' })).toBeChecked();
131+
fireEvent.click(screen.getByText('Estimated amount'));
132+
expect(onValueChange).toHaveBeenLastCalledWith('estimate', false);
133+
});
134+
135+
it('only a real `true` is checked', async () => {
136+
renderScreen([{ name: 'estimate', label: 'Estimated amount', type: 'boolean' }], { values: { estimate: 'true' } });
137+
expect(await screen.findByRole('checkbox', { name: 'Estimated amount' })).not.toBeChecked();
138+
});
139+
140+
it('carries the required state and marker, and its help text under the row describes it', async () => {
141+
renderScreen([{ name: 'estimate', label: 'Estimated amount', type: 'boolean', required: true, inlineHelpText: 'Tick when estimated' }]);
142+
const box = await screen.findByRole('checkbox', { name: 'Estimated amount' });
143+
expect(box).toHaveAttribute('aria-required', 'true');
144+
expect(box).toHaveAccessibleDescription('Tick when estimated');
145+
const label = screen.getByText('Estimated amount');
146+
expect(label.querySelector('[data-required-marker="true"]')).not.toBeNull();
147+
const help = screen.getByText('Tick when estimated');
148+
expect(help.parentElement?.contains(label.parentElement)).toBe(true);
149+
expect(label.parentElement?.contains(help)).toBe(false);
150+
});
151+
152+
it('the widget draws no second label for the host-named control', async () => {
153+
const { container } = renderScreen([{ name: 'estimate', label: 'Estimated amount', type: 'boolean' }]);
154+
await screen.findByRole('checkbox', { name: 'Estimated amount' });
155+
expect(container.querySelectorAll('label[for="ff-estimate"]')).toHaveLength(1);
156+
});
157+
158+
it('CONTROL — a boolean field declaring `options` stays a select with its label above', () => {
159+
const { container } = renderScreen([
160+
{ name: 'flag', label: 'Flag', type: 'boolean', options: [{ label: 'Yes', value: true }, { label: 'No', value: false }] },
161+
]);
162+
expect(screen.getByRole('combobox', { name: 'Flag' })).toBeTruthy();
163+
expect(screen.queryByRole('checkbox')).toBeNull();
164+
expect(container.querySelector('.space-y-1\\.5')?.children).toHaveLength(2);
165+
});
166+
});

‎ui/packages/app-shell/src/views/ScreenView.requiredMarker-10367.test.tsx‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@
2222

2323
import { describe, it, expect, afterEach } from 'vitest';
2424
import { render, screen, cleanup } from '@testing-library/react';
25+
// Module scope, per AGENTS.md 测试纪律: the boolean arm reaches `BooleanField`
26+
// through a `React.lazy` factory inside `@object-ui/fields`; importing the
27+
// barrel here resolves that factory before any test's `findBy` budget starts.
28+
import '@object-ui/fields';
2529
import { ScreenView, type ScreenSpec } from './ScreenView';
2630

2731
afterEach(cleanup);
@@ -48,7 +52,7 @@ describe('objectui#10367 — ScreenView keeps the required `*` out of the access
4852
expect(marker).toHaveAttribute('aria-hidden', 'true');
4953
});
5054

51-
it('every other control arm carries aria-required and a name without `*`', () => {
55+
it('every other control arm carries aria-required and a name without `*`', async () => {
5256
renderScreen([
5357
{ name: 'note', label: 'Note', type: 'textarea', required: true },
5458
{ name: 'agree', label: 'Agree', type: 'boolean', required: true },
@@ -59,15 +63,15 @@ describe('objectui#10367 — ScreenView keeps the required `*` out of the access
5963
expect(note).toHaveAttribute('aria-required', 'true');
6064
expect(note).not.toHaveAttribute('required');
6165

62-
const agree = screen.getByRole('checkbox', { name: 'Agree' });
66+
const agree = await screen.findByRole('checkbox', { name: 'Agree' });
6367
expect(agree).toHaveAttribute('aria-required', 'true');
6468
expect(agree).not.toHaveAttribute('required');
6569

6670
const kind = screen.getByRole('combobox', { name: 'Kind' });
6771
expect(kind).toHaveAttribute('aria-required', 'true');
6872
});
6973

70-
it('CONTROL — an optional field shows no marker and carries no aria-required', () => {
74+
it('CONTROL — an optional field shows no marker and carries no aria-required', async () => {
7175
const { container } = renderScreen([
7276
{ name: 'title', label: 'Title', type: 'text' },
7377
{ name: 'agree', label: 'Agree', type: 'boolean' },
@@ -77,6 +81,6 @@ describe('objectui#10367 — ScreenView keeps the required `*` out of the access
7781
const input = screen.getByRole('textbox', { name: 'Title' });
7882
expect(input).not.toBeRequired();
7983
expect(input).not.toHaveAttribute('aria-required');
80-
expect(screen.getByRole('checkbox', { name: 'Agree' })).not.toHaveAttribute('aria-required');
84+
expect(await screen.findByRole('checkbox', { name: 'Agree' })).not.toHaveAttribute('aria-required');
8185
});
8286
});

0 commit comments

Comments
 (0)