From 0037c49adaad5dc2480e91b789870091d990b46d Mon Sep 17 00:00:00 2001 From: foxhoundn Date: Mon, 19 Oct 2026 11:59:11 +0200 Subject: [PATCH 01/23] =?UTF-8?q?feat(form-engine):=20an=20opt-in=20calm?= =?UTF-8?q?=20appearance=20for=20the=20compact=20form=20=E2=80=94=200.10.6?= =?UTF-8?q?4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Qorus IDE is moving to a calmer design — colour only where something needs a person, panels on one surface edged by one hairline, quiet headings and counts — and every creator, editor and many dialogs there render through FormEngine's compact mode. It was the biggest surface still in the old look: a green Ready and meter, green/amber tinted boxes with coloured count tags, a recessed black Optional box, a dot on every row, upper-case group labels and "SELECT ONE:", red asterisks and a red "This field is required", Yes/No as green/red chips, and the outer panel drawn as a card around its own boxes once a theme gives panels a surface. Other apps use Reqraft, so the new look is opt-in: `appearance="calm"` on FormEngine, published through FormAppearanceContext so nested sub-forms and the field editors a form mounts follow it, and a FormAppearanceProvider for a host that wants to set it once. Every look choice is decided once inside the engine; nothing a row does reads it, so bucketing, counts, validation, templates, search, the optional fields and read mode are the same in both. In the calm look the status boxes are plain panels on the theme's panel surface with label/value rows split by full-width hairlines; labels are grey; group headings, choice headings, radio options and preview labels keep their case; an empty required value reads "Required · add a value" in amber where the value would be (read-only: "Required · not set") instead of an asterisk and a red message; counts are quiet numbers; only Needs attention carries a dot, and a row keeps one only when its value fails; Yes/No are words; Done and Cancel are neutral; the readiness line is one quiet line with a short meter whose done part is grey and only the outstanding part amber; messages under an open field are lines of text; read mode is one hairline-split list flush with its host. The default look is unchanged (the existing FormEngine stories pass as they were). Stories: Form/Engine/FormEngine Appearance, with plays measuring the surfaces, hairlines, case, dots, colours, meter and phone width. Closes qoretechnologies/toolkit-react#144 --- package.json | 2 +- src/components/form/engine/CompactRow.tsx | 95 +++- src/components/form/engine/CompactToolbar.tsx | 56 ++- src/components/form/engine/FormEngine.tsx | 178 +++++-- .../engine/FormEngineAppearance.stories.tsx | 469 ++++++++++++++++++ .../form/engine/OptionFieldMessages.tsx | 52 +- .../engine/_structuredData/SchemaDataView.tsx | 21 +- .../form/engine/compactRowStyles.ts | 256 ++++++++-- src/components/form/engine/formAppearance.ts | 78 +++ .../form/fields/ChoiceGroupHeading.tsx | 38 ++ src/components/form/fields/Field.tsx | 11 +- .../fields/allowed-values/AllowedValues.tsx | 11 +- .../form/fields/radio-group/RadioGroup.tsx | 20 +- src/components/form/index.tsx | 1 + 14 files changed, 1165 insertions(+), 123 deletions(-) create mode 100644 src/components/form/engine/FormEngineAppearance.stories.tsx create mode 100644 src/components/form/engine/formAppearance.ts create mode 100644 src/components/form/fields/ChoiceGroupHeading.tsx diff --git a/package.json b/package.json index ce6df993..5b38ecea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qoretechnologies/reqraft", - "version": "0.10.62", + "version": "0.10.64", "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..104682e0 100644 --- a/src/components/form/engine/CompactRow.tsx +++ b/src/components/form/engine/CompactRow.tsx @@ -35,6 +35,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,6 +193,8 @@ 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); @@ -296,6 +299,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 +410,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 ( : null; const isPassiveCloseAction = fixedAllowedValueOption; @@ -1174,7 +1197,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 +1218,9 @@ export const CompactRow = memo( ({ intent: m.intent as string, content: String(m.label) })) : []; // TWO display channels, matching the Focus prototype: @@ -1493,7 +1520,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 +1531,7 @@ export const CompactRow = memo( }} > {label} - {required ? + {required && !calm ? : null} {helpIcon} @@ -1672,6 +1699,7 @@ export const CompactRow = memo( } $bg={cHover} $border={schemaIntentColor ? `${schemaIntentColor}66` : `${cInfo}66`} + $calm={calm} >
- + {( (schema as { icon?: string } | undefined)?.icon || (schema as { image?: string } | undefined)?.image @@ -1695,7 +1723,7 @@ export const CompactRow = memo( : null} {label} - {required ? + {required && !calm ? : null} {/* The `?` belongs here too. The read row and the inline @@ -1861,7 +1889,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 @@ -2210,7 +2238,24 @@ export const CompactRow = memo( }); // 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 +2297,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 +2367,12 @@ 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 ? 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 +2407,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..b75ecf4f 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 @@ -135,11 +155,16 @@ export const CompactToolbar = memo((reqoreProps: Partial; 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. @@ -161,11 +186,19 @@ export const CompactToolbar = memo((reqoreProps: Partial + {/* "Ready" is a claim about the whole form, so anything the form @@ -179,7 +212,7 @@ export const CompactToolbar = memo((reqoreProps: Partial - + · {completion.set}/{completion.total} set {attentionCount ? ' ·' : ''} @@ -192,15 +225,22 @@ export const CompactToolbar = memo((reqoreProps: Partial : null} - {completion.pct}% + {/* 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.tsx b/src/components/form/engine/FormEngine.tsx index 9b898b85..53efa92a 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, @@ -84,6 +85,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 +98,7 @@ import { StyledRequiredClusterHeader, StyledStatusBox, StyledStatusBoxGroupLabel, + StyledStatusDot, } from './compactRowStyles'; import { CompactToolbar } from './CompactToolbar'; import { @@ -114,6 +117,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, @@ -848,6 +859,21 @@ export interface IFormEngineProps extends Omit(!!(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 @@ -3266,6 +3296,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 @@ -3791,9 +3825,14 @@ const FormEngineImpl = ({ className='options-readfirst-required-cluster' $border={`${cWarning}33`} $tint={`${cWarning}0d`} + $calm={calm} > - - + + One of the below is required {railed} @@ -3821,20 +3860,31 @@ const FormEngineImpl = ({ return ( {showGroupSubLabel(groupName) ? - + {getOptionGroupLabel(groupName, groups)} : null} {showGroupSubLabel(groupName) && groupConfig?.subtitle ? {groupConfig.subtitle} @@ -3888,8 +3938,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 +4024,13 @@ const FormEngineImpl = ({ the reader's record withheld, and a page scrolls. */} {readOnly ? bucketCount('set') ? -
+
{bucketGroups.set.map((groupName) => renderBoxGroup('set', groupName))} @@ -4042,18 +4104,26 @@ const FormEngineImpl = ({ // 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 +4133,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 +4175,53 @@ 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' ? + + : null} + + {box.label} + + + {count} + + + : + + {box.label} + + + } - 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. */} ( - - - -); +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..83dd110f --- /dev/null +++ b/src/components/form/engine/FormEngineAppearance.stories.tsx @@ -0,0 +1,469 @@ +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', + 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 ( +
+ { + setValue(next); + onChange?.(name, next, info); + }} + /> +
+ ); + }, +}; + +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)); + +/** 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) => + element + .querySelector('.reqore-panel-title') + ?.textContent?.startsWith( + key === 'attention' ? 'Needs attention' + : 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); + + // 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, + }, + parameters: { + docs: { + description: { + story: + 'A new form 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 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/OptionFieldMessages.tsx b/src/components/form/engine/OptionFieldMessages.tsx index 3de9a2b2..f830060e 100644 --- a/src/components/form/engine/OptionFieldMessages.tsx +++ b/src/components/form/engine/OptionFieldMessages.tsx @@ -1,4 +1,9 @@ -import { ReqoreControlGroup, ReqoreTag, ReqoreVerticalSpacer } from '@qoretechnologies/reqore'; +import { + ReqoreControlGroup, + ReqoreP, + ReqoreTag, + ReqoreVerticalSpacer, +} from '@qoretechnologies/reqore'; import { IReqoreTagProps } from '@qoretechnologies/reqore/dist/components/Tag'; import { TReqoreIntent } from '@qoretechnologies/reqore/dist/constants/theme'; import { IQorusFormField, IQorusFormSchema, TQorusForm } from '@qoretechnologies/ts-toolkit'; @@ -19,6 +24,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 +341,15 @@ 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; } /** @@ -349,6 +364,8 @@ export const getOptionFieldMessages = ({ allOptions, getType, isRendererOnly, + calm, + readOnly, }: IOptionFieldMessagesProps): IReqoreTagProps[] => { const optionSchema = schema[name]; const result: IReqoreTagProps[] = []; @@ -394,7 +411,11 @@ 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' }); + result.push( + calm ? + { label: readOnly ? CALM_REQUIRED_READ_LABEL : CALM_REQUIRED_LABEL, intent: 'warning' } + : { label: 'This field is required', intent: 'danger' } + ); } if ( @@ -441,6 +462,7 @@ export const OptionFieldMessages = ({ getType, isRendererOnly, }: IOptionFieldMessagesProps) => { + const calm = useIsCalmForm(); const messages: IReqoreTagProps[] = useMemo( () => getOptionFieldMessages({ @@ -450,6 +472,7 @@ export const OptionFieldMessages = ({ allOptions, getType, isRendererOnly, + calm, }), [ JSON.stringify(schema), @@ -457,6 +480,7 @@ export const OptionFieldMessages = ({ JSON.stringify(allOptions), name, isRendererOnly, + calm, ] ); @@ -464,6 +488,30 @@ 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/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/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/fields/ChoiceGroupHeading.tsx b/src/components/form/fields/ChoiceGroupHeading.tsx new file mode 100644 index 00000000..d4a591b4 --- /dev/null +++ b/src/components/form/fields/ChoiceGroupHeading.tsx @@ -0,0 +1,38 @@ +import { ReqoreSpan } from '@qoretechnologies/reqore'; +import { memo } from 'react'; +import { useIsCalmForm } from '../engine/formAppearance'; + +/** + * The heading over a small group of choices (the checkboxes a field with three + * or fewer allowed values renders) — the field's own name, or "Select one:" + * when it has none. + * + * One component so both choice editors head their groups the same way, in + * either appearance: an upper-case micro label by default; in the calm look the + * words as written, at the small text size, because nothing in a calm form is + * forced into capitals. + */ +export const ChoiceGroupHeading = memo(({ children }: { children: React.ReactNode }) => { + const calm = useIsCalmForm(); + + return ( + + {children} + + ); +}); + +/** + * The intent a picked choice in those groups takes. Blue by default; none in + * the calm look, where blue says only "a filter is on" and the checked mark + * already says which choice is picked. + */ +export const useSelectedChoiceIntent = (): ((selected: boolean) => 'info' | undefined) => { + const calm = useIsCalmForm(); + + return (selected) => (selected && !calm ? 'info' : undefined); +}; diff --git a/src/components/form/fields/Field.tsx b/src/components/form/fields/Field.tsx index 4a60f59c..ede2c33e 100644 --- a/src/components/form/fields/Field.tsx +++ b/src/components/form/fields/Field.tsx @@ -2,7 +2,6 @@ import { IReqoreLabelProps, ReqoreCheckbox, ReqoreControlGroup, - ReqoreSpan, } from '@qoretechnologies/reqore'; import { IWithReqoreSize } from '@qoretechnologies/reqore/dist/types/global'; import { TReqoreBadge } from '@qoretechnologies/reqore/dist/components/Button'; @@ -34,6 +33,7 @@ import { UNAVAILABLE_ITEM_CLASS, } from './select/SelectCollection'; import { useAllowedValueAvailability } from '../engine/OptionFieldMessages'; +import { ChoiceGroupHeading, useSelectedChoiceIntent } from './ChoiceGroupHeading'; import { StringFormField } from './string/String'; import { ArrayAutoField } from './array/ArrayAutoField'; import { AutoFormField } from './auto/AutoFormField'; @@ -152,6 +152,7 @@ export const FormField = ({ ...rest }: IFormFieldProps) => { const id = useId(); + const selectedChoiceIntent = useSelectedChoiceIntent(); const { schema: finalArgSchema, loading: argSchemaLoading } = useArgSchema(arg_schema); const handleChange = (value: TFormFieldValueType, event?: unknown) => { @@ -435,7 +436,7 @@ export const FormField = ({ label={item.display_name ?? String(itemValue)} tooltip={item.short_desc || item.desc} checked={checked} - intent={checked ? 'info' : undefined} + intent={selectedChoiceIntent(checked)} onClick={() => { const next = checked ? @@ -653,11 +654,11 @@ export const FormField = ({ if (allowed_values.length <= 3) { return ( - + {/* Headed by the field's own name; "Select one:" only when it has none. Same rule as FieldAllowedValuesCheckGroup. */} {display_name ? `${display_name}:` : 'Select one:'} - + {allowed_values.map((item, index) => { const itemValue = item.value?.value; const checked = isEqual(value, itemValue); @@ -695,7 +696,7 @@ export const FormField = ({ readOnly={!!unavailable} uncheckedIcon={unavailable ? 'LockLine' : undefined} checked={checked} - intent={checked ? 'info' : undefined} + intent={selectedChoiceIntent(checked)} onClick={ unavailable ? undefined : ( () => handleChange((checked ? undefined : itemValue) as TFormFieldValueType) diff --git a/src/components/form/fields/allowed-values/AllowedValues.tsx b/src/components/form/fields/allowed-values/AllowedValues.tsx index d20c443c..4caa27c8 100644 --- a/src/components/form/fields/allowed-values/AllowedValues.tsx +++ b/src/components/form/fields/allowed-values/AllowedValues.tsx @@ -8,7 +8,7 @@ // - `ConnectionManagement` per-item actions — dropped (actions omitted). // - `Select` → reqraft `SelectFormField` (`items` instead of `defaultItems`, // single-arg `onChange`; `type`/`closeCollectionOnUnMount` not forwarded). -import { ReqoreCheckbox, ReqoreControlGroup, ReqoreSpan } from '@qoretechnologies/reqore'; +import { ReqoreCheckbox, ReqoreControlGroup } from '@qoretechnologies/reqore'; import { IReqoreButtonProps } from '@qoretechnologies/reqore/dist/components/Button'; import { IQorusAllowedValue, TQorusType } from '@qoretechnologies/ts-toolkit'; import { size as count, isEqual } from 'lodash'; @@ -20,6 +20,7 @@ import { } from '../select/SelectCollection'; import { ISelectFormFieldItem, SelectFormField as Select } from '../select/Select'; import { useAllowedValueAvailability } from '../../engine/OptionFieldMessages'; +import { ChoiceGroupHeading, useSelectedChoiceIntent } from '../ChoiceGroupHeading'; import type { IReqraftAllowedValue } from '../Field'; export interface IFieldAllowedValuesProps extends Pick< @@ -97,13 +98,15 @@ export const FieldAllowedValuesCheckGroup = memo( */ label?: string; }) => { + const selectedChoiceIntent = useSelectedChoiceIntent(); + return ( - + {label ? `${label}:` : multiSelect ? 'Select one or more:' : 'Select one:'} - + {items?.map((item) => { const unavailable = getSelectItemUnavailability(item); @@ -134,7 +137,7 @@ export const FieldAllowedValuesCheckGroup = memo( which keeps the pointer events the tooltip is read through. It does not withhold `onClick` — the handler below does. */ readOnly={rest.readOnly || !!unavailable} - intent={item.value === value ? 'info' : undefined} + intent={selectedChoiceIntent(item.value === value)} // `isEqual`, and only once there is a value to match. The single // -select branch compared JSON.stringify(value) with // JSON.stringify(item.value), and JSON.stringify(undefined) is diff --git a/src/components/form/fields/radio-group/RadioGroup.tsx b/src/components/form/fields/radio-group/RadioGroup.tsx index f651f6c6..fe9d6a4e 100644 --- a/src/components/form/fields/radio-group/RadioGroup.tsx +++ b/src/components/form/fields/radio-group/RadioGroup.tsx @@ -1,6 +1,7 @@ import { ReqoreRadioGroup } from '@qoretechnologies/reqore'; import { IReqoreRadioGroupProps } from '@qoretechnologies/reqore/dist/components/RadioGroup'; import { useMemo } from 'react'; +import { useIsCalmForm } from '../../engine/formAppearance'; export interface IRadioGroupFormFieldProps extends Omit { @@ -16,20 +17,25 @@ export const RadioGroupFormField = ({ value, ...rest }: IRadioGroupFormFieldProps) => { + const calm = useIsCalmForm(); const items: IReqoreRadioGroupProps['items'] = useMemo( () => _items.map((item) => ({ margin: 'right', - labelEffect: { - spaced: 1, - weight: 'bold', - uppercase: true, - textSize: 'small', - }, + // Calm: an option's name as written — a choice is read, not shouted. + labelEffect: + calm ? + { textSize: 'small' } + : { + spaced: 1, + weight: 'bold', + uppercase: true, + textSize: 'small', + }, ...item, disabled: disabled || item.disabled, })), - [_items] + [_items, calm] ); return ( diff --git a/src/components/form/index.tsx b/src/components/form/index.tsx index f6a65555..e3ba602d 100644 --- a/src/components/form/index.tsx +++ b/src/components/form/index.tsx @@ -24,6 +24,7 @@ export * from './fields/string/String'; export * from './fields/template/ExampleValueModal'; export * from './fields/template/TemplateField'; export * from './engine/FormEngine'; +export * from './engine/formAppearance'; export * from './engine/FormFieldsSkeleton'; export * from './engine/optionActions'; export * from './engine/rendererTypes'; From 41b108d91b0f734d6c7768e076fba9090226b5ab Mon Sep 17 00:00:00 2001 From: foxhoundn Date: Mon, 19 Oct 2026 12:05:11 +0200 Subject: [PATCH 02/23] =?UTF-8?q?chore(version):=200.10.63=20=E2=80=94=20o?= =?UTF-8?q?ne=20patch=20bump,=20as=20the=20PR=20gate=20requires?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The branch skipped to 0.10.64 because the open PR #137 also claims 0.10.63, but the gate counts bumps from the merge-base and allows exactly one; 0.10.63 is not published as a release. Whichever of the two PRs merges second rebases and bumps again, as AGENTS.md says. Refs qoretechnologies/toolkit-react#144 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5b38ecea..08afa904 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qoretechnologies/reqraft", - "version": "0.10.64", + "version": "0.10.63", "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", From 2aae872e82f8544e42cb3cc02d906785205a8da3 Mon Sep 17 00:00:00 2001 From: foxhoundn Date: Mon, 19 Oct 2026 12:28:24 +0200 Subject: [PATCH 03/23] fix(form-engine): a calm form's informative notes are not blue Seen in the Qorus IDE's column drawer: a field's schema note that only explains ("The type decides which of the fields below apply") drew as a blue info panel, while in the calm look blue says only "a filter is on" and green is for a healthy figure. In the calm appearance an info or success note now takes no intent; warning and danger notes keep theirs, because those are what a person has to act on. The Calm story asserts the note's surface is grey. The story frame also fills its width, so it renders the same in the Storybook UI as in the story run. Refs qoretechnologies/toolkit-react#144 --- src/components/form/engine/CompactRow.tsx | 9 ++++++++- .../form/engine/FormEngineAppearance.stories.tsx | 12 ++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/components/form/engine/CompactRow.tsx b/src/components/form/engine/CompactRow.tsx index 104682e0..dc281e6a 100644 --- a/src/components/form/engine/CompactRow.tsx +++ b/src/components/form/engine/CompactRow.tsx @@ -1344,7 +1344,14 @@ export const CompactRow = memo( size='small' opaque={false} flat - intent={m.intent as never} + // Calm: a note that only informs takes no colour — blue is kept for "a + // filter is on", green for a healthy figure. A warning or a failure + // keeps its intent: those are what a person has to act on. + intent={ + (calm && (!m.intent || m.intent === 'info' || m.intent === 'success') ? + undefined + : m.intent) as never + } title={m.title} style={{ marginBottom: 8 }} > diff --git a/src/components/form/engine/FormEngineAppearance.stories.tsx b/src/components/form/engine/FormEngineAppearance.stories.tsx index 83dd110f..645210f6 100644 --- a/src/components/form/engine/FormEngineAppearance.stories.tsx +++ b/src/components/form/engine/FormEngineAppearance.stories.tsx @@ -67,6 +67,12 @@ const options: Record = { 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' }, @@ -318,6 +324,12 @@ export const Calm: Story = { 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(); From 570be90d2bc20d0b9f6d13c3eb82460d218a7cf0 Mon Sep 17 00:00:00 2001 From: foxhoundn Date: Mon, 19 Oct 2026 22:24:53 +0200 Subject: [PATCH 04/23] fix(supportTicket): the ticket rail's labels take the theme's case TicketHeader passed uppercaseLabels to its description list, so the rail read STATUS / OPENED BY in an app whose theme sets every label in sentence case. Leaving the prop out lets the theme decide; a theme that says nothing still gets uppercase, as before. --- src/components/supportTicket/TicketHeader.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/supportTicket/TicketHeader.tsx b/src/components/supportTicket/TicketHeader.tsx index 8cad49f6..4dcd1d03 100644 --- a/src/components/supportTicket/TicketHeader.tsx +++ b/src/components/supportTicket/TicketHeader.tsx @@ -201,7 +201,8 @@ export const TicketHeader = ({ and the strip would no longer line up with them. */ rowPadding='tiny' labelWidth={LABEL_WIDTH} - uppercaseLabels + /* The labels' case is the theme's (`theme.uppercaseLabels`): an app that sets its + labels in sentence case gets "Opened by", not "OPENED BY". */ flat transparent padded={false} From 15234a3e34240ea6d9ca16cad6d3d1c140791fd5 Mon Sep 17 00:00:00 2001 From: foxhoundn Date: Thu, 22 Oct 2026 04:38:08 +0200 Subject: [PATCH 05/23] feat(form-engine): a new form is not already wrong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A brand-new form opened with "2 need attention" and every empty required field in amber before anyone had typed, so the first thing a person met was a list of faults (the Goal-gradient finding of the IDE's UX review). An empty required field nobody has been to yet is now `pending`: still in the first box, which reads "To fill in" in the quiet colour, with the requirement stated plainly ("Required · add a value", "This field is required") and no amber. It becomes a fault (`todo`, amber) once the person opens it and leaves it empty, or when the host reports a submit attempt through the new `revealRequired` prop. Invalid values are faults from the start, as before. - readFirst: the `pending` status; `getReadFirstStatus` takes `visited` (unset means visited, the old behaviour). - FormEngine: `visitedOptions` (rows opened and left), `revealRequired`, a fault count for the toolbar and the first box; the box is "Needs attention" with its amber dot only while it holds a fault; `compactBoxLabels.pending` renames "To fill in". - CompactRow / OptionFieldMessages: the requirement is stated with no intent until the field is visited (`requirementPending`). - CompactToolbar: "N to fill in →" in the quiet colour; once something is a fault, "N need attention → · M to fill in", and the meter's amber run counts faults only. - Stories about which box a field lands in find the first box under either name; "Calm Needs Attention" shows the state after a submit attempt, and "Calm New Form" shows a new form and the visit. --- __tests__/formEngineCompletionMeter.test.tsx | 4 +- __tests__/formEngineFreshForm.test.tsx | 74 ++++++++++++++++ __tests__/formEngineLockedRequired.test.tsx | 8 +- __tests__/formEngineRequiredMessage.test.tsx | 7 +- __tests__/readFirst.test.ts | 15 ++++ src/components/form/engine/CompactRow.tsx | 15 +++- src/components/form/engine/CompactToolbar.tsx | 29 +++++-- .../form/engine/FormEngine.stories.tsx | 38 +++++--- src/components/form/engine/FormEngine.tsx | 87 ++++++++++++++++--- .../engine/FormEngineAppearance.stories.tsx | 65 +++++++++++--- .../FormEngineStickyHeaders.stories.tsx | 6 +- .../form/engine/OptionFieldMessages.tsx | 14 ++- .../form/engine/compactRowContext.ts | 5 ++ .../form/engine/compactToolbarContext.ts | 3 + src/components/form/engine/readFirst.ts | 19 +++- 15 files changed, 337 insertions(+), 52 deletions(-) create mode 100644 __tests__/formEngineFreshForm.test.tsx 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..132273da --- /dev/null +++ b/__tests__/formEngineFreshForm.test.tsx @@ -0,0 +1,74 @@ +import { ReqoreUIProvider } from '@qoretechnologies/reqore'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +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('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/src/components/form/engine/CompactRow.tsx b/src/components/form/engine/CompactRow.tsx index dc281e6a..634d038a 100644 --- a/src/components/form/engine/CompactRow.tsx +++ b/src/components/form/engine/CompactRow.tsx @@ -197,6 +197,13 @@ export const CompactRow = memo( 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 @@ -1263,6 +1270,8 @@ export const CompactRow = memo( isRendererOnly, calm, readOnly, + // Until the person has been to the field, its requirement is stated plainly. + requirementPending: !visited, }).map((m) => ({ intent: m.intent as string, content: String(m.label) })) : []; // TWO display channels, matching the Focus prototype: @@ -2242,6 +2251,7 @@ 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. @@ -2375,7 +2385,10 @@ export const CompactRow = memo( // mobile, where a dim value blends into the prose. The bold name still // outranks it; the empty dash stays faint. $color={ - calmRequiredWord ? cWarning + calmRequiredWord ? + rowStatus === 'pending' ? + `${cMuted}a6` + : cWarning : empty || hidden ? `${cMuted}66` : cKey } diff --git a/src/components/form/engine/CompactToolbar.tsx b/src/components/form/engine/CompactToolbar.tsx index b75ecf4f..e4a83253 100644 --- a/src/components/form/engine/CompactToolbar.tsx +++ b/src/components/form/engine/CompactToolbar.tsx @@ -133,6 +133,7 @@ export const CompactToolbar = memo((reqoreProps: Partial - {attentionCount ? + {/* 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 ? - {attentionCount} need attention → + {attentionFaultCount} need attention → : null} + {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 ? 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 53efa92a..1410187b 100644 --- a/src/components/form/engine/FormEngine.tsx +++ b/src/components/form/engine/FormEngine.tsx @@ -954,8 +954,19 @@ 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. */ - compactBoxLabels?: Partial>; + revealRequired?: boolean; /** * 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`. @@ -1276,6 +1287,7 @@ const FormEngineImpl = ({ compactPanelProps, compactCollapsedGroups: compactCollapsedGroupsProp, compactBoxLabels, + revealRequired = false, compactStickyBoxHeaders = true, compactStickyOffset = 0, compactToolbar = true, @@ -1360,6 +1372,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([]); @@ -2578,9 +2605,12 @@ const FormEngineImpl = ({ covered, invalid, warned: msgIntent === 'warning', + visited: revealRequired || visitedOptions.includes(name), }); }, [ + revealRequired, + visitedOptions, isRendererOnly, JSON.stringify(options), JSON.stringify(availableOptions), @@ -2613,6 +2643,15 @@ 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] + ); const getIntent = useCallback( // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -3318,6 +3357,8 @@ const FormEngineImpl = ({ codePreviewRenderer, showAllDescriptions, expandedOptions, + visitedOptions, + revealRequired, highlightedOptions, flashedOptions, infoPanelOverrides, @@ -3366,6 +3407,8 @@ const FormEngineImpl = ({ codePreviewRenderer, showAllDescriptions, expandedOptions, + visitedOptions, + revealRequired, highlightedOptions, flashedOptions, infoPanelOverrides, @@ -3417,6 +3460,7 @@ const FormEngineImpl = ({ readOnly, invalidCount: size(validityData.invalidFields), attentionCount: readFirstAttentionCount, + attentionFaultCount: readFirstAttentionFaultCount, completion: readFirstCompletion, showInvalidOnly: showInvalidOptionsOnly, onToggleInvalidOnly: handleToggleInvalidOnly, @@ -3464,6 +3508,7 @@ const FormEngineImpl = ({ onReadOnlyActivate, validityData, readFirstAttentionCount, + readFirstAttentionFaultCount, readFirstCompletion, showInvalidOptionsOnly, handleToggleInvalidOnly, @@ -3745,12 +3790,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', @@ -3819,20 +3872,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} @@ -4098,7 +4159,7 @@ 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 @@ -4179,7 +4240,7 @@ const FormEngineImpl = ({ // One status dot and a word — and only where a // person is needed. The count is a quiet number. - {box.key === 'attention' ? + {box.key === 'attention' && readFirstAttentionFaultCount ? ; 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); @@ -205,15 +211,12 @@ const isGrey = (color: string): boolean => { }; const box = (key: 'attention' | 'set' | 'optional'): HTMLElement | undefined => - [...document.querySelectorAll('.options-readfirst-group')].find((element) => - element - .querySelector('.reqore-panel-title') - ?.textContent?.startsWith( - key === 'attention' ? 'Needs attention' - : key === 'set' ? 'Set' - : 'Optional' - ) - ); + [...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}"]`); @@ -349,12 +352,15 @@ 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 new form 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.', + '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.', }, }, }, @@ -444,6 +450,45 @@ export const CalmReadOnly: Story = { }, }; +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, 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, then open another field: Name has been visited and left empty. + await _testsClickText('Name'); + 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', 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 f830060e..f7e08ad1 100644 --- a/src/components/form/engine/OptionFieldMessages.tsx +++ b/src/components/form/engine/OptionFieldMessages.tsx @@ -350,6 +350,13 @@ export interface IOptionFieldMessagesProps { 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; } /** @@ -366,6 +373,7 @@ export const getOptionFieldMessages = ({ isRendererOnly, calm, readOnly, + requirementPending, }: IOptionFieldMessagesProps): IReqoreTagProps[] => { const optionSchema = schema[name]; const result: IReqoreTagProps[] = []; @@ -411,10 +419,12 @@ 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( + const message = calm ? { label: readOnly ? CALM_REQUIRED_READ_LABEL : CALM_REQUIRED_LABEL, intent: 'warning' } - : { label: 'This field is required', intent: 'danger' } + : { label: 'This field is required', intent: 'danger' }; + result.push( + requirementPending ? { label: message.label } : (message as IReqoreTagProps) ); } 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/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/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' From 7303dfea1b4c6736e91f7a88befdb205216a6943 Mon Sep 17 00:00:00 2001 From: foxhoundn Date: Thu, 22 Oct 2026 06:00:09 +0200 Subject: [PATCH 06/23] fix(form-engine): an open field on a new form is not already wrong either The quiet "to fill in" state reached the collapsed rows, the box and the readiness line, but not the line under an open editor: OptionFieldMessages took no requirementPending, so a host that opens the first empty field for typing (the IDE's creators do) showed that one field in amber on a form nobody had touched. The editor's message now follows the same rule as the row (open is not left; a submit attempt reveals all), in the row's faint pending colour. CalmNewForm asserts the open field's message is not amber; it fails without the fix. --- src/components/form/engine/FormEngine.tsx | 5 +++++ .../engine/FormEngineAppearance.stories.tsx | 18 ++++++++++++++++-- .../form/engine/OptionFieldMessages.tsx | 13 +++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/components/form/engine/FormEngine.tsx b/src/components/form/engine/FormEngine.tsx index 1410187b..9bbd0f41 100644 --- a/src/components/form/engine/FormEngine.tsx +++ b/src/components/form/engine/FormEngine.tsx @@ -3260,6 +3260,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) ? <> @@ -3311,6 +3314,8 @@ const FormEngineImpl = ({ getTypeForOption, inheritedScopes, inheritedFromParent, + revealRequired, + visitedOptions, ] ); diff --git a/src/components/form/engine/FormEngineAppearance.stories.tsx b/src/components/form/engine/FormEngineAppearance.stories.tsx index 66975a80..880070d3 100644 --- a/src/components/form/engine/FormEngineAppearance.stories.tsx +++ b/src/components/form/engine/FormEngineAppearance.stories.tsx @@ -459,7 +459,7 @@ export const CalmNewForm: Story = { 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, 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".', + '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".', }, }, }, @@ -476,8 +476,22 @@ export const CalmNewForm: Story = { expect(link.textContent).toContain('to fill in'); expect(isAmber(getComputedStyle(link).color)).toBe(false); - // Open Name, then open another field: Name has been visited and left empty. + // 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. diff --git a/src/components/form/engine/OptionFieldMessages.tsx b/src/components/form/engine/OptionFieldMessages.tsx index f7e08ad1..a0b6e6a7 100644 --- a/src/components/form/engine/OptionFieldMessages.tsx +++ b/src/components/form/engine/OptionFieldMessages.tsx @@ -3,9 +3,11 @@ import { 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'; @@ -471,8 +473,10 @@ export const OptionFieldMessages = ({ allOptions, getType, isRendererOnly, + requirementPending, }: IOptionFieldMessagesProps) => { const calm = useIsCalmForm(); + const theme = useReqoreTheme(); const messages: IReqoreTagProps[] = useMemo( () => getOptionFieldMessages({ @@ -483,6 +487,7 @@ export const OptionFieldMessages = ({ getType, isRendererOnly, calm, + requirementPending, }), [ JSON.stringify(schema), @@ -491,6 +496,7 @@ export const OptionFieldMessages = ({ name, isRendererOnly, calm, + requirementPending, ] ); @@ -513,6 +519,13 @@ export const OptionFieldMessages = ({ key={index} size='small' intent={message.intent} + // A requirement nobody has been asked about yet carries no intent: it is + // said in the same faint colour as the row's pending word, not in full text. + effect={ + message.intent ? undefined : ( + { color: `${getReadableColor(theme, undefined, undefined, true)}a6` } + ) + } className='options-field-message' > {message.label} From 77e07b70c310286df91d6ba30bd448bcc553e733 Mon Sep 17 00:00:00 2001 From: foxhoundn Date: Thu, 22 Oct 2026 09:25:48 +0200 Subject: [PATCH 07/23] feat(form-engine): tell the host what is left, and answer a blocked submit with where to type Two seams for a host that draws its own submit control (the IDE's editor rail): - onAttentionChange reports the readiness line's two numbers ({ count, faults }) whenever they change, so a submit button drawn from them can never disagree with the form about what is left. - focusAttentionRequest: each new value opens the first field that needs attention and puts the caret in it, the answer to a submit that could not go. The submit button holds the caret at that moment and an open row never takes a caret held outside it, so the engine lets go of it first; a field that is already open gets the caret directly. The arrival opener and the new request share one scan (getAttentionFieldMeta, openOption), so both pick the same field. The open row's editor cell carries options-readfirst-row-editor so the caret lands in the control, not the label. --- __tests__/formEngineFreshForm.test.tsx | 68 ++++++++++ src/components/form/engine/CompactRow.tsx | 1 + src/components/form/engine/FormEngine.tsx | 150 +++++++++++++++++----- 3 files changed, 187 insertions(+), 32 deletions(-) diff --git a/__tests__/formEngineFreshForm.test.tsx b/__tests__/formEngineFreshForm.test.tsx index 132273da..7518086a 100644 --- a/__tests__/formEngineFreshForm.test.tsx +++ b/__tests__/formEngineFreshForm.test.tsx @@ -60,6 +60,74 @@ describe('a new form', () => { 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) + ); + }); + 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), diff --git a/src/components/form/engine/CompactRow.tsx b/src/components/form/engine/CompactRow.tsx index 634d038a..96ec8ccd 100644 --- a/src/components/form/engine/CompactRow.tsx +++ b/src/components/form/engine/CompactRow.tsx @@ -1569,6 +1569,7 @@ export const CompactRow = memo(
{ if (event.key === 'Escape') { diff --git a/src/components/form/engine/FormEngine.tsx b/src/components/form/engine/FormEngine.tsx index 9bbd0f41..117ba36d 100644 --- a/src/components/form/engine/FormEngine.tsx +++ b/src/components/form/engine/FormEngine.tsx @@ -44,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'; @@ -208,6 +209,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), @@ -967,6 +976,19 @@ export interface IFormEngineProps extends Omit 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`. @@ -1288,6 +1310,8 @@ const FormEngineImpl = ({ compactCollapsedGroups: compactCollapsedGroupsProp, compactBoxLabels, revealRequired = false, + focusAttentionRequest, + onAttentionChange, compactStickyBoxHeaders = true, compactStickyOffset = 0, compactToolbar = true, @@ -2652,6 +2676,15 @@ const FormEngineImpl = ({ ).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 @@ -2777,6 +2810,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; @@ -2815,22 +2881,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 @@ -2838,17 +2889,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, @@ -2856,13 +2903,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'))?.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( From d2fe106f6e4e75649509afdcaea58a591fe57b8c Mon Sep 17 00:00:00 2001 From: foxhoundn Date: Thu, 22 Oct 2026 09:55:17 +0200 Subject: [PATCH 08/23] fix(form-engine): the attention filter never hides the row being typed in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "N need attention →" shows only the fields that are still invalid. The first character typed into an empty required field makes it valid, so the row was filtered out from under the caret mid-word and the rest of the keystrokes went nowhere. An open row now stays while the reader is in it, the same rule its box already follows (stableBucketOf), and leaves the filtered list once it is closed. It mattered more now that a blocked submit turns the filter on and puts the caret in the first field to fill in. The new test types the first letters, waits for the form to take them, then types the rest; it fails without the fix. --- __tests__/formEngineFreshForm.test.tsx | 51 +++++++++++++++++++++++ src/components/form/engine/FormEngine.tsx | 6 +++ 2 files changed, 57 insertions(+) diff --git a/__tests__/formEngineFreshForm.test.tsx b/__tests__/formEngineFreshForm.test.tsx index 7518086a..742aaee6 100644 --- a/__tests__/formEngineFreshForm.test.tsx +++ b/__tests__/formEngineFreshForm.test.tsx @@ -1,6 +1,7 @@ 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'; @@ -128,6 +129,56 @@ describe('a new form', () => { ); }); + /* "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); + }); + 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), diff --git a/src/components/form/engine/FormEngine.tsx b/src/components/form/engine/FormEngine.tsx index 117ba36d..516cc0f9 100644 --- a/src/components/form/engine/FormEngine.tsx +++ b/src/components/form/engine/FormEngine.tsx @@ -2539,6 +2539,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( @@ -2561,6 +2566,7 @@ const FormEngineImpl = ({ ); }, [ showInvalidOptionsOnly, + expandedOptions, isRendererOnly, JSON.stringify(availableOptions), JSON.stringify(options), From 857a198f70694d2990b3bfa0a1adb4bb18ae5ebe Mon Sep 17 00:00:00 2001 From: foxhoundn Date: Thu, 22 Oct 2026 10:16:05 +0200 Subject: [PATCH 09/23] =?UTF-8?q?fix(form-engine):=20a=20field=20that=20op?= =?UTF-8?q?ens=20as=20a=20card=20takes=20the=20caret,=20and=20"N=20to=20fi?= =?UTF-8?q?ll=20in=20=E2=86=92"=20is=20a=20real=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps a blocked submit made visible: - A field that opens as a card (a long text, a description, source code) never took the caret on opening; only the inline row did. So clicking Description opened it and left the cursor nowhere, and a submit request opened it without the caret. The card now carries the same row and editor refs as the inline row, so the one tap-to-edit rule covers both shapes. - "N need attention →" / "N to fill in →" was a clickable span: no role, no keyboard, no state. It is a toggle (show only those fields), so it is now a button with aria-pressed, Enter and Space. A host that turns it on for a person reads aria-pressed instead of guessing; the toolbar draws a different link once something becomes a fault, so a mark the host left on the old one was lost and a second press turned the filter back off. --- __tests__/formEngineFreshForm.test.tsx | 57 +++++++++++++++++++ src/components/form/engine/CompactRow.tsx | 6 ++ src/components/form/engine/CompactToolbar.tsx | 30 ++++++---- 3 files changed, 83 insertions(+), 10 deletions(-) diff --git a/__tests__/formEngineFreshForm.test.tsx b/__tests__/formEngineFreshForm.test.tsx index 742aaee6..46f75c2e 100644 --- a/__tests__/formEngineFreshForm.test.tsx +++ b/__tests__/formEngineFreshForm.test.tsx @@ -179,6 +179,63 @@ describe('a new form', () => { expect(document.activeElement).toBe(input); }); + /* "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), diff --git a/src/components/form/engine/CompactRow.tsx b/src/components/form/engine/CompactRow.tsx index 96ec8ccd..b8730b54 100644 --- a/src/components/form/engine/CompactRow.tsx +++ b/src/components/form/engine/CompactRow.tsx @@ -1708,6 +1708,7 @@ export const CompactRow = memo( return ( { if (event.key === 'Escape') { diff --git a/src/components/form/engine/CompactToolbar.tsx b/src/components/form/engine/CompactToolbar.tsx index e4a83253..7a8d93be 100644 --- a/src/components/form/engine/CompactToolbar.tsx +++ b/src/components/form/engine/CompactToolbar.tsx @@ -135,6 +135,7 @@ 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'; @@ -223,11 +241,7 @@ export const CompactToolbar = memo((reqoreProps: Partial + {attentionFaultCount} need attention → : null} @@ -236,11 +250,7 @@ export const CompactToolbar = memo((reqoreProps: Partial · {attentionCount - attentionFaultCount} to fill in - : + : {attentionCount} to fill in → : null} From fa26fd9755ba6bad29370d24e8c222a82e193905 Mon Sep 17 00:00:00 2001 From: foxhoundn Date: Thu, 22 Oct 2026 10:33:41 +0200 Subject: [PATCH 10/23] fix(form-engine): a card hands the caret only to where text goes, and role="textbox" is text Giving cards the row's caret rule exposed two gaps, found by typing into a job's Source Code after a blocked submit: - A code editor's edit area (Monaco) is a focusable div with role="textbox"; its ' + + '
' + ); + 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/src/components/form/engine/CompactRow.tsx b/src/components/form/engine/CompactRow.tsx index b8730b54..938844d5 100644 --- a/src/components/form/engine/CompactRow.tsx +++ b/src/components/form/engine/CompactRow.tsx @@ -960,7 +960,8 @@ export const CompactRow = memo( 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`). diff --git a/src/components/form/engine/FormEngine.tsx b/src/components/form/engine/FormEngine.tsx index 516cc0f9..372c8b9d 100644 --- a/src/components/form/engine/FormEngine.tsx +++ b/src/components/form/engine/FormEngine.tsx @@ -2949,9 +2949,9 @@ const FormEngineImpl = ({ const row = wrap?.querySelector(`[data-field="${target}"]`); if (row) { revealRow(row, 'auto'); - findRowFocusTarget(row.querySelector('.options-readfirst-row-editor'))?.focus({ - preventScroll: true, - }); + findRowFocusTarget(row.querySelector('.options-readfirst-row-editor'), { + textEntryOnly: row.classList.contains('options-readfirst-card'), + })?.focus({ preventScroll: true }); } }, [focusAttentionRequest]); 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 `