diff --git a/__tests__/allowedValueSpellings.test.tsx b/__tests__/allowedValueSpellings.test.tsx
new file mode 100644
index 00000000..9a4ec06b
--- /dev/null
+++ b/__tests__/allowedValueSpellings.test.tsx
@@ -0,0 +1,86 @@
+import { ReqoreUIProvider } from '@qoretechnologies/reqore';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { FieldAllowedValues } from '../src/components/form/fields/allowed-values/AllowedValues';
+import { allowedValueOf, findAllowedValueOption } from '../src/helpers/allowedValues';
+
+/**
+ * A choice is the same choice however its entry is written.
+ *
+ * An `allowed_values` entry comes as an envelope (`{value: {type, value}}`), a
+ * bare value (`{value: 'x'}`) or a name alone (`{name: 'x'}`) — the app
+ * catalogue sends the bare form, a `context=ui` answer the envelope. The row
+ * that names a stored choice read all three; the pickers that write one read
+ * only the envelope. On a bare list nothing showed as selected, and clicking a
+ * choice stored `undefined`: picking a pipeline processor's class cleared it.
+ */
+describe('the value a choice stands for', () => {
+ it('reads the envelope, the bare value and the name', () => {
+ expect(allowedValueOf({ value: { type: 'string', value: 'a' } })).toBe('a');
+ expect(allowedValueOf({ value: 'b' })).toBe('b');
+ expect(allowedValueOf({ name: 'c' })).toBe('c');
+ // A falsy bare value is still the value, not a reason to fall back to the name.
+ expect(allowedValueOf({ name: 'zero', value: 0 })).toBe(0);
+ expect(allowedValueOf({ value: false })).toBe(false);
+ });
+
+ it('agrees with the lookup that names the stored choice', () => {
+ for (const entry of [
+ { name: 'x', value: { type: 'string', value: 'v' } },
+ { name: 'x', value: 'v' },
+ { name: 'v' },
+ ]) {
+ const schema = { type: 'string', allowed_values: [entry] } as never;
+ expect(findAllowedValueOption(allowedValueOf(entry), schema)).toBe(entry);
+ }
+ });
+});
+
+describe('a short list written with bare values', () => {
+ const items = [
+ { name: 'ProcessorTypeTest', display_name: 'Processor Type Test', value: 'ProcessorTypeTest' },
+ {
+ name: 'qogcompatibilitytest',
+ display_name: 'Qog Compatibility Test',
+ value: 'qogcompatibilitytest',
+ },
+ ];
+
+ it('stores the choice that was clicked', () => {
+ const onChange = vi.fn();
+ render(
+
+
+
+ );
+
+ fireEvent.click(screen.getByText('Qog Compatibility Test').closest('.reqore-checkbox')!);
+ expect(onChange).toHaveBeenCalledWith('processor', 'qogcompatibilitytest');
+ });
+
+ it('knows the stored value is the selected choice', () => {
+ const onChange = vi.fn();
+ render(
+
+
+
+ );
+
+ // Clicking the selected choice unselects it, which is only possible if the
+ // picker recognised it as the selected one.
+ fireEvent.click(screen.getByText('Processor Type Test').closest('.reqore-checkbox')!);
+ expect(onChange).toHaveBeenCalledWith('processor', undefined);
+ });
+});
diff --git a/__tests__/autoFieldSemanticStringTypes.test.tsx b/__tests__/autoFieldSemanticStringTypes.test.tsx
new file mode 100644
index 00000000..1e2a37a2
--- /dev/null
+++ b/__tests__/autoFieldSemanticStringTypes.test.tsx
@@ -0,0 +1,54 @@
+// Copyright 2026 Qore Technologies, s.r.o.
+// A semantic string format is edited as one line of text.
+//
+// `email`, `uri`, `uuid`, `hostname`, `ipv4`, `ipv6` and `phone` are in the
+// option vocabulary (`optionUiTypes`), the single-line list and the validator,
+// but the auto field had no case for them: a field declared with one drew
+// "Unknown type!" in every form.
+import { ReqoreUIProvider } from '@qoretechnologies/reqore';
+import { render } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { AutoFormField } from '../src/components/form/fields/auto/AutoFormField';
+import { OPTION_UI_TYPES } from '../src/helpers/optionUiTypes';
+import { isSingleLineStringType } from '../src/helpers/singleLineString';
+import { FetchContext } from '../src/contexts/FetchContext';
+import { emptyFetchContext } from './support/fetchContext';
+
+const fetchContext = emptyFetchContext();
+
+const wrap = (node: React.ReactNode) => (
+
+ {node}
+
+);
+
+const SEMANTIC_STRING_TYPES = ['email', 'uri', 'uuid', 'hostname', 'ipv4', 'ipv6', 'phone'];
+
+describe('a semantic string format', () => {
+ it('is a single-line option type the vocabulary knows', () => {
+ for (const type of SEMANTIC_STRING_TYPES) {
+ expect(OPTION_UI_TYPES).toContain(type);
+ expect(isSingleLineStringType(type)).toBe(true);
+ }
+ });
+
+ it.each(SEMANTIC_STRING_TYPES)('%s is edited as one line of text', (type) => {
+ const onChange = vi.fn();
+ const { container } = render(
+ wrap(
+
+ )
+ );
+
+ expect(container.textContent).not.toContain('Unknown type!');
+ const input = container.querySelector('textarea') as HTMLTextAreaElement;
+ expect(input).toBeTruthy();
+ expect(input.value).toBe('a value');
+ });
+});
diff --git a/__tests__/compactRowCaretFindsTheControl.test.tsx b/__tests__/compactRowCaretFindsTheControl.test.tsx
index 891d20c2..7266c163 100644
--- a/__tests__/compactRowCaretFindsTheControl.test.tsx
+++ b/__tests__/compactRowCaretFindsTheControl.test.tsx
@@ -110,6 +110,89 @@ describe('the caret an opened row takes', () => {
expect((document.activeElement as HTMLElement).textContent).toContain('Choose a connection');
});
+ /* A code editor draws its chrome first (a fullscreen toggle) and its edit area once
+ it has loaded. The caret may rest on the chrome for that moment, but the edit area
+ takes it when it arrives: otherwise the next space typed presses the toggle. */
+ const LateCodeEditor = ({ onMount }: { onMount: (mount: () => void) => void }) => {
+ const [ready, setReady] = React.useState(false);
+ React.useEffect(() => onMount(() => setReady(true)), []);
+ return (
+
+
+ {ready ? : null}
+
+ );
+ };
+ const codeField = {
+ source: { type: 'string', ui_type: 'late-code', display_name: 'Source', required: true },
+ };
+
+ it('moves from an editor\'s chrome to its text box when the text box arrives late', async () => {
+ let mount: () => void = () => undefined;
+ renderForm(codeField, {}, {
+ componentOverrides: {
+ 'late-code': () => (mount = fn)} />,
+ },
+ });
+
+ await waitFor(() => expect(focusedField()).toBe('source'));
+ expect(document.activeElement?.textContent).toBe('Fullscreen');
+
+ mount();
+
+ await waitFor(() =>
+ expect(document.activeElement?.getAttribute('aria-label')).toBe('Editor content')
+ );
+ });
+
+ /* Monaco inserts its edit area before it can take focus, and focus() on it then does
+ nothing. The caret counts as handed over only once it has actually landed. */
+ it('keeps trying until the text box can actually take the caret', async () => {
+ let makeFocusable: () => void = () => undefined;
+ const SlowEditArea = () => {
+ const [focusable, setFocusable] = React.useState(false);
+ React.useEffect(() => {
+ makeFocusable = () => setFocusable(true);
+ }, []);
+ return (
+
+
+
+
+ );
+ };
+ renderForm(codeField, {}, { componentOverrides: { 'late-code': SlowEditArea } });
+
+ // The text box is there but cannot take focus yet: the row waits for it rather
+ // than settling on the toolbar beside it.
+ await waitFor(() => expect(document.querySelector('.readfirst-row-editing')).toBeTruthy());
+ expect(document.activeElement?.textContent).not.toBe('Fullscreen');
+
+ makeFocusable();
+
+ await waitFor(() =>
+ expect(document.activeElement?.getAttribute('aria-label')).toBe('Editor content')
+ );
+ });
+
+ it('leaves the caret where the reader put it, even when the text box arrives later', async () => {
+ let mount: () => void = () => undefined;
+ const { container } = renderForm(codeField, {}, {
+ componentOverrides: {
+ 'late-code': () => (mount = fn)} />,
+ },
+ });
+ await waitFor(() => expect(focusedField()).toBe('source'));
+
+ const elsewhere = document.createElement('input');
+ container.appendChild(elsewhere);
+ elsewhere.focus();
+ mount();
+
+ await waitFor(() => expect(document.querySelector('[aria-label="Editor content"]')).toBeTruthy());
+ expect(document.activeElement).toBe(elsewhere);
+ });
+
it('leaves a caret the reader moved elsewhere alone', async () => {
// A late editor plus a reader who did not wait: the row must not yank the
// caret out of the control they moved into themselves.
diff --git a/__tests__/expressionRichTextArgument.test.ts b/__tests__/expressionRichTextArgument.test.ts
new file mode 100644
index 00000000..4c70c515
--- /dev/null
+++ b/__tests__/expressionRichTextArgument.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from 'vitest';
+
+import { renderExpressionToText } from '../src/components/form/expressions/renderExpressionToText';
+import { IExpression, IExpressionValue } from '../src/components/form/expressions/types';
+
+/**
+ * A rich-text argument reads as the string it is.
+ *
+ * The editor stores rich text as its document — paragraphs of text and template
+ * tags — and the offline renderer printed that object as JSON. A Qog's Message
+ * Content row read `+([{"type":"paragraph","children":[…]}], …)`: the editor's
+ * node tree, which is no value anyone wrote and not what the server renders.
+ */
+const richtext = (...children: unknown[]): IExpression =>
+ ({ type: 'richtext', value: [{ type: 'paragraph', children }] }) as IExpression;
+const lit = (value: unknown): IExpression => ({ value }) as IExpression;
+
+describe('renderExpressionToText with a rich-text argument', () => {
+ it('writes the rich text as a string literal', () => {
+ const value = {
+ exp: '+',
+ args: [richtext({ text: 'This Qog was run at ' }), lit(' by me')],
+ } as IExpressionValue;
+
+ expect(renderExpressionToText(value)).toBe('"This Qog was run at " + " by me"');
+ });
+
+ it('writes a template tag inside it as its reference', () => {
+ const value = {
+ exp: '+',
+ args: [
+ richtext(
+ { text: 'Day ends at ' },
+ { type: 'tag', label: 'Day End', value: '$timestamp:next_midnight', children: [{ text: '' }] }
+ ),
+ lit('!'),
+ ],
+ } as IExpressionValue;
+
+ expect(renderExpressionToText(value)).toBe('"Day ends at $timestamp:next_midnight" + "!"');
+ });
+
+ it('still writes any other object as it is', () => {
+ const value = { exp: '+', args: [lit({ a: 1 }), lit(2)] } as IExpressionValue;
+
+ expect(renderExpressionToText(value)).toBe('{"a":1} + 2');
+ });
+});
diff --git a/__tests__/fieldHelpOnlyWhenThereIsMore.test.ts b/__tests__/fieldHelpOnlyWhenThereIsMore.test.ts
new file mode 100644
index 00000000..5b39a8cf
--- /dev/null
+++ b/__tests__/fieldHelpOnlyWhenThereIsMore.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from 'vitest';
+import { fieldHasLongDescription } from '../src/helpers/fieldDescription';
+
+/**
+ * A field offers help only when its help says more than the line under it.
+ *
+ * The server copies `short_desc` into `desc` for any field with no `desc` of its
+ * own, so every field had a `desc` and every field offered a help dialog that
+ * mostly repeated the summary.
+ */
+describe('a field with more to read', () => {
+ it('has a description that adds to its summary', () => {
+ expect(
+ fieldHasLongDescription({
+ short_desc: 'Any webhooks supported by the service',
+ desc: 'A **webhook** is an HTTP endpoint that Qorus publishes.',
+ })
+ ).toBe(true);
+ // A description with no summary is all there is to read.
+ expect(fieldHasLongDescription({ desc: 'Only a description.' })).toBe(true);
+ });
+
+ it('is not the server copying the summary into the description', () => {
+ const line = 'An option with nothing more to say';
+ expect(fieldHasLongDescription({ short_desc: line, desc: line })).toBe(false);
+ // Spacing, case, a closing full stop and emphasis do not make it new.
+ expect(
+ fieldHasLongDescription({ short_desc: line, desc: ` an option with **nothing** more to say.` })
+ ).toBe(false);
+ });
+
+ it('is not a field without a description', () => {
+ expect(fieldHasLongDescription({ short_desc: 'Summary' })).toBe(false);
+ expect(fieldHasLongDescription({ desc: ' ' })).toBe(false);
+ expect(fieldHasLongDescription(undefined)).toBe(false);
+ });
+});
diff --git a/__tests__/formBarrelExports.test.ts b/__tests__/formBarrelExports.test.ts
index f468646c..4feb8cdd 100644
--- a/__tests__/formBarrelExports.test.ts
+++ b/__tests__/formBarrelExports.test.ts
@@ -5,7 +5,12 @@
* needs both from the barrel — a deep import of `dist/…/builder` is a
* dependency on the file layout, not on the API (#119).
*/
-import { ExpressionBuilder, ExpressionField, RowOpenPickerContext } from '../src/components/form';
+import {
+ ExpressionBuilder,
+ ExpressionField,
+ RowOpenPickerContext,
+ SavedValuesContext,
+} from '../src/components/form';
describe('form barrel — expression editor exports', () => {
it('exports the bare ExpressionBuilder alongside the ExpressionField shell', () => {
@@ -26,3 +31,14 @@ describe('form barrel — the row-open instruction', () => {
expect(RowOpenPickerContext.Provider).toBeDefined();
});
});
+
+/**
+ * A host keeps the values its users save for reuse; it hands them to every form
+ * through this context, from the barrel.
+ */
+describe('form barrel — saved values', () => {
+ it('exports the context a host provides its saved values through', () => {
+ expect(SavedValuesContext).toBeDefined();
+ expect(SavedValuesContext.Provider).toBeDefined();
+ });
+});
diff --git a/__tests__/formEngineCompletionMeter.test.tsx b/__tests__/formEngineCompletionMeter.test.tsx
index 8bb3b4c1..02e1ccfe 100644
--- a/__tests__/formEngineCompletionMeter.test.tsx
+++ b/__tests__/formEngineCompletionMeter.test.tsx
@@ -95,7 +95,9 @@ describe('the completion header', () => {
expect(text).not.toContain('Draft');
expect(text).not.toContain('Ready');
expect(text).toContain('0/2 set');
- expect(text).toContain('2 need attention');
+ // Nobody has been to either field yet, so they are still to fill in, not faults.
+ expect(text).toContain('2 to fill in');
+ expect(text).not.toContain('need attention');
});
it('still reads Ready at 100% when nothing is flagged', async () => {
diff --git a/__tests__/formEngineFreshForm.test.tsx b/__tests__/formEngineFreshForm.test.tsx
new file mode 100644
index 00000000..705cdb60
--- /dev/null
+++ b/__tests__/formEngineFreshForm.test.tsx
@@ -0,0 +1,307 @@
+import { ReqoreUIProvider } from '@qoretechnologies/reqore';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useState } from 'react';
+import { describe, expect, it, vi } from 'vitest';
+import { FormEngine } from '../src/components/form/engine/FormEngine';
+import { FetchContext } from '../src/contexts/FetchContext';
+import { emptyFetchContext } from './support/fetchContext';
+
+const fetchContext = emptyFetchContext();
+
+/**
+ * A new form is not already wrong. It used to open with "2 need attention" and every
+ * required field in amber before anyone had typed a thing, so the first thing a person
+ * met was a list of faults. Until they have been to a field (opened it and left it) or
+ * the host reports a submit attempt (`revealRequired`), an empty required field is only
+ * still to fill in: the first box reads "To fill in", in the quiet colour.
+ */
+const SCHEMA = {
+ title: { type: 'string', ui_type: 'string', display_name: 'Title', required: true },
+ owner: { type: 'string', ui_type: 'string', display_name: 'Owner', required: true },
+} as never;
+
+const renderForm = (props: Record = {}) =>
+ render(
+
+
+
+
+
+ );
+
+const header = async () => {
+ await waitFor(() => expect(document.querySelector('.options-readfirst-completion')).toBeTruthy());
+ return (document.querySelector('.options-readfirst-completion') as HTMLElement).textContent || '';
+};
+
+describe('a new form', () => {
+ it('opens with its required fields to fill in, not as faults', async () => {
+ renderForm();
+ const text = await header();
+ expect(text).toContain('2 to fill in');
+ expect(text).not.toContain('need attention');
+ expect(document.body.textContent).toContain('To fill in');
+ expect(document.body.textContent).not.toContain('Needs attention');
+ });
+
+ it('shows every empty required field as needing attention after a submit attempt', async () => {
+ renderForm({ revealRequired: true });
+ const text = await header();
+ expect(text).toContain('2 need attention');
+ expect(document.body.textContent).toContain('Needs attention');
+ expect(document.body.textContent).not.toContain('To fill in');
+ });
+
+ it('tells the host the same two numbers its readiness line shows', async () => {
+ const onAttentionChange = vi.fn();
+ const { rerender } = renderForm({ onAttentionChange });
+ await header();
+ await waitFor(() => expect(onAttentionChange).toHaveBeenLastCalledWith({ count: 2, faults: 0 }));
+
+ rerender(
+
+
+
+
+
+ );
+ await waitFor(() => expect(onAttentionChange).toHaveBeenLastCalledWith({ count: 2, faults: 2 }));
+ });
+
+ /* The answer to a submit that could not go is where to type. The submit button
+ holds the caret when it is pressed; each request moves it to the first field that
+ needs attention, and a second press moves it again. */
+ it('moves the caret from the submit button to the first field to fill in, on every request', async () => {
+ const formWith = (focusAttentionRequest?: number) => (
+
+
+
+
+
+
+ );
+ const { rerender } = render(formWith(undefined));
+ await header();
+ const submit = screen.getByRole('button', { name: 'Submit' });
+ submit.focus();
+
+ rerender(formWith(1));
+ await waitFor(() =>
+ expect(
+ document.querySelector('[data-field="title"]')?.contains(document.activeElement)
+ ).toBe(true)
+ );
+
+ // Pressed again with the field still open: the caret goes back to it.
+ submit.focus();
+ rerender(formWith(2));
+ await waitFor(() =>
+ expect(
+ document.querySelector('[data-field="title"]')?.contains(document.activeElement)
+ ).toBe(true)
+ );
+ });
+
+ /* "N need attention →" shows only the fields that need attention. The first character
+ typed into an empty required field makes it valid; the row being typed in must not
+ vanish then, or the rest of the word goes nowhere. */
+ it('keeps the field being typed in while the form shows only what needs attention', async () => {
+ const user = userEvent.setup();
+ // Controlled, as a host holds it: each keystroke comes back as the form's value.
+ const Controlled = () => {
+ const [value, setValue] = useState>({});
+ return (
+
+
+ setValue((next ?? {}) as Record)}
+ />
+
+
+ );
+ };
+ render();
+ await header();
+ await user.click(document.querySelector('.options-readfirst-attention-link') as HTMLElement);
+ const input = await waitFor(() => {
+ const found = document.querySelector(
+ '[data-field="title"] input, [data-field="title"] textarea'
+ );
+ expect(found).toBeTruthy();
+ return found!;
+ });
+ await user.click(input);
+ // The first letters reach the form (the field is valid now), then the rest is typed.
+ await user.keyboard('Or');
+ await waitFor(() =>
+ expect(document.querySelector('.options-readfirst-attention-link')?.textContent).toContain(
+ '1 need attention'
+ )
+ );
+ expect(input.isConnected).toBe(true);
+ await user.keyboard('der sync');
+
+ await waitFor(() => expect(input.value).toBe('Order sync'));
+ expect(document.activeElement).toBe(input);
+ });
+
+ /* A field being typed in keeps its box until it is left, so it does not jump away
+ under the caret. Its box's count follows its value, though: the box read "To fill
+ in 1" while its last field already had a value and the host's submit read ready. */
+ it('counts a box by what its fields hold now, also while one is being typed in', async () => {
+ const user = userEvent.setup();
+ const onAttentionChange = vi.fn();
+ const ONE_REQUIRED = {
+ title: { type: 'string', ui_type: 'string', display_name: 'Title', required: true },
+ } as never;
+ const Controlled = () => {
+ const [value, setValue] = useState>({});
+ return (
+
+
+ setValue((next ?? {}) as Record)}
+ />
+
+
+ );
+ };
+ render();
+ const boxHeader = () =>
+ Array.from(document.querySelectorAll('.options-readfirst-group')).find((box) =>
+ box.textContent?.includes('To fill in')
+ );
+ await waitFor(() => expect(boxHeader()).toBeTruthy());
+ expect(boxHeader()!.textContent).toMatch(/To fill in\s*1/);
+ const input = await waitFor(() => {
+ const found = document.querySelector(
+ '[data-field="title"] input, [data-field="title"] textarea'
+ );
+ expect(found).toBeTruthy();
+ return found!;
+ });
+ await user.click(input);
+ await user.keyboard('Order sync');
+
+ // Still open, still in its box…
+ expect(input.isConnected).toBe(true);
+ expect(boxHeader()).toBeTruthy();
+ // …but the box no longer counts it, and agrees with what the host is told.
+ await waitFor(() =>
+ expect(onAttentionChange).toHaveBeenLastCalledWith({ count: 0, faults: 0 })
+ );
+ // The header is the box's name with no number after it (a tag, or a quiet count in
+ // the calm look).
+ expect(boxHeader()!.textContent).not.toMatch(/To fill in\s*\d/);
+ });
+
+ /* "N to fill in →" shows only those fields: a toggle a keyboard reaches, which says
+ whether it is on, so a host turning it on for a person never turns it off again. */
+ it('offers "N to fill in →" as a toggle that says whether it is on', async () => {
+ const user = userEvent.setup();
+ renderForm();
+ await header();
+ const toggle = () => document.querySelector('.options-readfirst-attention-link') as HTMLElement;
+ expect(toggle().getAttribute('role')).toBe('button');
+ expect(toggle().getAttribute('aria-pressed')).toBe('false');
+ toggle().focus();
+ await user.keyboard('{Enter}');
+ await waitFor(() => expect(toggle().getAttribute('aria-pressed')).toBe('true'));
+ });
+
+ /* A long text, a description, source code: these open as a card rather than in the
+ row. The card takes the caret on opening, as the row does, so a click (or a
+ blocked submit) puts the cursor where the text goes. */
+ it('puts the caret in a field that opens as a card', async () => {
+ const user = userEvent.setup();
+ render(
+
+
+
+
+
+ );
+ await header();
+ await user.click(document.querySelector('[data-field="notes"]') as HTMLElement);
+ await waitFor(() =>
+ expect(document.querySelector('.options-readfirst-card[data-field="notes"]')).toBeTruthy()
+ );
+ await waitFor(() =>
+ expect(
+ document
+ .querySelector('.options-readfirst-card[data-field="notes"]')
+ ?.contains(document.activeElement)
+ ).toBe(true)
+ );
+ expect(['TEXTAREA', 'INPUT']).toContain(document.activeElement?.tagName);
+ });
+
+ it('turns a field into a fault once someone has been to it and left it empty', async () => {
+ const user = userEvent.setup();
+ // The reader has the Title row open; opening Owner closes it (one row at a time),
+ // so Title has been visited and left empty.
+ renderForm({ initialExpandedOptions: ['title'] });
+ await waitFor(() => expect(document.querySelector('[data-field="owner"]')).toBeTruthy());
+ expect(await header()).toContain('to fill in');
+ await user.click(document.querySelector('[data-field="owner"]') as HTMLElement);
+ await waitFor(async () => expect(await header()).toContain('need attention'));
+ expect(screen.getByText('Needs attention')).toBeTruthy();
+ });
+});
diff --git a/__tests__/formEngineLockedRequired.test.tsx b/__tests__/formEngineLockedRequired.test.tsx
index 2490ac97..131782df 100644
--- a/__tests__/formEngineLockedRequired.test.tsx
+++ b/__tests__/formEngineLockedRequired.test.tsx
@@ -59,7 +59,11 @@ const box = (container: HTMLElement, title: string) =>
(group.textContent || '').startsWith(title)
);
-const attentionBox = (container: HTMLElement) => box(container, 'Needs attention');
+// The first box: "To fill in" while nothing in it is a fault yet (a form nobody has been
+// to), "Needs attention" once something is. These tests are about which box a field is
+// in, not about its name.
+const attentionBox = (container: HTMLElement) =>
+ box(container, 'Needs attention') ?? box(container, 'To fill in');
const row = (container: HTMLElement, field: string) =>
container.querySelector(`[data-field="${field}"]`);
@@ -76,6 +80,7 @@ describe('a required field locked by its dependency does not need attention', ()
// Nothing the author can act on, so there is no Needs attention box at all.
expect(attentionBox(container)).toBeUndefined();
expect(container.textContent).not.toContain('Needs attention');
+ expect(container.textContent).not.toContain('To fill in');
});
it('still shows the locked field, among the optional ones, with what unlocks it', async () => {
@@ -205,6 +210,7 @@ describe('a locked required field and the completeness status', () => {
expect(text).toContain('Ready');
expect(text).not.toContain('Incomplete');
expect(text).not.toContain('need attention');
+ expect(text).not.toContain('to fill in');
await waitFor(() => expect(onValidityChange).toHaveBeenLastCalledWith(true, expect.anything()));
});
diff --git a/__tests__/formEngineRequiredMessage.test.tsx b/__tests__/formEngineRequiredMessage.test.tsx
index 2e767b50..c88671e9 100644
--- a/__tests__/formEngineRequiredMessage.test.tsx
+++ b/__tests__/formEngineRequiredMessage.test.tsx
@@ -47,7 +47,7 @@ const renderForm = (value: Record = {}, props: Record {
- it('states the requirement on arrival, and counts as needing attention', async () => {
+ it('states the requirement on arrival, and counts as still to fill in, not as a fault', async () => {
renderForm();
// The row is open — this is the state the reader actually meets.
await waitFor(() => expect(document.querySelector('[data-field="title"]')).toBeTruthy());
@@ -57,7 +57,10 @@ describe('an empty required field', () => {
)
);
await waitFor(() => expect(document.body.textContent).toContain(REQUIRED_MESSAGE));
- expect(document.body.textContent).toContain('Needs attention');
+ // A new form is not already wrong: the field is to fill in until the reader has
+ // been in it and left it empty.
+ expect(document.body.textContent).toContain('To fill in');
+ expect(document.body.textContent).not.toContain('Needs attention');
});
it('states the error once the reader has been in it and left it empty', async () => {
diff --git a/__tests__/readFirst.test.ts b/__tests__/readFirst.test.ts
index 65c5b252..6cefc11d 100644
--- a/__tests__/readFirst.test.ts
+++ b/__tests__/readFirst.test.ts
@@ -717,6 +717,21 @@ describe('getReadFirstStatus', () => {
it("marks an empty, non-required, non-warned field 'optional'", () => {
expect(status({ empty: true })).toBe('optional');
});
+
+ it("marks an empty required field nobody has been to 'pending', still in the first box", () => {
+ // A new form is not already wrong: the field is to fill in, not a fault.
+ expect(status({ empty: true, required: true, visited: false })).toBe('pending');
+ expect(getReadFirstBucket('pending')).toBe('attention');
+ });
+
+ it("keeps a warned or invalid field a fault, visited or not", () => {
+ expect(status({ empty: true, warned: true, visited: false })).toBe('todo');
+ expect(status({ empty: false, invalid: true, visited: false })).toBe('invalid');
+ });
+
+ it("marks an empty required field 'todo' once it has been visited", () => {
+ expect(status({ empty: true, required: true, visited: true })).toBe('todo');
+ });
});
describe('getReadFirstBucket', () => {
diff --git a/__tests__/rowFocusTarget.test.ts b/__tests__/rowFocusTarget.test.ts
index f4bd730e..dcac86a2 100644
--- a/__tests__/rowFocusTarget.test.ts
+++ b/__tests__/rowFocusTarget.test.ts
@@ -30,6 +30,31 @@ describe('the control an open row offers the caret', () => {
expect(findRowFocusTarget(container)?.id).toBe('text');
});
+ it('is a code editor\'s edit area, which says it is a text box by its role', () => {
+ // What Monaco renders: a toolbar button first, its IME helper hidden from
+ // assistive technology, and the edit area a focusable div with role="textbox".
+ const container = cell(
+ '' +
+ '' +
+ ''
+ );
+ expect(findRowFocusTarget(container)?.id).toBe('code');
+ });
+
+ /* A field that opens as a card is a form in its own right: its first button is one
+ of several, and a caret on it turns the next space typed into a press. */
+ it('is only ever a place to type in a card, and nothing while that has not mounted', () => {
+ const loading = cell('');
+ expect(findRowFocusTarget(loading, { textEntryOnly: true })).toBeUndefined();
+ // The same row as an inline editor still hands the caret to its control.
+ expect(findRowFocusTarget(loading)?.id).toBe('fullscreen');
+
+ const mounted = cell(
+ ''
+ );
+ expect(findRowFocusTarget(mounted, { textEntryOnly: true })?.id).toBe('code');
+ });
+
it('is the pick-one control when that is all the row has', () => {
// What ReqoreCheckbox renders: a focusable icon, not an input.
const container = cell(
diff --git a/__tests__/savedValuesSeam.test.tsx b/__tests__/savedValuesSeam.test.tsx
new file mode 100644
index 00000000..636cdfbd
--- /dev/null
+++ b/__tests__/savedValuesSeam.test.tsx
@@ -0,0 +1,153 @@
+// Copyright 2026 Qore Technologies, s.r.o.
+// A host's saved values reach a form, and a form asks the host to save one.
+//
+// Saving a value for reuse and picking a saved one were IDE features that the
+// port left inert: `showSavedValues` contributed no choices and `allowSaving`
+// drew no control, so every form that moved to this engine lost both. The host
+// keeps the values; this package draws them and asks it to save
+// (`SavedValuesContext`).
+import { ReqoreUIProvider } from '@qoretechnologies/reqore';
+import { IReqoreDropdownItem } from '@qoretechnologies/reqore/dist/components/Dropdown/list';
+import { render, screen, waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { RowMenuContext } from '../src/components/form/engine/rowMenuContext';
+import { FieldAllowedValues } from '../src/components/form/fields/allowed-values/AllowedValues';
+import {
+ ISavedValues,
+ SavedValuesContext,
+} from '../src/components/form/fields/allowed-values/savedValues';
+import {
+ ITemplateFieldProps,
+ TemplateField,
+} from '../src/components/form/fields/template/TemplateField';
+import { FetchContext } from '../src/contexts/FetchContext';
+import { emptyFetchContext } from './support/fetchContext';
+
+const fetchContext = emptyFetchContext();
+
+const SAVED: ISavedValues['items'] = [
+ { display_name: 'Production URL', value: 'https://example.com', valueType: 'string' },
+ { display_name: 'Answer', value: 42, valueType: 'int' },
+];
+
+const withSavedValues = (node: React.ReactNode, savedValues?: ISavedValues) => (
+
+
+ {node}
+
+
+);
+
+describe('the saved values a field offers', () => {
+ it('are offered by a field that shows saved values', () => {
+ render(
+ withSavedValues(
+ ,
+ { items: SAVED }
+ )
+ );
+
+ expect(screen.queryAllByText('Saved & Suggested Values').length).toBeGreaterThan(0);
+ });
+
+ it('are not offered without a host, by a read-only field, or of another type', () => {
+ const { unmount } = render(
+ withSavedValues()
+ );
+ expect(screen.queryAllByText('Saved & Suggested Values')).toHaveLength(0);
+ unmount();
+
+ const readOnly = render(
+ withSavedValues(
+ ,
+ { items: SAVED }
+ )
+ );
+ expect(screen.queryAllByText('Saved & Suggested Values')).toHaveLength(0);
+ readOnly.unmount();
+
+ render(
+ withSavedValues(
+ ,
+ { items: SAVED }
+ )
+ );
+ expect(screen.queryAllByText('Saved & Suggested Values')).toHaveLength(0);
+ });
+});
+
+/** The row side of the menu channel: whatever the field inside published. */
+const published: Record = {};
+const registry = {
+ registerRowMenuItems: (id: string, _key: string, items: IReqoreDropdownItem[]) => {
+ published[id] = items;
+ },
+ unregisterRowMenuItems: (id: string) => {
+ delete published[id];
+ },
+};
+const menuItem = (label: string) =>
+ Object.values(published)
+ .flat()
+ .find((item) => (item as { label?: unknown }).label === label) as
+ | (IReqoreDropdownItem & { onClick?: () => void })
+ | undefined;
+
+const fieldInARow = (props: Partial, savedValues?: ISavedValues) =>
+ render(
+ withSavedValues(
+
+
+ ,
+ savedValues
+ )
+ );
+
+beforeEach(() => {
+ Object.keys(published).forEach((id) => delete published[id]);
+});
+
+describe('saving a field’s value', () => {
+ it('is offered in the row menu and asks the host to save the value', async () => {
+ const saveValue = vi.fn();
+ fieldInARow({ value: 'https://example.com' }, { items: [], saveValue, saveLabel: 'Save' });
+
+ await waitFor(() => expect(menuItem('Save')).toBeDefined());
+ menuItem('Save')!.onClick!();
+ expect(saveValue).toHaveBeenCalledWith({ value: 'https://example.com', type: 'string' });
+ });
+
+ it('is not offered with nothing to save, no host, a yes/no or fixed choices', async () => {
+ const saveValue = vi.fn();
+ const empty = fieldInARow({ value: undefined }, { items: [], saveValue });
+ await waitFor(() => expect(Object.keys(published).length).toBeGreaterThan(0));
+ expect(menuItem('Save value')).toBeUndefined();
+ empty.unmount();
+
+ const noHost = fieldInARow({ value: 'x' });
+ await waitFor(() => expect(Object.keys(published).length).toBeGreaterThan(0));
+ expect(menuItem('Save value')).toBeUndefined();
+ noHost.unmount();
+
+ const yesNo = fieldInARow({ type: 'bool' as never, value: true }, { items: [], saveValue });
+ await waitFor(() => expect(Object.keys(published).length).toBeGreaterThan(0));
+ expect(menuItem('Save value')).toBeUndefined();
+ yesNo.unmount();
+
+ fieldInARow(
+ {
+ value: 'a',
+ allowed_values: [{ name: 'a', value: { type: 'string', value: 'a' } }] as never,
+ },
+ { items: [], saveValue }
+ );
+ await waitFor(() => expect(Object.keys(published).length).toBeGreaterThan(0));
+ expect(menuItem('Save value')).toBeUndefined();
+ });
+});
diff --git a/package.json b/package.json
index 8463f7bd..aef07749 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@qoretechnologies/reqraft",
- "version": "0.10.65",
+ "version": "0.10.66",
"description": "ReQraft is a collection of React components and hooks that are used across Qore Technologies' products made using the ReQore component library from Qore.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
diff --git a/src/components/form/engine/CompactRow.tsx b/src/components/form/engine/CompactRow.tsx
index 9adb3ab7..95b7f179 100644
--- a/src/components/form/engine/CompactRow.tsx
+++ b/src/components/form/engine/CompactRow.tsx
@@ -27,6 +27,7 @@ import { useContextSelector } from 'use-context-selector';
import { hasAllDependenciesFullfilled, parseDependency } from '../../../helpers/validations';
import { findTemplate, isValueTemplate } from '../../../helpers/templates';
import { getDefaultValue, richtextToSegments, richtextToString } from '../../../helpers/common';
+import { fieldHasLongDescription } from '../../../helpers/fieldDescription';
import { hasTemplateText, templateTextSegments } from '../../../helpers/templateText';
import { ReadOnlyTemplateTag } from '../fields/template/ReadOnlyTemplateTag';
import { TemplateText } from '../fields/template/TemplateText';
@@ -35,6 +36,7 @@ import { Description } from '../../Description';
import { useMarkdownRenderer } from '../../Description/markdownRendererContext';
import { FocusedEditing } from '../../FocusedEditing';
import { CompactRowContext } from './compactRowContext';
+import { CALM_REQUIRED_LABEL, CALM_REQUIRED_READ_LABEL, useIsCalmForm } from './formAppearance';
import { revealRow } from './revealRow';
import {
StyledActionSlot,
@@ -192,8 +194,17 @@ export const CompactRow = memo(
clusterLast?: boolean;
}) => {
const readOnly = useContextSelector(CompactRowContext, (v) => v.readOnly);
+ // The look only — see `TFormEngineAppearance`. Nothing a row DOES reads it.
+ const calm = useIsCalmForm();
const commitMode = useContextSelector(CompactRowContext, (v) => v.commitMode);
const options = useContextSelector(CompactRowContext, (v) => v.options);
+ // Whether this field's emptiness is a fault yet: the person has opened it and left,
+ // or the host reported a submit attempt. Before that a required field is only to
+ // fill in, and says so in the quiet colour.
+ const visited = useContextSelector(
+ CompactRowContext,
+ (v) => v.revealRequired || !!v.visitedOptions?.includes(optionName)
+ );
// An absorbed field keeps its own name — it is a different field, not a
// property of its host — so the label comes from its schema exactly as it
@@ -296,6 +307,11 @@ export const CompactRow = memo(
const cDanger = useContextSelector(CompactRowContext, (v) => v.cDanger);
const cWarning = useContextSelector(CompactRowContext, (v) => v.cWarning);
const cInfo = useContextSelector(CompactRowContext, (v) => v.cInfo);
+ const cText = useContextSelector(CompactRowContext, (v) => v.cText);
+ // A field's NAME: full text colour by default; grey in the calm look, so the
+ // value beside it is what the eye lands on. One colour for the read row, the
+ // open row and the edit card, so a name does not change as its field opens.
+ const cLabel = calm ? `${cText}b3` : cKey;
// A markdown value renders as a document in the row's inset; the host's
// renderer when it supplied one, reqraft's own otherwise.
@@ -402,6 +418,11 @@ export const CompactRow = memo(
}
if (valueType === 'bool' || valueType === 'boolean') {
+ // Calm: Yes and No are words. Neither is a status — "No" is not a
+ // failure — so neither is coloured.
+ if (calm) {
+ return {formatted};
+ }
const truthy = field?.value === true || field?.value === 'true';
return (
{
if (settled) return true;
const active = document.activeElement as HTMLElement | null;
+ if (provisional) {
+ if (active !== provisional) {
+ // The reader moved the caret themselves. Leave it there.
+ settled = true;
+ return true;
+ }
+ const textEntry = findRowFocusTarget(editor, { textEntryOnly: true });
+ if (!textEntry) return false;
+ textEntry.focus({ preventScroll: true });
+ // An editor's text box can be in the DOM before it can take focus (Monaco's
+ // edit area, mid-layout), and `focus()` then does nothing. Only a caret
+ // that landed counts; otherwise try again on the editor's next change.
+ if (document.activeElement !== textEntry) return false;
+ settled = true;
+ return true;
+ }
if (active && active !== document.body && !rowRef.current?.contains(active)) {
// Someone else holds the caret — a field the reader moved into while
// this row's editor was still arriving. Leave it where it is.
settled = true;
return true;
}
- const control = findRowFocusTarget(editor);
+ // A card hands the caret only to where text goes (see `findRowFocusTarget`).
+ const control = findRowFocusTarget(editor, { textEntryOnly: !inlineEditable });
if (!control) return false;
// The least scroll that shows the row clear of the pinned box header,
// and none when it is already in view (see `revealRow`).
@@ -940,6 +994,9 @@ export const CompactRow = memo(
// `preventScroll`, because the reveal above has already put the row
// where it belongs; letting focus scroll as well moves it twice.
control.focus({ preventScroll: true });
+ // A control that cannot take focus yet (still laying out) did not take
+ // the caret: try again on the editor's next change.
+ if (document.activeElement !== control) return false;
/* …and if the control the row offers is a PICKER, open it.
*
* A closed picker prints the value it holds, which is exactly what the
@@ -957,6 +1014,13 @@ export const CompactRow = memo(
if (openedByAuthor.current && inlineEditable && isClosedPickerTrigger(control)) {
openedByAuthor.current = false;
setOpenThePicker(true);
+ settled = true;
+ return true;
+ }
+ if (findRowFocusTarget(editor, { textEntryOnly: true }) !== control) {
+ // Not a text box: hold it until one arrives (see `provisional`).
+ provisional = control;
+ return false;
}
settled = true;
return true;
@@ -965,7 +1029,9 @@ export const CompactRow = memo(
const observer = new MutationObserver(() => {
if (takeCaret()) observer.disconnect();
});
- observer.observe(editor, { childList: true, subtree: true });
+ // Attributes too: an editor's text box can become focusable by an attribute or
+ // style change after it is inserted (see the `focus()` checks above).
+ observer.observe(editor, { childList: true, subtree: true, attributes: true });
return () => observer.disconnect();
}, [isExpanded, optionName, inlineEditable]);
@@ -1150,7 +1216,7 @@ export const CompactRow = memo(
fixed
flat
compact
- effect={{ uppercase: true, spaced: 1 }}
+ effect={calm ? undefined : { uppercase: true, spaced: 1 }}
/>
: null;
const isPassiveCloseAction = fixedAllowedValueOption;
@@ -1174,7 +1240,7 @@ export const CompactRow = memo(
minimal
fixed
icon='CloseLine'
- intent='warning'
+ intent={calm ? undefined : 'warning'}
// "Cancel edit", not "Cancel": a bare Cancel collides with the one
// in Reqore's confirmation modal, and this one is specifically about
// abandoning the edit rather than dismissing a dialog.
@@ -1195,7 +1261,9 @@ export const CompactRow = memo(
({ intent: m.intent as string, content: String(m.label) }))
: [];
// TWO display channels, matching the Focus prototype:
@@ -1274,7 +1346,7 @@ export const CompactRow = memo(
// with no route to the field's description at all. Stops propagation because
// both labels are click targets themselves (the row expands / collapses).
const helpIcon =
- schema?.desc ?
+ fieldHasLongDescription(schema) ?
@@ -1493,7 +1572,7 @@ export const CompactRow = memo(
tabIndex={0}
aria-label={`Collapse ${label}`}
title={schema?.short_desc || undefined}
- $color={cKey}
+ $color={cLabel}
$pointer
onClick={collapse}
onKeyDown={(event) => {
@@ -1504,7 +1583,7 @@ export const CompactRow = memo(
}}
>
{label}
- {required ?
+ {required && !calm ?
: null}
{helpIcon}
@@ -1526,6 +1605,7 @@ export const CompactRow = memo(
-
+
{(
(schema as { icon?: string } | undefined)?.icon ||
(schema as { image?: string } | undefined)?.image
@@ -1695,7 +1777,7 @@ export const CompactRow = memo(
: null}
{label}
- {required ?
+ {required && !calm ?
: null}
{/* The `?` belongs here too. The read row and the inline
@@ -1802,6 +1884,11 @@ export const CompactRow = memo(
keystroke a silent commit. Same handler, same `cancelEdit()`, so
the two shapes cannot diverge. */}
{
if (event.key === 'Escape') {
@@ -1861,7 +1948,7 @@ export const CompactRow = memo(
const valueReasons: TInfoMsg[] = [
...inlineMessages,
...(empty && coveredByLabel ?
- [{ content: `Covered by “${coveredByLabel}”`, intent: 'success' }]
+ [{ content: `Covered by “${coveredByLabel}”`, intent: calm ? undefined : 'success' }]
: []),
];
// A hash row reveals its sub-fields as read-only sub-rows under a "view
@@ -2207,10 +2294,28 @@ export const CompactRow = memo(
covered: !!coveredByLabel || groupResolved,
invalid: worstIntent === 'danger',
warned: worstIntent === 'warning',
+ visited,
});
// No dot at all in a read-only form: the dot reports progress on the
// form, and a read view has no progress to report.
- const dotStatus = hidden || fieldDisabled || readOnly ? undefined : rowStatus;
+ //
+ // Calm: no dot on a healthy row, and none on an empty required one either —
+ // its value column already says "Required · add a value" in amber, and one
+ // thing is said once. Only a failing value keeps the red dot beside the
+ // reason it gives.
+ const dotStatus =
+ hidden || fieldDisabled || readOnly || (calm && rowStatus !== 'invalid') ?
+ undefined
+ : rowStatus;
+ // Calm: an empty required value is said IN the value column, in amber,
+ // where the value would be — instead of a dash plus a red reason beside it.
+ // Not on a locked field (its lock and reason speak for it) or one that
+ // shows the default it will fall back to.
+ const calmRequiredWord =
+ calm && empty && !hidden && !!schema?.required && !fieldDisabled && !formattedDefault ?
+ readOnly ? CALM_REQUIRED_READ_LABEL
+ : CALM_REQUIRED_LABEL
+ : undefined;
const dotColor =
dotStatus === 'invalid' ? cDanger
: dotStatus === 'todo' ? cWarning
@@ -2252,7 +2357,7 @@ export const CompactRow = memo(
>
{clusterNode}
-
+
{rowChromeIcon}
{/* label + asterisk + help flow as ONE inline run, so the asterisk
stays right after the last word even when the name wraps. */}
@@ -2322,8 +2427,15 @@ export const CompactRow = memo(
// actual data — crucial when stacked under a (muted) description on
// mobile, where a dim value blends into the prose. The bold name still
// outranks it; the empty dash stays faint.
- $color={empty || hidden ? `${cMuted}66` : cKey}
- $empty={empty || hidden}
+ $color={
+ calmRequiredWord ?
+ rowStatus === 'pending' ?
+ `${cMuted}a6`
+ : cWarning
+ : empty || hidden ? `${cMuted}66`
+ : cKey
+ }
+ $empty={(empty || hidden) && !calmRequiredWord}
>
{/* A markdown value with a host renderer draws in full in the inset
below, so the row prints no value line at all: the stripped-prose
@@ -2358,13 +2470,16 @@ export const CompactRow = memo(
null
:
{hidden || empty ?
- formattedDefault || '—'
+ calmRequiredWord || formattedDefault || '—'
: renderReadFirstValue(optionField, schema, formatted, readOnly)}
}
{readOnlyChoiceDesc ?
{readOnlyChoiceDesc}
: null}
- {valueReasons.map((m, i) => (
+ {(calmRequiredWord ?
+ valueReasons.filter((m) => m.content !== calmRequiredWord)
+ : valueReasons
+ ).map((m, i) => (
: dotColor ?
-
+
: null}
diff --git a/src/components/form/engine/CompactToolbar.tsx b/src/components/form/engine/CompactToolbar.tsx
index 12c42f12..7a8d93be 100644
--- a/src/components/form/engine/CompactToolbar.tsx
+++ b/src/components/form/engine/CompactToolbar.tsx
@@ -11,9 +11,10 @@ import {
} from '@qoretechnologies/reqore/dist/components/Dropdown/list';
import { useReqoreTheme } from '@qoretechnologies/reqore/dist/hooks/useTheme';
import React, { memo } from 'react';
-import styled from 'styled-components';
+import styled, { css } from 'styled-components';
import { useContext } from 'use-context-selector';
import { CompactToolbarContext, TCompactSort } from './compactToolbarContext';
+import { useIsCalmForm } from './formAppearance';
// Field sort modes, listed in the Fields menu's "Sort by" submenu. 'schema' is
// the default (the schema's declared order); the rest reorder within each group.
@@ -28,11 +29,21 @@ const SORT_MODES: { value: TCompactSort; label: string; tooltip: string }[] = [
// "Focus" header: a summary line ({pct}% complete · {set}/{total} set ·
// {attention} need attention →) above a SEGMENTED meter — a green run (set) then
// an amber run (needs attention) then the empty remainder.
-const StyledCompletion = styled.div`
+const StyledCompletion = styled.div<{ $calm?: boolean }>`
display: flex;
flex-flow: column;
gap: 8px;
padding: 0 2px;
+ /* Calm: one line — the summary, then a short meter beside it — that wraps
+ the meter under the words where there is no room for both. */
+ ${({ $calm }) =>
+ $calm ?
+ css`
+ flex-flow: row wrap;
+ align-items: center;
+ gap: 6px 14px;
+ `
+ : ''}
`;
const StyledCompletionLine = styled.div`
display: flex;
@@ -46,11 +57,20 @@ const StyledMeter = styled.div<{
$track: string;
$set_c: string;
$att_c: string;
+ /** Calm: a short meter beside the summary instead of a full-width bar. */
+ $calm?: boolean;
}>`
position: relative;
height: 4px;
- border-radius: 3px;
+ border-radius: ${({ $calm }) => ($calm ? 2 : 3)}px;
width: 100%;
+ ${({ $calm }) =>
+ $calm ?
+ css`
+ flex: 1 1 160px;
+ max-width: 360px;
+ `
+ : ''}
overflow: hidden;
background: ${({ $track }) => $track};
/* No per-segment radius — the container's overflow:hidden + radius rounds only
@@ -113,7 +133,9 @@ export const CompactToolbar = memo((reqoreProps: Partial;
+ /* "N need attention →" / "N to fill in →" is a toggle (show only those fields), so it
+ is one: a button a keyboard reaches, which says whether it is on. A host that turns
+ it on for a person (a blocked submit) reads `aria-pressed` rather than guessing,
+ since the toolbar draws a different link once something becomes a fault. */
+ const attentionToggleProps = {
+ className: 'options-readfirst-attention-link',
+ role: 'button',
+ tabIndex: 0,
+ 'aria-pressed': !!showInvalidOnly,
+ onClick: onToggleInvalidOnly,
+ onKeyDown: (event: React.KeyboardEvent) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ onToggleInvalidOnly?.();
+ }
+ },
+ };
const cSuccess = intents.success || '#4a7110';
const cWarning = intents.warning || '#d17c29';
const cText = (theme.text?.color as string) || '#e8e8e8';
const cTrack = `${cText}1f`;
+ // Calm: what is done is grey — finished work needs nobody — and only the
+ // outstanding part (the amber run, the "need attention" link) is coloured.
+ const cDone = calm ? `${cText}8c` : cSuccess;
+ const cQuiet = `${cText}a6`;
// The GREEN run is what is done, not merely what has a value: a field that is
// set but flagged belongs to the amber run, and counting it twice pushed that
// run off the end of the track.
const setPct = completion.total ? (completion.done / completion.total) * 100 : 0;
- const attentionPct = completion.total ? (attentionCount / completion.total) * 100 : 0;
+ // The amber run is what is WRONG. On a new form the required fields are only still
+ // to fill in, so the track shows them as not done yet, without amber.
+ const attentionPct = completion.total ? (attentionFaultCount / completion.total) * 100 : 0;
return (
+
{/* "Ready" is a claim about the whole form, so anything the form
@@ -179,28 +233,43 @@ export const CompactToolbar = memo((reqoreProps: Partial
-
+
· {completion.set}/{completion.total} set
{attentionCount ? ' ·' : ''}
- {attentionCount ?
-
- {attentionCount} need attention →
+ {/* Amber only for what is a fault. A new form's required fields are still to
+ fill in, which is the next step, not a warning: they are counted apart,
+ in the quiet colour. */}
+ {attentionFaultCount ?
+
+ {attentionFaultCount} need attention →
: null}
- {completion.pct}%
+ {attentionCount > attentionFaultCount ?
+ attentionFaultCount ?
+
+ · {attentionCount - attentionFaultCount} to fill in
+
+ :
+ {attentionCount} to fill in →
+
+ : null}
+ {/* Calm: the percentage is one more quiet figure on the line, not
+ a large bold number pinned to the far edge. */}
+ {calm ?
+
+ · {completion.pct}%
+
+ : {completion.pct}%}
: null}
diff --git a/src/components/form/engine/FormEngine.stories.tsx b/src/components/form/engine/FormEngine.stories.tsx
index 5ff75f3c..0fa52be1 100644
--- a/src/components/form/engine/FormEngine.stories.tsx
+++ b/src/components/form/engine/FormEngine.stories.tsx
@@ -41,6 +41,12 @@ import {
import { basicFormValue, getBasicFormOptions as getOptions } from './__fixtures__/basicFormOptions';
import { chromeFieldBases, metaFieldBases } from './__fixtures__/fieldChromeOptions';
+/** The first status box: "To fill in" while nothing in it is a fault (a new form),
+ * "Needs attention" once something is. Stories about which box a field lands in find
+ * it under either name. */
+const FIRST_BOX = /^(Needs attention|To fill in)$/;
+const FIRST_BOX_PREFIX = /^(Needs attention|To fill in)/;
+
// schema data
const TestOptionsWithRequiredGroups: IOptionsSchema = {
@@ -2243,6 +2249,7 @@ export const CompactLockedRequiredNeedsNoAttention: Story = {
await expect(document.querySelector('[data-field="min"]')).toBeTruthy();
await expect(document.querySelector('[data-field="max"]')).toBeTruthy();
await _testsWaitForTextToNotExist('Needs attention');
+ await _testsWaitForTextToNotExist('To fill in');
// Nothing the author can fill in is missing, so the form is Ready — the
// locked fields do not apply until a kind is picked.
await _testsWaitForText('Ready');
@@ -2266,11 +2273,11 @@ export const CompactUnlockedRequiredNeedsAttention: Story = {
value: { kind: { type: 'string', value: 'between' } } as IOptions,
},
play: async () => {
- await _testsWaitForText('Needs attention');
+ await _testsWaitForText(FIRST_BOX);
await _testsWaitForText('Incomplete');
await _testsWaitForTextToNotExist('Draft');
const attention = [...document.querySelectorAll('.options-readfirst-group')].find((group) =>
- (group.textContent || '').startsWith('Needs attention')
+ FIRST_BOX_PREFIX.test(group.textContent || '')
);
await expect(attention).toBeTruthy();
for (const field of ['expected', 'min', 'max']) {
@@ -3070,6 +3077,7 @@ export const CompactReadOnly: Story = {
await _testsWaitForTextToNotExist('Incomplete');
await _testsWaitForTextToNotExist('Draft');
await _testsWaitForTextToNotExist('Needs attention');
+ await _testsWaitForTextToNotExist('To fill in');
await _testsWaitForTextToNotExist('Optional');
// A required field nobody set is listed, unset, with no amber dot.
await _testsWaitForText('Description');
@@ -3551,6 +3559,7 @@ export const CompactReadOnlyValues: Story = {
expect(document.querySelector('.options-readfirst-completion')).toBeNull();
expect(document.querySelector('.options-readfirst-group')).toBeNull();
await _testsWaitForTextToNotExist('Needs attention');
+ await _testsWaitForTextToNotExist('To fill in');
await _testsWaitForTextToNotExist('Optional');
// An optional field nobody set is not listed at all…
await _testsWaitForTextToNotExist('Notes');
@@ -3932,7 +3941,7 @@ export const CompactValidIdentifierRule: Story = {
// The rules-driven validation marks the form as needing attention — the
// dedicated "Needs attention" box (and the header link) signal it.
await _testsWaitForText('Incomplete');
- await _testsWaitForText('Needs attention');
+ await _testsWaitForText(FIRST_BOX);
},
};
@@ -6764,7 +6773,7 @@ export const CompactShowcase: Story = {
// Optional box's fold, which scrolls, so the frame is put back afterwards:
// this story's snapshot is the form's top.
const attentionBox = Array.from(document.querySelectorAll('.options-readfirst-group')).find(
- (group) => (group.textContent || '').startsWith('Needs attention')
+ (group) => FIRST_BOX_PREFIX.test(group.textContent || '')
);
await expect(
attentionBox?.querySelector('[data-field="optionWithShortDescription"]')
@@ -8092,9 +8101,11 @@ export const CompactExpressionWithoutCatalogue: Story = {
* A collapsed ReqorePanel unmounts its content, so "has no
* `.reqore-panel-content`" IS the collapsed assertion. */
const findStatusBox = (title: string) =>
- Array.from(document.querySelectorAll('.options-readfirst-group')).find((panel) =>
- panel.querySelector('.reqore-panel-title')?.textContent?.includes(title)
- );
+ Array.from(document.querySelectorAll('.options-readfirst-group')).find((panel) => {
+ const heading = panel.querySelector('.reqore-panel-title')?.textContent ?? '';
+ // The first box is "To fill in" until something in it is a fault.
+ return title === 'Needs attention' ? FIRST_BOX_PREFIX.test(heading) : heading.includes(title);
+ });
const expectBoxCollapsed = async (title: string) => {
await waitFor(
@@ -9417,7 +9428,7 @@ export const ANewFormSaysWhatIsRequired: Story = {
docs: {
description: {
story:
- 'A brand-new form with two empty required fields. Each says "This field is required" from the start, including the first one, which opens for editing, without waiting for the author to visit it.',
+ 'A brand-new form with two empty required fields. Each says "This field is required" from the start, including the first one, which opens for editing, without waiting for the author to visit it. Saying what is required is not saying something is wrong: the first box reads "To fill in", in the quiet colour, until a field has been visited and left empty.',
},
},
},
@@ -9429,7 +9440,8 @@ export const ANewFormSaysWhatIsRequired: Story = {
expandFirstRequired: true,
},
play: async ({ canvasElement }) => {
- await _testsWaitForText('Needs attention');
+ await _testsWaitForText('To fill in');
+ await _testsWaitForTextToNotExist('Needs attention');
await waitFor(() => {
const title = canvasElement.querySelector('[data-field="title"]');
const owner = canvasElement.querySelector('[data-field="owner"]');
@@ -9551,7 +9563,7 @@ export const AMovedRowInViewIsNotScrolled: Story = {
options: MovedRowSchema,
},
play: async () => {
- await _testsWaitForText('Needs attention');
+ await _testsWaitForText(FIRST_BOX);
const watch = watchRowScrolls();
try {
const scroller = movedRowScroller();
@@ -9610,7 +9622,7 @@ export const AMovedRowLandsClearOfThePinnedHeader: Story = {
await scrolled;
const row = document.querySelector('.readfirst-row[data-field="owner"]')!;
const attention = [...document.querySelectorAll('.options-readfirst-group')].find(
- (group) => (group.textContent || '').startsWith('Needs attention')
+ (group) => FIRST_BOX_PREFIX.test(group.textContent || '')
)!;
const header = attention.querySelector(':scope > .reqore-panel-title')!;
const rowRect = row.getBoundingClientRect();
@@ -9634,12 +9646,12 @@ export const AMovedRowLandsClearOfThePinnedHeader: Story = {
* slide UNDER the toolbar, never over it. Each step waits on `scrollend`.
*/
const assertBoxHeaderSlidesUnderToolbar = async (scroller: HTMLElement) => {
- await _testsWaitForText('Needs attention');
+ await _testsWaitForText(FIRST_BOX);
const toolbar = document.querySelector(
'.options-readfirst-scroll > .reqore-panel > .reqore-panel-title'
)!;
const box = [...document.querySelectorAll('.options-readfirst-group')].find(
- (group) => (group.textContent || '').startsWith('Needs attention')
+ (group) => FIRST_BOX_PREFIX.test(group.textContent || '')
)!;
const header = box.querySelector(':scope > .reqore-panel-title')!;
const scrollBy = async (delta: number) => {
diff --git a/src/components/form/engine/FormEngine.tsx b/src/components/form/engine/FormEngine.tsx
index 9b898b85..09eb7097 100644
--- a/src/components/form/engine/FormEngine.tsx
+++ b/src/components/form/engine/FormEngine.tsx
@@ -6,6 +6,7 @@ import {
ReqoreIcon,
ReqoreMessage,
ReqoreP,
+ ReqoreSpan,
ReqoreTag,
ReqoreTagGroup,
ReqoreVerticalSpacer,
@@ -43,6 +44,7 @@ import { offersTypeChoices } from './typeChoices';
import { optionRowActions, resolveOptionActions, TOptionActions } from './optionActions';
import { createRendererOnlyUiTypeCheck, isRendererOnlyUiType } from './rendererTypes';
import { revealRow } from './revealRow';
+import { findRowFocusTarget } from './rowFocus';
import { useEmittedValues } from '../fields/emittedValues';
import { cloneDeep, findKey, flatten, forEach, isEqual, isPlainObject, last, uniq } from 'lodash';
import map from 'lodash/map';
@@ -51,6 +53,8 @@ import size from 'lodash/size';
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useMeasure, useMount, useUpdateEffect } from 'react-use';
import styled, { css } from 'styled-components';
+import { allowedValueOf } from '../../../helpers/allowedValues';
+import { fieldHasLongDescription } from '../../../helpers/fieldDescription';
import {
fixOperatorValue,
getDefaultValue,
@@ -84,6 +88,7 @@ import { CompactRow } from './CompactRow';
import { FormFieldsSkeleton } from './FormFieldsSkeleton';
import { CompactRowContext, ICompactRowContext, TCodePreviewRenderer } from './compactRowContext';
import {
+ CALM_SECTION_INSET,
GROUP_INDENT,
LABEL_AFFORDANCE_WIDTH,
LABEL_COL_MAX,
@@ -96,6 +101,7 @@ import {
StyledRequiredClusterHeader,
StyledStatusBox,
StyledStatusBoxGroupLabel,
+ StyledStatusDot,
} from './compactRowStyles';
import { CompactToolbar } from './CompactToolbar';
import {
@@ -114,6 +120,14 @@ import {
TMarkdownRenderer,
} from '../../Description/markdownRendererContext';
import { OptionsHelpDialog } from './OptionsHelpDialog';
+import {
+ FormAppearanceProvider,
+ getCalmPanelHairline,
+ getCalmPanelSurface,
+ TFormEngineAppearance,
+ useFormAppearance,
+ useIsCalmForm,
+} from './formAppearance';
import { firstDeclaredType, isUntypedOptionType } from '../../../helpers/optionUiTypes';
import {
TReadFirstStatus,
@@ -197,6 +211,14 @@ export interface IFormValidityData {
invalidFields: IFormFieldValidityData[];
}
+/** The two numbers the readiness line shows ("2 need attention · 1 to fill in"). */
+export interface IFormAttention {
+ /** Fields in the first box: still to fill in, and faults. */
+ count: number;
+ /** How many of those are faults (amber) rather than only still to fill in. */
+ faults: number;
+}
+
/**
* Resolve a field's `inherit_props` map against the current available
* options (plus any values inherited from an outer FormEngine scope),
@@ -848,6 +870,21 @@ export interface IFormEngineProps extends Omit>;
+ /**
+ * The host attempted a submit. Until then an empty required field the person has not
+ * been to yet is only "to fill in": a new form opens neutral rather than with every
+ * required field in amber, and a field turns amber once someone opens it and leaves
+ * it empty. Pass `true` after a failed submit to show every empty required field as
+ * needing attention. Invalid values are faults from the start either way.
+ */
+ revealRequired?: boolean;
+ /**
+ * Bump it to move the caret to the first field that needs attention: the answer to a
+ * submit that could not go is where to type. Each new value opens that field and puts
+ * the caret in it once, even though the host's submit button holds the caret (an open
+ * row otherwise never takes a caret held outside it). Pair it with `revealRequired`.
+ */
+ focusAttentionRequest?: number;
+ /**
+ * The readiness line's two numbers, reported whenever they change. A host that draws
+ * its own submit control draws it from these, so the control and the form can never
+ * disagree about what is left. `{ count: 0, faults: 0 }` is a form with nothing left.
*/
- compactBoxLabels?: Partial>;
+ onAttentionChange?: (attention: IFormAttention) => void;
/**
* Compact mode only: the three status-box headers stay pinned to the top of
* whatever scrolls while their own box is in view. Default `true`.
@@ -1235,6 +1296,9 @@ const FormEngineImpl = ({
forceDropdown,
showTypeToggle = true,
compact,
+ // Read through the context `FormEngine` provides (resolved there against an
+ // inherited appearance); destructured only to keep it out of `rest`.
+ appearance: _appearance, // eslint-disable-line @typescript-eslint/no-unused-vars
// Destructured only to keep it out of `rest` (and so off the DOM). The gutter
// this prop documents was never implemented: it was passed to the wrap as
// `$flush`, whose styled template had no interpolation slot, so it has always
@@ -1247,6 +1311,9 @@ const FormEngineImpl = ({
compactPanelProps,
compactCollapsedGroups: compactCollapsedGroupsProp,
compactBoxLabels,
+ revealRequired = false,
+ focusAttentionRequest,
+ onAttentionChange,
compactStickyBoxHeaders = true,
compactStickyOffset = 0,
compactToolbar = true,
@@ -1318,6 +1385,7 @@ const FormEngineImpl = ({
const [loading, setLoading] = useState(!!(url || customUrl || operatorsUrl));
const confirmAction = useReqoreProperty('confirmAction');
const theme = useReqoreTheme();
+ const calm = useIsCalmForm();
const [focusedEditing, setFocusedEditing] = useState();
const [showFieldTypes, setShowFieldTypes] = useState(false);
// Global toggle (toolbar ⓘ): reveal the short-description info panel on every
@@ -1330,6 +1398,21 @@ const FormEngineImpl = ({
const [showInvalidOptionsOnly, setShowInvalidOptionsOnly] = useState(false);
// Which options are expanded into their editor (several can be open at once).
const [expandedOptions, setExpandedOptions] = useState([]);
+ // The fields the person has opened and left. An empty required field becomes a
+ // fault only then (or after a submit attempt, `revealRequired`): a form nobody has
+ // touched is not already wrong.
+ const [visitedOptions, setVisitedOptions] = useState([]);
+ const lastExpandedOptions = useRef([]);
+ useEffect(() => {
+ const left = lastExpandedOptions.current.filter((name) => !expandedOptions.includes(name));
+ lastExpandedOptions.current = expandedOptions;
+ if (left.length) {
+ setVisitedOptions((prev) => {
+ const added = left.filter((name) => !prev.includes(name));
+ return added.length ? [...prev, ...added] : prev;
+ });
+ }
+ }, [expandedOptions]);
/** Status boxes whose "N more fields" control the reader has opened. Keyed by
box so opening Optional does not also unfold Set. */
const [expandedMoreBoxes, setExpandedMoreBoxes] = useState([]);
@@ -2270,7 +2353,7 @@ const FormEngineImpl = ({
forEach(options || {}, (optionSchema, name) => {
forEach(optionSchema?.allowed_values || [], (allowedValue) => {
if (!getAllowedValueAvailability(allowedValue, availableOptions, options || {}).available) {
- locked.push(`${name}\u0000${JSON.stringify(allowedValue?.value?.value)}`);
+ locked.push(`${name}\u0000${JSON.stringify(allowedValueOf(allowedValue))}`);
}
});
});
@@ -2458,6 +2541,11 @@ const FormEngineImpl = ({
(newValue, option, optionName) => {
if (
showInvalidOptionsOnly &&
+ // An open row stays while the reader is in it, as its box does
+ // (`stableBucketOf`): the first character typed into an empty required
+ // field makes it valid, and hiding it then would pull the field out
+ // from under the caret mid-word.
+ !expandedOptions.includes(optionName) &&
isOptionValid(
optionName,
getOptionFieldStorageType(
@@ -2480,6 +2568,7 @@ const FormEngineImpl = ({
);
}, [
showInvalidOptionsOnly,
+ expandedOptions,
isRendererOnly,
JSON.stringify(availableOptions),
JSON.stringify(options),
@@ -2548,9 +2637,12 @@ const FormEngineImpl = ({
covered,
invalid,
warned: msgIntent === 'warning',
+ visited: revealRequired || visitedOptions.includes(name),
});
},
[
+ revealRequired,
+ visitedOptions,
isRendererOnly,
JSON.stringify(options),
JSON.stringify(availableOptions),
@@ -2583,6 +2675,24 @@ const FormEngineImpl = ({
.length,
[JSON.stringify(availableOptions), getOptionBucket]
);
+ // How many of those are faults rather than only still to fill in (`pending`). Zero
+ // on a new form, so its first box reads "To fill in" without amber.
+ const readFirstAttentionFaultCount = useMemo(
+ () =>
+ Object.keys(availableOptions || {}).filter(
+ (name) => getOptionBucket(name) === 'attention' && getOptionStatus(name) !== 'pending'
+ ).length,
+ [JSON.stringify(availableOptions), getOptionBucket, getOptionStatus]
+ );
+ // The same two numbers, to a host that draws its own submit control from them.
+ const onAttentionChangeRef = useRef(onAttentionChange);
+ onAttentionChangeRef.current = onAttentionChange;
+ useEffect(() => {
+ onAttentionChangeRef.current?.({
+ count: readFirstAttentionCount,
+ faults: readFirstAttentionFaultCount,
+ });
+ }, [readFirstAttentionCount, readFirstAttentionFaultCount]);
const getIntent = useCallback(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -2708,6 +2818,39 @@ const FormEngineImpl = ({
// field update or an in-place schema reload will NOT re-grab focus; only a
// genuine remount (a fresh instance) auto-focuses again, which is the intended
// on-mount behaviour.
+ // What the "first field to fix" scans read: the opener on arrival below and the
+ // caret move on a submit attempt (`focusAttentionRequest`) pick the same field.
+ const getAttentionFieldMeta = useCallback(
+ (fieldName: string) => {
+ const schema = options?.[fieldName];
+ if (!schema) {
+ return undefined;
+ }
+ return {
+ focusable:
+ !readOnly &&
+ !schema.disabled &&
+ !(schema as { readonly?: boolean }).readonly &&
+ !dependencyLockedNames.includes(fieldName),
+ // Single source of truth: the same bucket the read-first status boxes
+ // show, so a filled-but-invalid required row is a target too.
+ needsAttention: getOptionBucket(fieldName) === 'attention',
+ };
+ },
+ [options, readOnly, dependencyLockedNames, getOptionBucket]
+ );
+ // Opening a row by name: `expandMode: 'single'` replaces only what a click could
+ // (see the note in the opener below), never a row the host named.
+ const openOption = useCallback(
+ (target: string) =>
+ setExpandedOptions((prev) =>
+ prev.includes(target) ? prev
+ : expandMode === 'multi' ? [...prev, target]
+ : [...prev.filter((name) => initialExpandedOptions?.includes(name)), target]
+ ),
+ [expandMode, initialExpandedOptions]
+ );
+
const hasAutoFocusedRef = useRef(false);
useEffect(() => {
// Two decisions, one scan. Both open the row that needs attention;
@@ -2746,22 +2889,7 @@ const FormEngineImpl = ({
return;
}
- const target = getFirstAttentionOptionName(orderedNames, (fieldName) => {
- const schema = options[fieldName];
- if (!schema) {
- return undefined;
- }
- return {
- focusable:
- !readOnly &&
- !schema.disabled &&
- !(schema as { readonly?: boolean }).readonly &&
- !dependencyLockedNames.includes(fieldName),
- // Single source of truth: the same bucket the read-first status boxes
- // show, so a filled-but-invalid required row is a target too.
- needsAttention: getOptionBucket(fieldName) === 'attention',
- };
- });
+ const target = getFirstAttentionOptionName(orderedNames, getAttentionFieldMeta);
// Mark done even when nothing needs attention, so later value edits or an
// in-place schema reload never re-scan or re-focus. Only a remount arms it
@@ -2769,17 +2897,13 @@ const FormEngineImpl = ({
hasAutoFocusedRef.current = true;
if (target) {
- setExpandedOptions((prev) =>
- prev.includes(target) ? prev
- : expandMode === 'multi' ? [...prev, target]
- // `expandMode: 'single'` is a rule about what a CLICK does: opening a
- // row closes the one the reader last opened. It is not a licence to
- // close a row the HOST named through `initialExpandedOptions`. That
- // is an address — a statement about what the page is FOR, made before
- // the reader did anything — and this scan is a convenience layered on
- // top of it. Keep the named rows; replace only what a click could.
- : [...prev.filter((name) => initialExpandedOptions?.includes(name)), target]
- );
+ // `expandMode: 'single'` is a rule about what a CLICK does: opening a row
+ // closes the one the reader last opened. It is not a licence to close a row
+ // the HOST named through `initialExpandedOptions`. That is an address — a
+ // statement about what the page is FOR, made before the reader did anything
+ // — and this scan is a convenience layered on top of it. `openOption` keeps
+ // the named rows and replaces only what a click could.
+ openOption(target);
}
}, [
autoFocusFirstRequired,
@@ -2787,13 +2911,52 @@ const FormEngineImpl = ({
compact,
options,
availableOptions,
- readOnly,
- dependencyLockedNames,
- getOptionBucket,
- expandMode,
- initialExpandedOptions,
+ getAttentionFieldMeta,
+ openOption,
]);
+ // A submit that could not go is answered with where to type: each new
+ // `focusAttentionRequest` opens the first field that needs attention and puts the
+ // caret in it. The host's submit button holds the caret at that moment, and an open
+ // row never takes a caret held outside it (CompactRow), so the button lets go of it
+ // here first; the person asked to submit, and this is the answer to that.
+ const lastFocusRequest = useRef(focusAttentionRequest);
+ useEffect(() => {
+ if (focusAttentionRequest === lastFocusRequest.current) {
+ return;
+ }
+ lastFocusRequest.current = focusAttentionRequest;
+ if (!focusAttentionRequest || !compact || !options) {
+ return;
+ }
+ const target = getFirstAttentionOptionName(
+ Object.keys(availableOptions),
+ getAttentionFieldMeta
+ );
+ if (!target) {
+ return;
+ }
+ const wrap = compactWrapNodeRef.current;
+ const active = document.activeElement as HTMLElement | null;
+ if (active && active !== document.body && !wrap?.contains(active)) {
+ active.blur();
+ }
+ if (!expandedOptions.includes(target)) {
+ // The row's own open effect reveals it and takes the caret.
+ openOption(target);
+ return;
+ }
+ // Already open (the field the form opened on arrival): its open effect has run,
+ // so the caret goes to its control here.
+ const row = wrap?.querySelector(`[data-field="${target}"]`);
+ if (row) {
+ revealRow(row, 'auto');
+ findRowFocusTarget(row.querySelector('.options-readfirst-row-editor'), {
+ textEntryOnly: row.classList.contains('options-readfirst-card'),
+ })?.focus({ preventScroll: true });
+ }
+ }, [focusAttentionRequest]);
+
// Read-first completion summary (how many shown options have a value set),
// surfaced as a progress meter at the top of the compact form.
const readFirstCompletion = useMemo(
@@ -3191,6 +3354,9 @@ const FormEngineImpl = ({
option={{ type: resolvedType, ...other }}
getType={getTypeForOption}
isRendererOnly={isRendererOnly}
+ // Open is not left: a field the form opened for typing (the first empty
+ // one, on a new form) says what it needs without already being wrong.
+ requirementPending={!(revealRequired || visitedOptions.includes(optionName))}
/>
{operators && size(operators) && size(other.op) ?
<>
@@ -3242,6 +3408,8 @@ const FormEngineImpl = ({
getTypeForOption,
inheritedScopes,
inheritedFromParent,
+ revealRequired,
+ visitedOptions,
]
);
@@ -3266,6 +3434,10 @@ const FormEngineImpl = ({
// The "N more fields" row's dashed edge. Well above the divider tint: this
// one is an affordance the reader has to notice, not a hairline between rows.
const cMoreBorder = `${cText}4d`;
+ // Calm: the section surface and its hairline, from the theme's panel (the
+ // same surface and edge Reqore gives the section panels themselves).
+ const cPanel = getCalmPanelSurface(theme);
+ const cHairline = getCalmPanelHairline(theme);
// The closure surface the extracted CompactRow reads through context. Refs and
// setters are stable; the state/memo/handler fields change identity as they do
@@ -3284,6 +3456,8 @@ const FormEngineImpl = ({
codePreviewRenderer,
showAllDescriptions,
expandedOptions,
+ visitedOptions,
+ revealRequired,
highlightedOptions,
flashedOptions,
infoPanelOverrides,
@@ -3332,6 +3506,8 @@ const FormEngineImpl = ({
codePreviewRenderer,
showAllDescriptions,
expandedOptions,
+ visitedOptions,
+ revealRequired,
highlightedOptions,
flashedOptions,
infoPanelOverrides,
@@ -3383,6 +3559,7 @@ const FormEngineImpl = ({
readOnly,
invalidCount: size(validityData.invalidFields),
attentionCount: readFirstAttentionCount,
+ attentionFaultCount: readFirstAttentionFaultCount,
completion: readFirstCompletion,
showInvalidOnly: showInvalidOptionsOnly,
onToggleInvalidOnly: handleToggleInvalidOnly,
@@ -3430,6 +3607,7 @@ const FormEngineImpl = ({
onReadOnlyActivate,
validityData,
readFirstAttentionCount,
+ readFirstAttentionFaultCount,
readFirstCompletion,
showInvalidOptionsOnly,
handleToggleInvalidOnly,
@@ -3711,12 +3889,20 @@ const FormEngineImpl = ({
intent?: 'warning' | 'success';
icon: IReqoreIconName;
}> = [
- {
- key: 'attention',
- label: compactBoxLabels?.attention ?? 'Needs attention',
- intent: 'warning',
- icon: 'ErrorWarningLine',
- },
+ // The first box is amber only once something in it is a fault. On a new form it
+ // holds the required fields still to fill in, which is not yet anything wrong.
+ readFirstAttentionFaultCount ?
+ {
+ key: 'attention',
+ label: compactBoxLabels?.attention ?? 'Needs attention',
+ intent: 'warning',
+ icon: 'ErrorWarningLine',
+ }
+ : {
+ key: 'attention',
+ label: compactBoxLabels?.pending ?? 'To fill in',
+ icon: 'CheckboxBlankCircleLine',
+ },
{ key: 'set', label: compactBoxLabels?.set ?? 'Set', intent: 'success', icon: 'CheckLine' },
{
key: 'optional',
@@ -3785,15 +3971,28 @@ const FormEngineImpl = ({
// box (the Focus cluster). A met group needs no banner — the rail + the
// "Covers"/"Covered by" chips already say which member satisfies it.
if (requiredGroupsInfo.satisfiedBy[grp]) return railed;
+ // Amber once someone has been to the group (or a submit was attempted); until
+ // then it is still to fill in, in the quiet colour, like its members.
+ const clusterColor =
+ memberEntries.some((e) => getOptionStatus(e.name) !== 'pending') ? cWarning : cMuted;
return (
-
-
+
+
One of the below is required
{railed}
@@ -3821,20 +4020,31 @@ const FormEngineImpl = ({
return (
{showGroupSubLabel(groupName) ?
-
+
{getOptionGroupLabel(groupName, groups)}
: null}
{showGroupSubLabel(groupName) && groupConfig?.subtitle ?
{groupConfig.subtitle}
@@ -3888,8 +4098,15 @@ const FormEngineImpl = ({
)
}
flat
- raised
+ // Calm: no surface and no lift at all. A theme that gives
+ // panels a surface of their own (Reqore's `theme.panel`) would
+ // otherwise paint this wrapper as a card around the section
+ // cards inside it. Transparent and un-raised, Reqore also drops
+ // the wrapper's horizontal padding, so the readiness line, the
+ // search and the sections share one left edge with the host.
+ raised={!calm}
minimal
+ transparent={calm || undefined}
// No panel background: the form sits transparently on whatever
// hosts it (page, drawer, or — for an arg_schema field — the
// parent's edit card) instead of stacking its own dark surface.
@@ -3967,8 +4184,13 @@ const FormEngineImpl = ({
the reader's record withheld, and a page scrolls. */}
{readOnly ?
bucketCount('set') ?
-
+
{bucketGroups.set.map((groupName) => renderBoxGroup('set', groupName))}
@@ -4004,6 +4226,17 @@ const FormEngineImpl = ({
const orderedRows: TRowEntry[] = groupsInBox.flatMap(
(groupName) => buckets[box.key][groupName] || []
);
+ // The number in the box's header counts the rows whose value
+ // belongs in it NOW. A field being edited keeps its box until it
+ // is left (so it does not jump away under the caret), and it used
+ // to keep its count too: a box read "To fill in 1" while its last
+ // field already had a value and the host's submit read ready.
+ const headerCount =
+ readOnly ? count : (
+ orderedRows.filter(
+ (entry) => getOptionBucket(entry.name, entry.hidden) === box.key
+ ).length
+ );
// Optional's own rule first: a box opened solely to reveal
// preselected rows offers those, not an arbitrary first-N.
const candidateRows =
@@ -4036,24 +4269,32 @@ const FormEngineImpl = ({
);
const moreOpen = Boolean(query) || expandedMoreBoxes.includes(box.key);
const accent =
- box.key === 'attention' ? cWarning
+ box.key === 'attention' && readFirstAttentionFaultCount ? cWarning
: box.key === 'set' ? cSuccess
: cMuted;
// The muted "Optional" box reads as a quieter, recessed
// surface — a touch darker than the page rather than the
// faint grey tint the accent would give.
+ // Calm: every box is the same panel — no recessed one.
const boxBg =
- box.key === 'optional' ?
+ box.key === 'optional' && !calm ?
changeDarkness(getMainBackgroundColor(theme), 0.06)
: undefined;
return (
{
statusBoxRefs.current[box.key] = node;
@@ -4063,7 +4304,11 @@ const FormEngineImpl = ({
onCollapseChange={(collapsed) =>
setOpenedStatusBox(collapsed ? undefined : box.key)
}
- flat
+ // Calm: an ordinary panel — Reqore paints the theme's
+ // panel surface and its hairline edge. The header stays
+ // minimal (no surface or rule of its own); the first
+ // row's hairline is the line under it.
+ flat={!calm}
minimal
/* The header stays on screen while its own box is
being read — see `compactStickyBoxHeaders`. Sticky
@@ -4101,29 +4346,57 @@ const FormEngineImpl = ({
// author.
isCollapsed={wouldCollapse && !hasPreselectedRows}
label={
-
-
- {box.label}
-
-
-
+ calm ?
+ // One status dot and a word — and only where a
+ // person is needed. The count is a quiet number.
+
+ {box.key === 'attention' && readFirstAttentionFaultCount ?
+
+ : null}
+
+ {box.label}
+
+ {headerCount ?
+
+ {headerCount}
+
+ : null}
+
+ :
+
+ {box.label}
+
+ {headerCount ?
+
+ : null}
+
}
- icon={box.icon}
+ icon={calm ? undefined : box.icon}
className='options-readfirst-group'
padded={false}
- contentStyle={{ padding: '4px 4px 6px' }}
+ contentStyle={{ padding: calm ? 0 : '4px 4px 6px' }}
>
{/* ONE group body per box: every field block (and the
thin schema sub-labels) is a direct flex child, so the
inter-field gap is identical everywhere — including
across schema-group boundaries. */}
handleOptionLabelClick(optionName) : undefined,
+ fieldHasLongDescription(options[optionName]) ?
+ () => handleOptionLabelClick(optionName)
+ : undefined,
},
showLabelTooltip: !!options[optionName]?.short_desc,
customLabelTooltip: {
delay: 300,
content: (
),
@@ -4398,7 +4673,8 @@ const FormEngineImpl = ({
descriptionEffect: {
textSize: '11px',
opacity: 0.3,
- underline: options[optionName]?.desc ? 'underline dotted #878787' : undefined,
+ underline:
+ fieldHasLongDescription(options[optionName]) ? 'underline dotted #878787' : undefined,
},
customTheme: {
main: 'main:darken:1',
@@ -4560,10 +4836,20 @@ const FormEngineImpl = ({
* a child of the row it belongs to. Threading a renderer to each of them by
* hand is how one of them gets missed.
*/
-export const FormEngine = (props: IFormEngineProps) => (
-
-
-
-);
+export const FormEngine = (props: IFormEngineProps) => {
+ // The form's own `appearance` wins; without one it follows the form (or the
+ // host's provider) it sits in, so a nested sub-form never changes look
+ // halfway down its parent.
+ const inherited = useFormAppearance();
+ const appearance = props.appearance ?? inherited;
+
+ return (
+
+
+
+
+
+ );
+};
export default FormEngine;
diff --git a/src/components/form/engine/FormEngineAppearance.stories.tsx b/src/components/form/engine/FormEngineAppearance.stories.tsx
new file mode 100644
index 00000000..880070d3
--- /dev/null
+++ b/src/components/form/engine/FormEngineAppearance.stories.tsx
@@ -0,0 +1,540 @@
+import { Meta, StoryObj } from '@storybook/react-vite';
+import { useState } from 'react';
+import { expect, fn, waitFor } from 'storybook/test';
+import {
+ _testsClickText,
+ _testsWaitForText,
+ _testsWaitForTextToNotExist,
+ _testsWithViewport,
+} from '../../../stories/Tests/utils';
+import {
+ FormEngine,
+ IFormEngineGroup,
+ IFormEngineProps,
+ IOptions,
+ IOptionsSchemaArg,
+} from './FormEngine';
+import { CALM_REQUIRED_LABEL, CALM_REQUIRED_READ_LABEL } from './formAppearance';
+
+/*
+ * The calm appearance against the default one, over the fields a workflow
+ * creator actually serves (display names, groups and descriptions as the Qorus
+ * server's workflow metadata gives them), so the comparison is about the look
+ * and nothing else.
+ */
+
+const groups: Record = {
+ info: { label: 'Info', sort: 1 },
+ resources: { label: 'Resources', sort: 2 },
+ scaling: { label: 'Scaling', sort: 3 },
+ business: { label: 'Business', sort: 4 },
+};
+
+// `group` is server-supplied metadata not yet in the typed schema; widen locally.
+type TAppearanceField = IOptionsSchemaArg & { group?: string };
+
+const options: Record = {
+ name: {
+ type: 'string',
+ display_name: 'Internal Name',
+ short_desc: 'The internal technical name of the object',
+ group: 'info',
+ preselected: true,
+ },
+ display_name: {
+ type: 'string',
+ display_name: 'Name',
+ short_desc: 'The display name of the object',
+ required: true,
+ group: 'info',
+ },
+ desc: {
+ type: 'long-string',
+ display_name: 'Description',
+ short_desc: 'The markdown description of the object',
+ required: true,
+ group: 'info',
+ },
+ version: {
+ type: 'string',
+ display_name: 'Version',
+ short_desc: 'The object’s version',
+ required: true,
+ group: 'info',
+ },
+ lang: {
+ type: 'string',
+ display_name: 'Language',
+ required: true,
+ group: 'info',
+ messages: [
+ {
+ intent: 'info',
+ content: 'Changing the language later generates a new source skeleton.',
+ },
+ ],
+ allowed_values: [
+ { value: { type: 'string', value: 'qore' }, display_name: 'Qore' },
+ { value: { type: 'string', value: 'python' }, display_name: 'Python' },
+ { value: { type: 'string', value: 'java' }, display_name: 'Java' },
+ ],
+ },
+ classes: {
+ type: 'string',
+ display_name: 'Classes',
+ short_desc: 'Any classes used by the object',
+ group: 'resources',
+ preselected: true,
+ },
+ autostart: {
+ type: 'int',
+ display_name: 'Autostart',
+ short_desc: 'The number of execution instances started automatically for the workflow',
+ group: 'scaling',
+ preselected: true,
+ },
+ max_instances: {
+ type: 'int',
+ display_name: 'Maximum Instances',
+ short_desc: 'The maximum number of workflow execution instances that can be started',
+ group: 'scaling',
+ preselected: true,
+ },
+ remote: {
+ type: 'bool',
+ display_name: 'Remote?',
+ short_desc: 'Should this interface run in a dedicated process?',
+ group: 'scaling',
+ preselected: true,
+ },
+ business_category: {
+ type: 'string',
+ display_name: 'Business Category',
+ short_desc: 'Automation intelligence category for this interface',
+ group: 'business',
+ preselected: true,
+ },
+ author: {
+ type: 'string',
+ display_name: 'Author',
+ short_desc: 'The author of the object',
+ group: 'info',
+ },
+ sla_threshold: {
+ type: 'int',
+ display_name: 'SLA Threshold',
+ short_desc: 'The time in seconds before which workflow orders should get a final status',
+ group: 'scaling',
+ },
+};
+
+const filledValue: IOptions = {
+ name: { type: 'string', value: 'supplier-payment-release' },
+ display_name: { type: 'string', value: 'Supplier Payment Release' },
+ desc: {
+ type: 'long-string',
+ value: 'Reconciles inbound orders with supplier confirmations and payment holds before release.',
+ },
+ version: { type: 'string', value: '1.0' },
+ lang: { type: 'string', value: 'qore' },
+ classes: { type: 'string', value: 'SupplyChainReconciler' },
+ autostart: { type: 'int', value: 1 },
+ max_instances: { type: 'int', value: 20 },
+ remote: { type: 'bool', value: false },
+ business_category: { type: 'string', value: 'Supply Chain' },
+};
+
+const newFormValue: IOptions = {
+ lang: { type: 'string', value: 'qore' },
+};
+
+const meta: Meta = {
+ component: FormEngine,
+ title: 'Form/Engine/FormEngine Appearance',
+ args: {
+ name: 'workflow',
+ compact: true,
+ options,
+ groups,
+ value: filledValue,
+ onChange: fn(),
+ },
+ parameters: {
+ layout: 'fullscreen',
+ },
+ render: ({ value, onChange, ...rest }: IFormEngineProps) => {
+ const [val, setValue] = useState(value);
+
+ return (
+
+ );
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+/* --------------------------------------------------------------------------
+ * Measurements the calm look depends on
+ * ------------------------------------------------------------------------ */
+
+const rgbOf = (color: string): number[] =>
+ (color.match(/[\d.]+/g) || []).slice(0, 3).map((channel) => Number(channel));
+
+/** Amber: red well above blue (the warning colour), as opposed to a quiet grey. */
+const isAmber = (color: string): boolean => {
+ const [r, , b] = rgbOf(color);
+ return r - b > 60;
+};
+
+/** A grey: no channel more than a few steps from the others. */
+const isGrey = (color: string): boolean => {
+ const [r, g, b] = rgbOf(color);
+ return Math.max(r, g, b) - Math.min(r, g, b) <= 8;
+};
+
+const box = (key: 'attention' | 'set' | 'optional'): HTMLElement | undefined =>
+ [...document.querySelectorAll('.options-readfirst-group')].find((element) => {
+ const heading = element.querySelector('.reqore-panel-title')?.textContent ?? '';
+ // The first box is "To fill in" until something in it is a fault (a new form).
+ return key === 'attention' ? /^(Needs attention|To fill in)/.test(heading)
+ : heading.startsWith(key === 'set' ? 'Set' : 'Optional');
+ });
+
+const row = (field: string): HTMLElement | null =>
+ document.querySelector(`.readfirst-row[data-field="${field}"]`);
+
+/** The section is a plain panel: a grey surface, a grey edge, and no tint. */
+const expectCalmSection = (element: HTMLElement | undefined) => {
+ expect(element).toBeTruthy();
+ const style = getComputedStyle(element!);
+ expect(isGrey(style.backgroundColor)).toBe(true);
+ expect(style.backgroundImage).toBe('none');
+ expect(isGrey(style.borderTopColor)).toBe(true);
+ expect(Number.parseFloat(style.borderTopWidth)).toBe(1);
+ // The count is a quiet number, not a tag.
+ expect(element!.querySelector('.reqore-panel-title .reqore-tag')).toBeNull();
+ expect(element!.querySelector('.options-readfirst-group-count')).toBeTruthy();
+};
+
+const expectNoUppercase = (selector: string) => {
+ const elements = [...document.querySelectorAll(selector)];
+ expect(elements.length).toBeGreaterThan(0);
+ for (const element of elements) {
+ expect(getComputedStyle(element).textTransform).not.toBe('uppercase');
+ }
+};
+
+/** No status dot on a row that is filled and fine. */
+const expectNoDot = (field: string) => {
+ expect(row(field)).toBeTruthy();
+ expect(row(field)!.querySelector('.options-readfirst-status-dot')).toBeNull();
+};
+
+/* --------------------------------------------------------------------------
+ * Stories
+ * ------------------------------------------------------------------------ */
+
+export const DefaultLook: Story = {
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'The default appearance over a filled workflow form, for comparison with **Calm**: tinted Set box, a green Ready and meter, a green dot on every filled row, upper-case group labels and a red/green Yes/No chip.',
+ },
+ },
+ },
+ play: async () => {
+ await _testsWaitForText('Supplier Payment Release');
+ // The default look is untouched: the tinted box and its count tag.
+ await waitFor(() => expect(box('set')).toBeTruthy());
+ expect(box('set')!.querySelector('.reqore-panel-title .reqore-tag')).toBeTruthy();
+ expect(row('display_name')!.querySelector('.options-readfirst-status-dot')).toBeTruthy();
+ },
+};
+
+export const Calm: Story = {
+ args: {
+ appearance: 'calm',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ '`appearance="calm"` over the same filled form: the sections are plain panels on the theme’s panel surface with label/value rows split by hairlines, labels grey, group headings in sentence case, the count a quiet number, no dot on a healthy row, No as a word, and a short meter whose done part is grey.',
+ },
+ },
+ },
+ play: async () => {
+ await _testsWaitForText('Supplier Payment Release');
+ await waitFor(() => expect(box('set')).toBeTruthy());
+
+ // Sections are calm panels — no green tint, no recessed Optional box.
+ expectCalmSection(box('set'));
+ expectCalmSection(box('optional'));
+ expect(getComputedStyle(box('optional')!).backgroundColor).toBe(
+ getComputedStyle(box('set')!).backgroundColor
+ );
+
+ // Rows are split by one full-width hairline each, and there is no gap
+ // between them.
+ const rows = [...box('set')!.querySelectorAll('.readfirst-row')];
+ expect(rows.length).toBeGreaterThan(4);
+ const second = rows[2];
+ expect(Number.parseFloat(getComputedStyle(second).borderTopWidth)).toBe(1);
+
+ // Sentence case everywhere, grey labels.
+ expectNoUppercase('.options-readfirst-group-label');
+ expect(document.querySelector('.options-readfirst-group-label')?.textContent).toBe('Info');
+ const label = row('display_name')!.querySelector('.options-readfirst-label-text')!;
+ const value = row('display_name')!.querySelector('.options-readfirst-valuetext')!;
+ expect(getComputedStyle(label).color).not.toBe(getComputedStyle(value).color);
+
+ // The section's header label, its group headings and its rows' labels all
+ // start on one left edge.
+ const headerText = box('set')!
+ .querySelector('.reqore-panel-title p')!
+ .getBoundingClientRect().left;
+ expect(Math.abs(headerText - label.getBoundingClientRect().left)).toBeLessThanOrEqual(1);
+ const heading = document.querySelector('.options-readfirst-group-label')!;
+ expect(
+ Math.abs(
+ heading.getBoundingClientRect().left +
+ Number.parseFloat(getComputedStyle(heading).paddingLeft) -
+ label.getBoundingClientRect().left
+ )
+ ).toBeLessThanOrEqual(1);
+
+ // No dot on a filled row, no asterisk on a required one.
+ expectNoDot('display_name');
+ expectNoDot('version');
+ expect(row('display_name')!.querySelectorAll('svg').length).toBe(0);
+
+ // A note that only informs is not blue.
+ const note = row('lang')!.querySelector('.options-readfirst-info-panel .reqore-message')!;
+ expect(note).toBeTruthy();
+ expect(isGrey(getComputedStyle(note).backgroundColor)).toBe(true);
+ expect(isGrey(getComputedStyle(note).borderTopColor)).toBe(true);
+
+ // Yes/No is a word, not a coloured chip.
+ expect(row('remote')!.textContent).toContain('No');
+ expect(row('remote')!.querySelector('.reqore-tag')).toBeNull();
+
+ // The readiness line: the word in the text colour, the meter's done part grey.
+ const status = document.querySelector('.options-readfirst-status')!;
+ expect(status.textContent).toBe('Ready');
+ expect(isGrey(getComputedStyle(status).color)).toBe(true);
+ const bar = document.querySelector('.options-readfirst-completion-bar')!;
+ expect(isGrey(getComputedStyle(bar, '::before').backgroundColor)).toBe(true);
+ expect(bar.getBoundingClientRect().width).toBeLessThanOrEqual(361);
+ expect(bar.getBoundingClientRect().height).toBe(4);
+ },
+};
+
+export const CalmNeedsAttention: Story = {
+ args: {
+ appearance: 'calm',
+ value: newFormValue,
+ // After a submit attempt: every empty required field is now a fault. A form nobody
+ // has been to yet reads "To fill in" instead (CalmNewForm).
+ revealRequired: true,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'A form after a submit attempt (`revealRequired`) in the calm look: the Needs attention section carries one amber dot and a word, each empty required field says **Required · add a value** in amber where its value would be (no red asterisk, no red message), and only the outstanding part of the meter is coloured. Opening a required field shows the same words under the editor as plain amber text.',
+ },
+ },
+ },
+ play: async () => {
+ await waitFor(() => expect(box('attention')).toBeTruthy(), { timeout: 10000 });
+ expectCalmSection(box('attention'));
+ expect(box('attention')!.querySelector('.options-readfirst-group-dot')).toBeTruthy();
+
+ // The missing values are amber words in the value column.
+ await waitFor(() =>
+ expect(
+ [...document.querySelectorAll('.options-readfirst-valuetext')].filter(
+ (element) => element.textContent === CALM_REQUIRED_LABEL
+ ).length
+ ).toBe(3)
+ );
+ await _testsWaitForTextToNotExist('This field is required');
+ const word = row('display_name')!.querySelector('.options-readfirst-valuetext')!;
+ expect(isGrey(getComputedStyle(word).color)).toBe(false);
+ expect(getComputedStyle(word).fontStyle).toBe('normal');
+ // The amber word is the row's one signal: no dot beside it.
+ expectNoDot('display_name');
+
+ // Only the outstanding part of the readiness line is coloured.
+ const status = document.querySelector('.options-readfirst-status')!;
+ expect(status.textContent).toBe('Incomplete');
+ expect(isGrey(getComputedStyle(status).color)).toBe(true);
+ const link = document.querySelector('.options-readfirst-attention-link')!;
+ expect(isGrey(getComputedStyle(link).color)).toBe(false);
+
+ // Inside an open required field the requirement is plain amber text.
+ await _testsClickText('Name');
+ await waitFor(() =>
+ expect(document.querySelector('.options-field-message')?.textContent).toBe(
+ CALM_REQUIRED_LABEL
+ )
+ );
+ expect(document.querySelector('.options-field-message')?.closest('.reqore-tag')).toBeNull();
+ },
+};
+
+export const CalmChoiceHeading: Story = {
+ args: {
+ appearance: 'calm',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'A choice field opened in the calm look: its three choices are headed by the field’s name as written (“Language:”), not an upper-case micro label.',
+ },
+ },
+ },
+ play: async () => {
+ await _testsWaitForText('Supplier Payment Release');
+ await _testsClickText('Qore');
+ await waitFor(() =>
+ expect(document.querySelector('.reqraft-choice-group-heading')).toBeTruthy()
+ );
+ expectNoUppercase('.reqraft-choice-group-heading');
+ },
+};
+
+export const CalmReadOnly: Story = {
+ args: {
+ appearance: 'calm',
+ readOnly: true,
+ value: { ...filledValue, version: { type: 'string', value: '' } },
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'The calm look in read mode: one list of label/value rows split by hairlines, flush with the host’s edge and with no card of its own (a read view usually sits in one already); an unset required value reads **Required · not set**, because a reader cannot add it.',
+ },
+ },
+ },
+ play: async () => {
+ await _testsWaitForText('Supplier Payment Release');
+ expect(document.querySelector('.options-readfirst-group')).toBeNull();
+ const first = document.querySelector('.readfirst-read-list > *')!;
+ expect(Number.parseFloat(getComputedStyle(first).borderTopWidth)).toBe(0);
+ expect(Number.parseFloat(getComputedStyle(row('display_name')!).borderTopWidth)).toBe(1);
+ expect(document.querySelectorAll('.options-readfirst-status-dot').length).toBe(0);
+ expect(row('version')!.textContent).toContain(CALM_REQUIRED_READ_LABEL);
+ expect(row('remote')!.querySelector('.reqore-tag')).toBeNull();
+ },
+};
+
+export const CalmNewForm: Story = {
+ args: {
+ appearance: 'calm',
+ value: newFormValue,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'A brand-new form in the calm look. A new form is not already wrong: its required fields are still to fill in, so the first section reads **To fill in** with no amber dot, each empty required field says **Required · add a value** in the quiet colour (under its editor too, while it is open for typing), and the readiness line says "3 to fill in". Opening a required field and leaving it empty makes it a fault: the section becomes **Needs attention**, with its amber dot, that field\'s words turn amber, and the readiness line says "1 need attention · 2 to fill in".',
+ },
+ },
+ },
+ play: async () => {
+ await waitFor(() => expect(box('attention')).toBeTruthy(), { timeout: 10000 });
+ const heading = () => box('attention')!.querySelector('.reqore-panel-title')!.textContent ?? '';
+ expect(heading()).toContain('To fill in');
+ expect(box('attention')!.querySelector('.options-readfirst-group-dot')).toBeNull();
+ const word = () =>
+ row('display_name')!.querySelector('.options-readfirst-valuetext')!;
+ await waitFor(() => expect(word().textContent).toBe(CALM_REQUIRED_LABEL));
+ expect(isAmber(getComputedStyle(word()).color)).toBe(false);
+ const link = document.querySelector('.options-readfirst-attention-link')!;
+ expect(link.textContent).toContain('to fill in');
+ expect(isAmber(getComputedStyle(link).color)).toBe(false);
+
+ // Open Name: an open field is not yet a left one, so the line under its editor
+ // says what it needs in the quiet colour (a host that opens the first empty field
+ // on a new form must not show it as already wrong).
+ await _testsClickText('Name');
+ const openMessage = await waitFor(() => {
+ // An open row is no longer a read-first row, so it is found by its field alone.
+ const found = Array.from(
+ document.querySelectorAll('[data-field="display_name"] .options-field-message')
+ ).find((el) => el.textContent === CALM_REQUIRED_LABEL);
+ expect(found).toBeTruthy();
+ return found!;
+ });
+ expect(isAmber(getComputedStyle(openMessage).color)).toBe(false);
+ expect(heading()).toContain('To fill in');
+
+ // Then open another field: Name has been visited and left empty.
+ await _testsClickText('Version');
+ await waitFor(() => expect(heading()).toContain('Needs attention'));
+ // Only the visited field is a fault; the other two are still to fill in.
+ const line = document.querySelector('.options-readfirst-completion')!.textContent;
+ expect(line).toContain('1 need attention');
+ expect(line).toContain('2 to fill in');
+ expect(box('attention')!.querySelector('.options-readfirst-group-dot')).toBeTruthy();
+ await waitFor(() => expect(isAmber(getComputedStyle(word()).color)).toBe(true));
+ },
+};
+
+export const CalmOnAPhone: Story = {
+ args: {
+ appearance: 'calm',
+ value: newFormValue,
+ },
+ parameters: {
+ qlip: { viewport: { width: 390, height: 844 } },
+ docs: {
+ description: {
+ story:
+ 'The calm look on a 390px phone: the readiness meter wraps under its words, each row stacks its value under its name inside the section, and nothing scrolls sideways.',
+ },
+ },
+ },
+ play: async ({ canvasElement }) => {
+ await _testsWithViewport(390, 844, async (narrowed) => {
+ await waitFor(() => expect(box('attention')).toBeTruthy(), { timeout: 10000 });
+ if (!narrowed) return;
+ await waitFor(() =>
+ expect(document.querySelector('.readfirst-narrow')).toBeTruthy()
+ );
+ expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(
+ document.documentElement.clientWidth
+ );
+ const section = box('attention')!.getBoundingClientRect();
+ for (const element of canvasElement.querySelectorAll(
+ '.options-readfirst-group .readfirst-row'
+ )) {
+ const rect = element.getBoundingClientRect();
+ expect(rect.left).toBeGreaterThanOrEqual(section.left - 1);
+ expect(rect.right).toBeLessThanOrEqual(section.right + 1);
+ }
+ });
+ },
+};
diff --git a/src/components/form/engine/FormEngineStickyHeaders.stories.tsx b/src/components/form/engine/FormEngineStickyHeaders.stories.tsx
index 02bdc2bb..b7f8b2e6 100644
--- a/src/components/form/engine/FormEngineStickyHeaders.stories.tsx
+++ b/src/components/form/engine/FormEngineStickyHeaders.stories.tsx
@@ -122,8 +122,12 @@ const scroller = () => document.getElementById(SCROLLER) as HTMLElement;
/** One box's header element, found by the label it carries. */
const boxHeader = (label: string): HTMLElement => {
+ // The first box is "To fill in" until something in it is a fault (a new form).
+ const labels = label === 'Needs attention' ? ['Needs attention', 'To fill in'] : [label];
const box = Array.from(document.querySelectorAll('.options-readfirst-group')).find((el) =>
- (el.querySelector(':scope > .reqore-panel-title')?.textContent ?? '').includes(label)
+ labels.some((name) =>
+ (el.querySelector(':scope > .reqore-panel-title')?.textContent ?? '').includes(name)
+ )
);
if (!box) {
throw new Error(`no status box labelled ${label}`);
diff --git a/src/components/form/engine/OptionFieldMessages.tsx b/src/components/form/engine/OptionFieldMessages.tsx
index 3de9a2b2..a0b6e6a7 100644
--- a/src/components/form/engine/OptionFieldMessages.tsx
+++ b/src/components/form/engine/OptionFieldMessages.tsx
@@ -1,6 +1,13 @@
-import { ReqoreControlGroup, ReqoreTag, ReqoreVerticalSpacer } from '@qoretechnologies/reqore';
+import {
+ ReqoreControlGroup,
+ ReqoreP,
+ ReqoreTag,
+ ReqoreVerticalSpacer,
+ useReqoreTheme,
+} from '@qoretechnologies/reqore';
import { IReqoreTagProps } from '@qoretechnologies/reqore/dist/components/Tag';
import { TReqoreIntent } from '@qoretechnologies/reqore/dist/constants/theme';
+import { getReadableColor } from '@qoretechnologies/reqore/dist/helpers/colors';
import { IQorusFormField, IQorusFormSchema, TQorusForm } from '@qoretechnologies/ts-toolkit';
import { isArray, size } from 'lodash';
import { useMemo } from 'react';
@@ -19,6 +26,7 @@ import {
unsatisfiedRequiredGroups,
} from '../../../helpers/validations';
import { OptionsContext } from './optionsContext';
+import { CALM_REQUIRED_LABEL, CALM_REQUIRED_READ_LABEL, useIsCalmForm } from './formAppearance';
/** A `messages` entry that appears only for certain values of its siblings. */
export interface IConditionalFieldMessage {
@@ -335,6 +343,22 @@ export interface IOptionFieldMessagesProps {
* reqraft's built-in editors are known.
*/
isRendererOnly?: (type?: any) => boolean;
+ /**
+ * The calm appearance says an empty required field the calm way: an amber
+ * "Required · add a value" (or, read-only, "Required · not set") rather than
+ * a red "This field is required". Waiting for a person is amber; red is for
+ * something that failed.
+ */
+ calm?: boolean;
+ /** Read-only: the reader cannot add a value, so the calm wording only states it. */
+ readOnly?: boolean;
+ /**
+ * Nobody has been to this field yet. The requirement is still stated, but with no
+ * intent: a new form is not already wrong, and saying what is required is not saying
+ * something failed. It takes its colour once the person has opened the field and left
+ * it empty (`FormEngine`'s `visitedOptions` / `revealRequired`).
+ */
+ requirementPending?: boolean;
}
/**
@@ -349,6 +373,9 @@ export const getOptionFieldMessages = ({
allOptions,
getType,
isRendererOnly,
+ calm,
+ readOnly,
+ requirementPending,
}: IOptionFieldMessagesProps): IReqoreTagProps[] => {
const optionSchema = schema[name];
const result: IReqoreTagProps[] = [];
@@ -394,7 +421,13 @@ export const getOptionFieldMessages = ({
// locked field is not asked for: the dependency message below says what
// unlocks it, and until then there is nothing to fill in.
if (optionSchema?.required && !locked) {
- result.push({ label: 'This field is required', intent: 'danger' });
+ const message =
+ calm ?
+ { label: readOnly ? CALM_REQUIRED_READ_LABEL : CALM_REQUIRED_LABEL, intent: 'warning' }
+ : { label: 'This field is required', intent: 'danger' };
+ result.push(
+ requirementPending ? { label: message.label } : (message as IReqoreTagProps)
+ );
}
if (
@@ -440,7 +473,10 @@ export const OptionFieldMessages = ({
allOptions,
getType,
isRendererOnly,
+ requirementPending,
}: IOptionFieldMessagesProps) => {
+ const calm = useIsCalmForm();
+ const theme = useReqoreTheme();
const messages: IReqoreTagProps[] = useMemo(
() =>
getOptionFieldMessages({
@@ -450,6 +486,8 @@ export const OptionFieldMessages = ({
allOptions,
getType,
isRendererOnly,
+ calm,
+ requirementPending,
}),
[
JSON.stringify(schema),
@@ -457,6 +495,8 @@ export const OptionFieldMessages = ({
JSON.stringify(allOptions),
name,
isRendererOnly,
+ calm,
+ requirementPending,
]
);
@@ -464,6 +504,37 @@ export const OptionFieldMessages = ({
return null;
}
+ // Calm: each message is a line of text in its own colour under the editor —
+ // a word, not a tinted chip with a warning glyph.
+ if (calm) {
+ return (
+
+ {messages.map((message, index) => (
+
+ {message.label}
+
+ ))}
+
+ );
+ }
+
return (
<>
diff --git a/src/components/form/engine/_structuredData/SchemaDataView.tsx b/src/components/form/engine/_structuredData/SchemaDataView.tsx
index d0ea1150..c14ed2fc 100644
--- a/src/components/form/engine/_structuredData/SchemaDataView.tsx
+++ b/src/components/form/engine/_structuredData/SchemaDataView.tsx
@@ -17,6 +17,7 @@ import React from 'react';
import styled from 'styled-components';
import { useContextSelector } from 'use-context-selector';
import { CompactRowContext } from '../compactRowContext';
+import { useIsCalmForm } from '../formAppearance';
import {
MONO_FONT_STACK,
StyledCodePreview,
@@ -216,13 +217,25 @@ const StyledItemFields = styled.div<{ $border: string; $rule: boolean }>`
* case-sensitive — a service method is `onOrderStatus`, not `ONORDERSTATUS` —
* and the item heading is a value.
*/
-const StyledFieldLabel = styled(StyledRowLabel)`
+const StyledFieldLabelBase = styled(StyledRowLabel)<{ $calm?: boolean }>`
font-size: 12px;
- text-transform: uppercase;
- letter-spacing: 0.06em;
- opacity: 0.55;
+ /* Calm: the name as written. The calm look forces nothing into capitals, and
+ its detail pages no longer do either, so the vocabulary this label borrows
+ is sentence case there too. */
+ text-transform: ${({ $calm }) => ($calm ? 'none' : 'uppercase')};
+ letter-spacing: ${({ $calm }) => ($calm ? 'normal' : '0.06em')};
+ opacity: ${({ $calm }) => ($calm ? 0.7 : 0.55)};
`;
+/** The record field label in the form's appearance — see `StyledFieldLabelBase`. */
+const StyledFieldLabel = (
+ props: Omit, '$calm'>
+) => {
+ const calm = useIsCalmForm();
+
+ return ;
+};
+
const StyledFields = styled.div`
/* The supporting fields step in under the heading, so the heading keeps the
left edge to itself. This is the half that answers "the name is written in
diff --git a/src/components/form/engine/compactRowContext.ts b/src/components/form/engine/compactRowContext.ts
index 110ad90a..bcdd4885 100644
--- a/src/components/form/engine/compactRowContext.ts
+++ b/src/components/form/engine/compactRowContext.ts
@@ -69,6 +69,11 @@ export interface ICompactRowContext {
// display name in the read-first chip.
templates?: IReqoreFormTemplates;
expandedOptions: string[];
+ /** Fields the person has opened and left: an empty required one among them is a
+ * fault (`todo`); one they have not been to yet is only `pending`. */
+ visitedOptions: string[];
+ /** The host reported a submit attempt: every empty required field is a fault. */
+ revealRequired: boolean;
highlightedOptions: string[];
flashedOptions: string[];
infoPanelOverrides: Record;
diff --git a/src/components/form/engine/compactRowStyles.ts b/src/components/form/engine/compactRowStyles.ts
index 1a4805de..82b1614a 100644
--- a/src/components/form/engine/compactRowStyles.ts
+++ b/src/components/form/engine/compactRowStyles.ts
@@ -1,5 +1,5 @@
import { ReqoreP, ReqorePanel } from '@qoretechnologies/reqore';
-import { GAP_FROM_SIZE, TSizes } from '@qoretechnologies/reqore/dist/constants/sizes';
+import { GAP_FROM_SIZE, PADDING_FROM_SIZE, TSizes } from '@qoretechnologies/reqore/dist/constants/sizes';
import styled, { css } from 'styled-components';
// Styled primitives for the compact (read-first) rows and their editor cards.
@@ -22,6 +22,18 @@ export const COMPACT_PANEL_LEFT = COMPACT_VALUE_LEFT - 10;
// own horizontal padding so the labels line up flush with the field labels below
// them (no extra gutter — the spine/rail that used a fluid indent are gone).
export const GROUP_INDENT = `${COMPACT_ROW_PAD_X}px`;
+/**
+ * Calm appearance: the inset of everything inside a section — rows, group
+ * headings, the one-of sentence, and the section's own header label, so all of
+ * them start on one left edge.
+ */
+export const CALM_SECTION_INSET = 16;
+/**
+ * The extra inset a calm section header's label needs to land on that edge:
+ * Reqore pads a `normal` panel's title bar by `PADDING_FROM_SIZE.normal`, and
+ * the label makes up the rest itself.
+ */
+export const CALM_SECTION_HEADER_INSET = CALM_SECTION_INSET - PADDING_FROM_SIZE.normal;
// Measured label column (GLOBAL). The label column sizes to the WIDEST field
// label across the whole form, clamped to [MIN, MAX]. FormEngine measures the
@@ -202,10 +214,15 @@ export const StyledCompactPanel = styled(ReqorePanel)<{
// Compact group header laid out as the panel's `label`: the group name, a
// hairline that fades toward the status chip, and the chip pinned at the end.
-export const StyledGroupHeader = styled.div`
+export const StyledGroupHeader = styled.div<{ $calm?: boolean }>`
display: flex;
align-items: center;
- gap: 12px;
+ /* Calm: the label, its quiet count and (on Needs attention) the one amber
+ dot read as a single phrase, so they sit closer than the default's
+ label-and-chip pair. */
+ gap: ${({ $calm }) => ($calm ? 8 : 12)}px;
+ /* …and starts on the rows' left edge. */
+ padding-left: ${({ $calm }) => ($calm ? CALM_SECTION_HEADER_INSET : 0)}px;
flex: 1;
min-width: 0;
`;
@@ -225,14 +242,30 @@ export const StyledStatusBox = styled(ReqorePanel)<{
$bg?: string;
/** The page's own background, for the PINNED header — see below. */
$surface?: string;
+ /**
+ * Calm appearance: the box is an ordinary panel — Reqore paints the theme's
+ * panel surface and its one neutral hairline — so no tint, no accent edge
+ * and no recessed Optional box are drawn over it. Only the radius is kept,
+ * so a calm form has the same shape as a default one.
+ */
+ $calm?: boolean;
}>`
- &&& {
- border: 1px solid ${({ $accent }) => `${$accent}33`};
- /* $bg lets the muted "Optional" box opt into a darker, recessed surface
- instead of the faint accent tint the coloured boxes use. */
- background: ${({ $accent, $bg }) => $bg || `${$accent}1f`};
- border-radius: 10px;
- }
+ ${({ $calm, $accent, $bg }) =>
+ $calm ?
+ css`
+ &&& {
+ border-radius: 10px;
+ }
+ `
+ : css`
+ &&& {
+ border: 1px solid ${`${$accent}33`};
+ /* $bg lets the muted "Optional" box opt into a darker, recessed surface
+ instead of the faint accent tint the coloured boxes use. */
+ background: ${$bg || `${$accent}1f`};
+ border-radius: 10px;
+ }
+ `}
/* A PINNED header needs a surface of its own.
@@ -248,13 +281,16 @@ export const StyledStatusBox = styled(ReqorePanel)<{
Optional box already carries an opaque \`$bg\`, so that is used as-is.
Only when \`$surface\` is supplied — i.e. only on a form that pins at all —
- so an unpinned box is pixel-identical to before. */
- ${({ $surface, $accent, $bg }) =>
+ so an unpinned box is pixel-identical to before.
+
+ Calm boxes carry no tint, so the pinned header is just the panel surface
+ the caller passes as \`$surface\`. */
+ ${({ $surface, $accent, $bg, $calm }) =>
$surface ?
css`
&&& > .reqore-panel-title {
background-color: ${$bg || $surface};
- ${$bg ? '' : `background-image: linear-gradient(${$accent}1f, ${$accent}1f);`}
+ ${$bg || $calm ? '' : `background-image: linear-gradient(${$accent}1f, ${$accent}1f);`}
}
`
: ''}
@@ -264,42 +300,76 @@ export const StyledStatusBox = styled(ReqorePanel)<{
// one-of required group (in the Needs-attention box) so the constraint reads as
// one unit. The connection rail + status nodes still render inside (they convey
// which member satisfies the group); this box adds the explicit heading.
-export const StyledRequiredClusterBox = styled.div<{ $border: string; $tint: string }>`
- border: 1px solid ${({ $border }) => $border};
- border-radius: 8px;
- background: ${({ $tint }) => $tint};
- padding: 2px 6px 6px;
- margin: 4px 0;
+export const StyledRequiredClusterBox = styled.div<{
+ $border: string;
+ $tint: string;
+ /** Calm: no box of its own — the heading and the hairlines carry the group. */
+ $calm?: boolean;
+}>`
+ ${({ $calm, $border, $tint }) =>
+ $calm ?
+ ''
+ : css`
+ border: 1px solid ${$border};
+ border-radius: 8px;
+ background: ${$tint};
+ padding: 2px 6px 6px;
+ margin: 4px 0;
+ `}
/* The members render directly here (not via the gapped group body), so give
them the same modest gap the rest of the rows have. Pin the divider-centring
var to that gap (it doesn't widen in narrow like the group body's does). */
display: flex;
flex-flow: column;
- --readfirst-row-gap: 4px;
- gap: 4px;
+ --readfirst-row-gap: ${({ $calm }) => ($calm ? 0 : 4)}px;
+ gap: ${({ $calm }) => ($calm ? 0 : 4)}px;
`;
-export const StyledRequiredClusterHeader = styled.div<{ $color: string }>`
+export const StyledRequiredClusterHeader = styled.div<{ $color: string; $calm?: boolean }>`
display: flex;
align-items: center;
gap: 6px;
- padding: 8px 0 4px ${GROUP_INDENT};
- font-size: 10px;
- font-weight: 600;
- letter-spacing: 1px;
- text-transform: uppercase;
color: ${({ $color }) => $color};
+ font-weight: 600;
+ /* Calm: an amber sentence over the rows it is about, at the rows' own text
+ size and inset — the instruction, not an upper-case eyebrow. */
+ ${({ $calm }) =>
+ $calm ?
+ css`
+ padding: 12px ${CALM_SECTION_INSET}px 2px;
+ font-size: 13px;
+ `
+ : css`
+ padding: 8px 0 4px ${GROUP_INDENT};
+ font-size: 10px;
+ letter-spacing: 1px;
+ text-transform: uppercase;
+ `}
`;
// Thin schema-group sub-label inside a status box (CONNECTION / AUTHENTICATION /
// …). Quiet by design — the box header is the loud heading; this just keeps each
// field's group context as you scan. Indented to the rows' content line.
-export const StyledStatusBoxGroupLabel = styled.div`
- font-size: 10px;
- font-weight: 500;
- letter-spacing: 1px;
- text-transform: uppercase;
- opacity: 0.5;
- padding: 8px 0 2px ${GROUP_INDENT};
+//
+// Calm: the group's name as written (no upper-casing), at the rows' text size in
+// the primary text colour — the field labels below it are grey, so the heading
+// is told apart by colour and the space above it, not by shouting.
+export const StyledStatusBoxGroupLabel = styled.div<{ $calm?: boolean; $color?: string }>`
+ ${({ $calm, $color }) =>
+ $calm ?
+ css`
+ font-size: 13px;
+ font-weight: 600;
+ color: ${$color || 'inherit'};
+ padding: 14px ${CALM_SECTION_INSET}px 2px;
+ `
+ : css`
+ font-size: 10px;
+ font-weight: 500;
+ letter-spacing: 1px;
+ text-transform: uppercase;
+ opacity: 0.5;
+ padding: 8px 0 2px ${GROUP_INDENT};
+ `}
`;
// Required-group "connection" rail: contiguous members are linked by a continuous
@@ -334,14 +404,24 @@ export const StyledClusterNode = styled.span<{ $color: string; $filled: boolean;
}
`;
-export const StyledEditCard = styled.div<{ $bg: string; $border: string }>`
- padding: 12px;
+export const StyledEditCard = styled.div<{ $bg: string; $border: string; $calm?: boolean }>`
display: flex;
flex-flow: column;
gap: 8px;
background: ${({ $bg }) => $bg};
- border: 1px solid ${({ $border }) => $border};
- border-radius: 8px;
+ /* Calm: an open field is a row of its section, set apart by the section's own
+ hairlines and the active-row tint — not a second card drawn inside the
+ first. */
+ ${({ $calm, $border }) =>
+ $calm ?
+ css`
+ padding: 12px ${CALM_SECTION_INSET}px;
+ `
+ : css`
+ padding: 12px;
+ border: 1px solid ${$border};
+ border-radius: 8px;
+ `}
/* Single-value editors (operators, long-string, markdown, richtext, byte-size)
carry the card's own "Clear value" action, so suppress the value input's
@@ -566,6 +646,10 @@ export const StyledGroupBody = styled.div<{
HAS to be seen — the divider tint that separates two fields is ~8% of the
text colour, which disappears entirely as a 1px dashed border. */
$moreBorder: string;
+ /** Calm appearance: label/value rows split by full-width hairlines. */
+ $calm?: boolean;
+ /** The calm section's hairline — the same edge its panel is drawn with. */
+ $hairline?: string;
}>`
display: flex;
flex-flow: column;
@@ -871,6 +955,102 @@ export const StyledGroupBody = styled.div<{
padding-top: 0;
padding-bottom: 0;
}
+
+ /* CALM appearance — a section of label/value rows split by hairlines.
+
+ Every entry the body holds (a row, the column an open row sits in, a
+ group heading, the one-of cluster, the "N more fields" control) is drawn
+ edge to edge with ONE full-width hairline above it, the same colour as the
+ section's own edge. The first entry's line is the one under the section
+ header (a minimal header draws none of its own). Two things take no line,
+ because a heading and the rows it heads read as one block: the entry right
+ under a group heading (or its subtitle), and the first row under the
+ cluster's sentence.
+
+ Rows lose their rounded hover pill and the inset hairline between them, and
+ take the section's 16px inset. Placed last so it wins over the narrow
+ rules above, which a phone-width calm form also matches. */
+ ${({ $calm, $hairline }) =>
+ $calm ?
+ css`
+ && {
+ --readfirst-row-gap: 0px;
+ gap: 0;
+ }
+ && > *,
+ && > .options-readfirst-required-cluster > * {
+ border-top: 1px solid ${$hairline};
+ }
+ && > .options-readfirst-group-label + *,
+ && > .options-readfirst-group-subtitle + *,
+ && .options-readfirst-required-cluster-header,
+ && .options-readfirst-required-cluster-header + * {
+ border-top: 0;
+ }
+ && .readfirst-row {
+ min-height: 44px;
+ padding: 10px ${CALM_SECTION_INSET}px;
+ border-radius: 0;
+ }
+ && .readfirst-row::after {
+ display: none;
+ }
+ /* The open row keeps the read row's band: 3px above the editor's 32px
+ control centres it where the read row centred its text. */
+ && .readfirst-row-editing {
+ padding-top: 3px;
+ padding-bottom: 10px;
+ }
+ /* The section's rounded bottom corners, which a full-bleed hover or an
+ open row's tint would otherwise square off. */
+ && > :last-child,
+ && > :last-child > :last-child {
+ border-bottom-left-radius: 9px;
+ border-bottom-right-radius: 9px;
+ }
+ /* "N more fields" is a quiet last row: the hairline above it says where
+ the section's fields end; a dashed box around it said it again. */
+ && .readfirst-more-row {
+ border: 0;
+ border-top: 1px solid ${$hairline};
+ margin-top: 0;
+ }
+ &&.readfirst-narrow {
+ --readfirst-row-gap: 0px;
+ }
+ &&.readfirst-narrow .readfirst-row {
+ padding: 10px ${CALM_SECTION_INSET}px;
+ min-height: 0;
+ }
+ &&.readfirst-narrow .readfirst-row-editing {
+ padding-top: 3px;
+ padding-bottom: 10px;
+ }
+ &&.readfirst-narrow .readfirst-row:not(.readfirst-row-editing) > :nth-child(3) {
+ top: 10px;
+ right: ${CALM_SECTION_INSET}px;
+ }
+ /* READ mode is one list with no section around it — usually inside a
+ card the host already drew — so it adds no panel and no inset of its
+ own: the rows sit flush with the host's content edge, split by the
+ same hairlines, with no line above the first. */
+ &&.readfirst-read-list > :first-child {
+ border-top: 0;
+ }
+ &&.readfirst-read-list .readfirst-row,
+ &&.readfirst-read-list .options-readfirst-group-label,
+ &&.readfirst-read-list .options-readfirst-group-subtitle {
+ padding-left: 0;
+ padding-right: 0;
+ margin-left: 0;
+ }
+ &&.readfirst-read-list.readfirst-narrow
+ .readfirst-row:not(.readfirst-row-editing)
+ > :nth-child(3) {
+ right: 0;
+ }
+ `
+ : ''}
`;
// One absorbed sibling, rendered inside its host row's container rather than
diff --git a/src/components/form/engine/compactToolbarContext.ts b/src/components/form/engine/compactToolbarContext.ts
index c0ba8048..56fc7cc2 100644
--- a/src/components/form/engine/compactToolbarContext.ts
+++ b/src/components/form/engine/compactToolbarContext.ts
@@ -35,6 +35,9 @@ export interface ICompactToolbarContext {
// Completion meter
invalidCount: number;
attentionCount: number;
+ /** How many of `attentionCount` are faults (invalid, or a required field the person
+ * has been to and left empty). Zero on a fresh form: the rest are only to fill in. */
+ attentionFaultCount: number;
completion: { set: number; done: number; total: number; pct: number };
// Invalid-fields banner (pinned in the sticky header)
showInvalidOnly: boolean;
diff --git a/src/components/form/engine/formAppearance.ts b/src/components/form/engine/formAppearance.ts
new file mode 100644
index 00000000..f14240bf
--- /dev/null
+++ b/src/components/form/engine/formAppearance.ts
@@ -0,0 +1,78 @@
+import { IReqoreTheme } from '@qoretechnologies/reqore/dist/constants/theme';
+import {
+ changeDarkness,
+ changeLightness,
+ getMainBackgroundColor,
+} from '@qoretechnologies/reqore/dist/helpers/colors';
+import { createContext, useContext } from 'react';
+
+/**
+ * How a form looks. The look only: which fields exist, how they bucket, what a
+ * click does and what is validated are identical in every appearance.
+ *
+ * - `'default'` — the established read-first look: tinted status boxes, an
+ * intent-coloured completion meter, a status dot on every row.
+ * - `'calm'` — colour only where something needs a person. The status boxes are
+ * plain panels on the theme's panel surface with label/value rows split by
+ * hairlines; labels are grey; a missing required value is an amber word
+ * instead of a red asterisk and message; counts are quiet numbers; a healthy
+ * row carries no dot; Yes/No are plain words; nothing is forced into
+ * uppercase. Opt-in, so an app that has not asked for it does not change.
+ */
+export type TFormEngineAppearance = 'default' | 'calm';
+
+/**
+ * The appearance in force for everything below it: the engine's own rows and
+ * toolbar, every field editor it mounts, and every nested (`arg_schema`)
+ * sub-form, which inherits it unless it is given one of its own.
+ *
+ * `FormEngine` provides it from its `appearance` prop. A host may also wrap
+ * its whole tree in it, to set the appearance once for every form — and for
+ * the field components it renders outside a form.
+ */
+export const FormAppearanceContext = createContext(undefined);
+
+export const FormAppearanceProvider = FormAppearanceContext.Provider;
+
+/** The appearance in force here; `'default'` when nothing set one. */
+export const useFormAppearance = (): TFormEngineAppearance =>
+ useContext(FormAppearanceContext) ?? 'default';
+
+/** Shorthand for the one question most call sites ask. */
+export const useIsCalmForm = (): boolean => useFormAppearance() === 'calm';
+
+/** The theme's own panel surface, when it declares one (Reqore >= 0.78.6). */
+const getDeclaredPanelSurface = (
+ theme: IReqoreTheme
+): { main?: string; border?: string } | undefined => {
+ const themed = theme as IReqoreTheme & {
+ panel?: { main?: string; border?: string };
+ originalMain?: string;
+ };
+ // Same rule as Reqore's `getThemePanelSurface`: a component whose `main` was
+ // moved off the original (a custom theme, an intent) keeps deriving its own.
+ return themed.panel && (!themed.originalMain || themed.main === themed.originalMain) ?
+ themed.panel
+ : undefined;
+};
+
+/**
+ * The surface a calm section paints, matching the one Reqore gives a panel:
+ * `theme.panel.main` when the theme declares one, else the panel shade Reqore
+ * derives from `main`. Read here rather than through Reqore's own helper so the
+ * whole peer range keeps working — the helper arrived after the floor.
+ */
+export const getCalmPanelSurface = (theme: IReqoreTheme): string =>
+ getDeclaredPanelSurface(theme)?.main ?? changeDarkness(getMainBackgroundColor(theme), 0.03);
+
+/** The neutral 1px edge of a calm section, and of every row inside it. */
+export const getCalmPanelHairline = (theme: IReqoreTheme): string =>
+ getDeclaredPanelSurface(theme)?.border ?? changeLightness(getMainBackgroundColor(theme), 0.08);
+
+/**
+ * What a calm form writes in place of an empty required value. Editable: what
+ * to do about it. Read-only: only what is the case, because the reader cannot
+ * add anything.
+ */
+export const CALM_REQUIRED_LABEL = 'Required · add a value';
+export const CALM_REQUIRED_READ_LABEL = 'Required · not set';
diff --git a/src/components/form/engine/readFirst.ts b/src/components/form/engine/readFirst.ts
index 988f7372..8d89969c 100644
--- a/src/components/form/engine/readFirst.ts
+++ b/src/components/form/engine/readFirst.ts
@@ -854,9 +854,13 @@ export interface IReadFirstCompletion {
* status BOX bucketing (FormEngine) so the two can never disagree:
* invalid — a value fails validation, or a danger message is present (red)
* todo — empty & required (or has a warning message), needs a value (amber)
+ * pending — empty & required, but the person has not been to it yet: still to
+ * fill in, not yet a fault (neutral). A new form is not already wrong;
+ * the field becomes `todo` once they open it and leave it empty, or the
+ * host reports a submit attempt (`revealRequired`)
* set — has a valid value (green)
* optional — empty & not required, or covered by a one-of sibling (calm) */
-export type TReadFirstStatus = 'invalid' | 'todo' | 'set' | 'optional';
+export type TReadFirstStatus = 'invalid' | 'todo' | 'pending' | 'set' | 'optional';
export const getReadFirstStatus = (s: {
empty: boolean;
@@ -867,17 +871,26 @@ export const getReadFirstStatus = (s: {
invalid: boolean;
/** A warning message is attached (surfaces an empty field for attention). */
warned: boolean;
+ /**
+ * Whether the person has been to this field (opened it and left it), or the host
+ * reported a submit attempt. Until then an empty required field is `pending`, not
+ * `todo`. Unset means visited, the behaviour before this existed.
+ */
+ visited?: boolean;
}): TReadFirstStatus => {
if (s.invalid) return 'invalid';
if (!s.empty) return 'set';
// A one-of member covered by a satisfied sibling reads as "set" (green) — the
// requirement is met; the row just shows a "Covered by …" note.
if (s.covered) return 'set';
- if (s.required || s.warned) return 'todo';
+ if (s.warned) return 'todo';
+ if (s.required) return s.visited === false ? 'pending' : 'todo';
return 'optional';
};
-/** Coarse bucket for the three status boxes (Needs attention / Set / Optional). */
+/** Coarse bucket for the three status boxes (Needs attention / Set / Optional). A
+ * `pending` field is in the first box too: it still has to be filled in. The box only
+ * reads "To fill in" instead of "Needs attention" while nothing in it is a fault. */
export const getReadFirstBucket = (status: TReadFirstStatus): 'attention' | 'set' | 'optional' =>
status === 'set' ? 'set'
: status === 'optional' ? 'optional'
diff --git a/src/components/form/engine/rowFocus.ts b/src/components/form/engine/rowFocus.ts
index 8ef6dc70..5862ab54 100644
--- a/src/components/form/engine/rowFocus.ts
+++ b/src/components/form/engine/rowFocus.ts
@@ -15,9 +15,11 @@
* focusable element" would answer a text field with a button beside it.
*/
-/** Controls the reader types into. */
+/** Controls the reader types into. `role="textbox"` is how an editor that is neither
+ * an input nor contenteditable says so: Monaco's edit area is a focusable div with that
+ * role (its own `