@qoretechnologies/reqraft 0.10.8 → 0.10.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  2. package/dist/components/form/engine/CompactRow.js +6 -2
  3. package/dist/components/form/engine/CompactRow.js.map +1 -1
  4. package/dist/components/form/engine/FormEngine.d.ts +16 -1
  5. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  6. package/dist/components/form/engine/FormEngine.js +83 -1
  7. package/dist/components/form/engine/FormEngine.js.map +1 -1
  8. package/dist/components/form/engine/compactRowContext.d.ts +1 -0
  9. package/dist/components/form/engine/compactRowContext.d.ts.map +1 -1
  10. package/dist/components/form/engine/compactRowContext.js.map +1 -1
  11. package/dist/components/form/engine/readFirst.d.ts +28 -0
  12. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  13. package/dist/components/form/engine/readFirst.js +24 -1
  14. package/dist/components/form/engine/readFirst.js.map +1 -1
  15. package/dist/components/form/fields/binary/Binary.d.ts +15 -0
  16. package/dist/components/form/fields/binary/Binary.d.ts.map +1 -0
  17. package/dist/components/form/fields/binary/Binary.js +74 -0
  18. package/dist/components/form/fields/binary/Binary.js.map +1 -0
  19. package/package.json +2 -2
  20. package/src/components/form/engine/CompactRow.tsx +6 -2
  21. package/src/components/form/engine/FormEngine.stories.tsx +169 -0
  22. package/src/components/form/engine/FormEngine.tsx +108 -0
  23. package/src/components/form/engine/compactRowContext.ts +6 -0
  24. package/src/components/form/engine/readFirst.ts +42 -0
  25. package/src/components/form/fields/binary/Binary.stories.tsx +90 -0
  26. package/src/components/form/fields/binary/Binary.tsx +81 -0
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ var __assign = (this && this.__assign) || function () {
3
+ __assign = Object.assign || function(t) {
4
+ for (var s, i = 1, n = arguments.length; i < n; i++) {
5
+ s = arguments[i];
6
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
7
+ t[p] = s[p];
8
+ }
9
+ return t;
10
+ };
11
+ return __assign.apply(this, arguments);
12
+ };
13
+ var __rest = (this && this.__rest) || function (s, e) {
14
+ var t = {};
15
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
16
+ t[p] = s[p];
17
+ if (s != null && typeof Object.getOwnPropertySymbols === "function")
18
+ for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
19
+ if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
20
+ t[p[i]] = s[p[i]];
21
+ }
22
+ return t;
23
+ };
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.ReqraftBinaryFormField = void 0;
26
+ var jsx_runtime_1 = require("react/jsx-runtime");
27
+ var reqore_1 = require("@qoretechnologies/reqore");
28
+ var react_1 = require("react");
29
+ var react_use_1 = require("react-use");
30
+ var File_1 = require("../file/File");
31
+ /**
32
+ * Strip a `data:<mime-type>;base64,` prefix so the emitted value carries raw base64, matching the
33
+ * canonical binary wire form the Qorus server expects. `ReqraftFileFormField` reads uploads with
34
+ * `readAsDataURL`, so its `content` always carries that prefix.
35
+ */
36
+ var stripDataUrlPrefix = function (dataUrl) {
37
+ var commaIndex = dataUrl.indexOf(',');
38
+ return commaIndex === -1 ? dataUrl : dataUrl.slice(commaIndex + 1);
39
+ };
40
+ /**
41
+ * A field for binary values encoded as base64. The value can be typed or pasted directly into the
42
+ * textarea, or populated by uploading a file — the upload reuses {@link ReqraftFileFormField} (the
43
+ * shared Reqraft file picker / drop zone) rather than a bespoke dropzone; its data-URL content is
44
+ * decoded to raw base64 for the field value.
45
+ */
46
+ exports.ReqraftBinaryFormField = (0, react_1.memo)(function (_a) {
47
+ var value = _a.value, onChange = _a.onChange, rest = __rest(_a, ["value", "onChange"]);
48
+ var _b = (0, react_1.useState)(value !== null && value !== void 0 ? value : ''), localValue = _b[0], setLocalValue = _b[1];
49
+ (0, react_1.useEffect)(function () {
50
+ if (value !== localValue) {
51
+ setLocalValue(value !== null && value !== void 0 ? value : '');
52
+ }
53
+ }, [value]);
54
+ (0, react_use_1.useDebounce)(function () {
55
+ if (localValue !== value) {
56
+ onChange === null || onChange === void 0 ? void 0 : onChange(localValue);
57
+ }
58
+ }, 100, [localValue, onChange]);
59
+ var handleChange = (0, react_1.useCallback)(function (event) {
60
+ setLocalValue(event.target.value);
61
+ }, []);
62
+ var handleFileChange = (0, react_1.useCallback)(function (file) {
63
+ var _a;
64
+ var base64 = stripDataUrlPrefix((_a = file === null || file === void 0 ? void 0 : file.content) !== null && _a !== void 0 ? _a : '');
65
+ setLocalValue(base64);
66
+ onChange === null || onChange === void 0 ? void 0 : onChange(base64);
67
+ }, [onChange]);
68
+ return ((0, jsx_runtime_1.jsxs)(reqore_1.ReqoreControlGroup, { vertical: true, fluid: true, children: [(0, jsx_runtime_1.jsx)(reqore_1.ReqoreTextarea, __assign({ scaleWithContent: true, fluid: true, value: localValue, onChange: handleChange, onClearClick: function () {
69
+ setLocalValue('');
70
+ onChange === null || onChange === void 0 ? void 0 : onChange('');
71
+ }, placeholder: 'Base64-encoded binary value' }, rest)), (0, jsx_runtime_1.jsx)(File_1.ReqraftFileFormField, { disabled: rest.disabled, onChange: handleFileChange })] }));
72
+ });
73
+ exports.default = exports.ReqraftBinaryFormField;
74
+ //# sourceMappingURL=Binary.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Binary.js","sourceRoot":"","sources":["../../../../../src/components/form/fields/binary/Binary.tsx"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,mDAA8E;AAE9E,+BAA4E;AAC5E,uCAAwC;AACxC,qCAAyE;AAOzE;;;;GAIG;AACH,IAAM,kBAAkB,GAAG,UAAC,OAAe;IACzC,IAAM,UAAU,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IACxC,OAAO,UAAU,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,UAAU,GAAG,CAAC,CAAC,CAAC;AACrE,CAAC,CAAC;AAEF;;;;;GAKG;AACU,QAAA,sBAAsB,GAAG,IAAA,YAAI,EACxC,UAAC,EAA0D;IAAxD,IAAA,KAAK,WAAA,EAAE,QAAQ,cAAA,EAAK,IAAI,cAA1B,qBAA4B,CAAF;IACnB,IAAA,KAA8B,IAAA,gBAAQ,EAAS,KAAK,aAAL,KAAK,cAAL,KAAK,GAAI,EAAE,CAAC,EAA1D,UAAU,QAAA,EAAE,aAAa,QAAiC,CAAC;IAElE,IAAA,iBAAS,EAAC;QACR,IAAI,KAAK,KAAK,UAAU,EAAE,CAAC;YACzB,aAAa,CAAC,KAAK,aAAL,KAAK,cAAL,KAAK,GAAI,EAAE,CAAC,CAAC;QAC7B,CAAC;IACH,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC;IAEZ,IAAA,uBAAW,EACT;QACE,IAAI,UAAU,KAAK,KAAK,EAAE,CAAC;YACzB,QAAQ,aAAR,QAAQ,uBAAR,QAAQ,CAAG,UAAU,CAAC,CAAC;QACzB,CAAC;IACH,CAAC,EACD,GAAG,EACH,CAAC,UAAU,EAAE,QAAQ,CAAC,CACvB,CAAC;IAEF,IAAM,YAAY,GAAG,IAAA,mBAAW,EAAC,UAAC,KAAuC;QACvE,aAAa,CAAC,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IACpC,CAAC,EAAE,EAAE,CAAC,CAAC;IAEP,IAAM,gBAAgB,GAAG,IAAA,mBAAW,EAClC,UAAC,IAA0B;;QACzB,IAAM,MAAM,GAAG,kBAAkB,CAAC,MAAA,IAAI,aAAJ,IAAI,uBAAJ,IAAI,CAAE,OAAO,mCAAI,EAAE,CAAC,CAAC;QACvD,aAAa,CAAC,MAAM,CAAC,CAAC;QACtB,QAAQ,aAAR,QAAQ,uBAAR,QAAQ,CAAG,MAAM,CAAC,CAAC;IACrB,CAAC,EACD,CAAC,QAAQ,CAAC,CACX,CAAC;IAEF,OAAO,CACL,wBAAC,2BAAkB,IAAC,QAAQ,QAAC,KAAK,mBAChC,uBAAC,uBAAc,aACb,gBAAgB,QAChB,KAAK,QACL,KAAK,EAAE,UAAU,EACjB,QAAQ,EAAE,YAAY,EACtB,YAAY,EAAE;oBACZ,aAAa,CAAC,EAAE,CAAC,CAAC;oBAClB,QAAQ,aAAR,QAAQ,uBAAR,QAAQ,CAAG,EAAE,CAAC,CAAC;gBACjB,CAAC,EACD,WAAW,EAAC,6BAA6B,IACrC,IAAI,EACR,EACF,uBAAC,2BAAoB,IAAC,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,QAAQ,EAAE,gBAAgB,GAAI,IAC1D,CACtB,CAAC;AACJ,CAAC,CACF,CAAC;AAEF,kBAAe,8BAAsB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qoretechnologies/reqraft",
3
- "version": "0.10.8",
3
+ "version": "0.10.9",
4
4
  "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.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -56,7 +56,7 @@
56
56
  "@chromatic-com/storybook": "^5.2.1",
57
57
  "@netsells/storybook-mockdate": "^0.3.3",
58
58
  "@qoretechnologies/qlip": "^0.1.3-beta.20260622084321",
59
- "@qoretechnologies/reqore": "^0.70.5",
59
+ "@qoretechnologies/reqore": "^0.70.6",
60
60
  "@storybook/addon-docs": "^10.4.6",
61
61
  "@storybook/addon-links": "^10.4.6",
62
62
  "@storybook/addon-vitest": "^10.4.6",
@@ -143,6 +143,7 @@ export const CompactRow = memo(
143
143
  const isExpanded = useContextSelector(CompactRowContext, (v) =>
144
144
  v.expandedOptions.includes(optionName)
145
145
  );
146
+ const autoFocusNameRef = useContextSelector(CompactRowContext, (v) => v.autoFocusNameRef);
146
147
  const isHighlighted = useContextSelector(CompactRowContext, (v) =>
147
148
  v.highlightedOptions.includes(optionName)
148
149
  );
@@ -437,10 +438,13 @@ export const CompactRow = memo(
437
438
  const el = editorRef.current?.querySelector<HTMLElement>(
438
439
  'input:not([type="hidden"]):not([disabled]), textarea, [contenteditable="true"]'
439
440
  );
440
- el?.focus();
441
+ // A user-driven expand scrolls the just-opened editor into view; the
442
+ // programmatic `autoFocusFirstRequired` expand must NOT — otherwise an
443
+ // off-screen / below-the-fold form scrolls itself into view on mount.
444
+ el?.focus(autoFocusNameRef?.current === optionName ? { preventScroll: true } : undefined);
441
445
  }, 60);
442
446
  return () => window.clearTimeout(id);
443
- }, [isExpanded]);
447
+ }, [isExpanded, optionName, autoFocusNameRef]);
444
448
 
445
449
  const revertButton =
446
450
  changed ?
@@ -4402,3 +4402,172 @@ export const CompactFieldSortWithinGroups: Story = {
4402
4402
  await expect(document.querySelectorAll('.readfirst-cluster-first')).toHaveLength(2);
4403
4403
  },
4404
4404
  };
4405
+
4406
+ // `autoFocusFirstRequired` drops the user straight into the first field they
4407
+ // must fill. Here `name` is required-but-filled and `description` is
4408
+ // required-but-empty, so the engine expands the `description` row on mount and
4409
+ // its editor takes focus — no click, no DOM scraping.
4410
+ export const CompactAutoFocusFirstRequired: Story = {
4411
+ args: {
4412
+ compact: true,
4413
+ minColumnWidth: '300px',
4414
+ options: CompactSchema,
4415
+ value: CompactValue,
4416
+ groups: CompactGroups,
4417
+ autoFocusFirstRequired: true,
4418
+ },
4419
+ play: async () => {
4420
+ await _testsWaitForText('order-fulfilment');
4421
+
4422
+ // The first empty required field (`description`) auto-expands into its
4423
+ // inline editor without any interaction.
4424
+ await waitFor(
4425
+ () =>
4426
+ expect(
4427
+ document.querySelector(
4428
+ '[data-field="description"] input, [data-field="description"] textarea'
4429
+ )
4430
+ ).toBeTruthy(),
4431
+ { timeout: 10000 }
4432
+ );
4433
+
4434
+ // …and that editor receives focus (CompactRow focuses the expanded row's
4435
+ // control). `name` is required but already filled, so it is skipped.
4436
+ await waitFor(
4437
+ () => {
4438
+ const active = document.activeElement as HTMLElement | null;
4439
+ const field = document.querySelector('[data-field="description"]');
4440
+ expect(!!active && !!field && field.contains(active)).toBe(true);
4441
+ },
4442
+ { timeout: 10000 }
4443
+ );
4444
+
4445
+ // The already-satisfied required field is NOT expanded/focused.
4446
+ const nameField = document.querySelector('[data-field="name"]');
4447
+ expect(nameField?.contains(document.activeElement)).toBeFalsy();
4448
+ },
4449
+ };
4450
+
4451
+ // A required field can be FILLED yet INVALID — here `endpoint` has a value that
4452
+ // fails its own `validation_regex` (it must be an http(s) URL). It still "needs
4453
+ // attention", so autofocus must land on IT and not skip ahead to the empty
4454
+ // `description` (which the old empty-only selector would have chosen). This is
4455
+ // the regression guard for the "filled-but-invalid" drift.
4456
+ const CompactInvalidFilledSchema: Record<string, TCompactField> = {
4457
+ endpoint: {
4458
+ type: 'string',
4459
+ ui_type: 'string',
4460
+ display_name: 'Endpoint URL',
4461
+ required: true,
4462
+ group: 'info',
4463
+ // Real format constraint: the value must be an http(s) URL. `validation_regex`
4464
+ // is read by the engine's own validation (helpers/validations.ts); it's not on
4465
+ // the base schema type, so cast.
4466
+ validation_regex: '^https?://',
4467
+ } as TCompactField,
4468
+ description: {
4469
+ type: 'string',
4470
+ ui_type: 'string',
4471
+ display_name: 'Description',
4472
+ required: true,
4473
+ group: 'general',
4474
+ },
4475
+ };
4476
+
4477
+ const CompactInvalidFilledValue: IOptions = {
4478
+ endpoint: { type: 'string', value: 'bad-endpoint' }, // filled, but not an http(s) URL → fails validation_regex
4479
+ // description left empty
4480
+ };
4481
+
4482
+ export const CompactAutoFocusTargetsInvalidFilledField: Story = {
4483
+ args: {
4484
+ compact: true,
4485
+ minColumnWidth: '300px',
4486
+ options: CompactInvalidFilledSchema,
4487
+ value: CompactInvalidFilledValue,
4488
+ groups: CompactGroups,
4489
+ autoFocusFirstRequired: true,
4490
+ },
4491
+ play: async () => {
4492
+ await _testsWaitForText('Endpoint URL');
4493
+
4494
+ // Sanity: the value genuinely fails the field's own validation (it isn't an
4495
+ // http(s) URL), using the very same validator the engine runs — so this is a
4496
+ // real filled-but-INVALID field, not a synthetically-flagged one.
4497
+ expect(validateField('string', 'bad-endpoint', { validation_regex: '^https?://' })).toBe(false);
4498
+
4499
+ // `endpoint` has a value but is invalid, so it needs attention and must be
4500
+ // the target — even though `description` is the empty required field the old
4501
+ // empty-only logic would have picked.
4502
+ await waitFor(
4503
+ () =>
4504
+ expect(
4505
+ document.querySelector('[data-field="endpoint"] input, [data-field="endpoint"] textarea')
4506
+ ).toBeTruthy(),
4507
+ { timeout: 10000 }
4508
+ );
4509
+
4510
+ await waitFor(
4511
+ () => {
4512
+ const active = document.activeElement as HTMLElement | null;
4513
+ const field = document.querySelector('[data-field="endpoint"]');
4514
+ expect(!!active && !!field && field.contains(active)).toBe(true);
4515
+ },
4516
+ { timeout: 10000 }
4517
+ );
4518
+
4519
+ // The empty `description` is NOT the one expanded/focused.
4520
+ const descField = document.querySelector('[data-field="description"]');
4521
+ expect(descField?.contains(document.activeElement)).toBeFalsy();
4522
+ },
4523
+ };
4524
+
4525
+ // DEMO / manual-test story: the first "needs attention" field is a BOOLEAN,
4526
+ // whose compact editor is a `<div tabindex=0>` (ReqoreCheckbox) — NOT an
4527
+ // input/textarea/contenteditable that CompactRow's focus selector matches. Both
4528
+ // `enabled` and `name` are required and unset, so both need attention; `enabled`
4529
+ // is first. Open this in Storybook to see the UX: autofocus TARGETS the boolean
4530
+ // (its row expands) and does NOT skip ahead to the focusable `name` field — but
4531
+ // no element inside actually receives keyboard focus, so the caret is left
4532
+ // nowhere. (This is the non-text-editor focus gap; kept as a playground rather
4533
+ // than a hard assertion until we decide how CompactRow should focus such rows.)
4534
+ const CompactNonFocusableFirstSchema: Record<string, TCompactField> = {
4535
+ enabled: {
4536
+ type: 'bool',
4537
+ ui_type: 'bool',
4538
+ display_name: 'Enabled',
4539
+ short_desc: 'A boolean — the first field that needs attention',
4540
+ required: true,
4541
+ preselected: true,
4542
+ group: 'info',
4543
+ },
4544
+ name: {
4545
+ type: 'string',
4546
+ ui_type: 'string',
4547
+ display_name: 'Name',
4548
+ short_desc: 'A focusable text field — comes second',
4549
+ required: true,
4550
+ preselected: true,
4551
+ group: 'general',
4552
+ },
4553
+ };
4554
+
4555
+ const CompactNonFocusableFirstValue: IOptions = {}; // both unset → both need attention
4556
+
4557
+ export const CompactAutoFocusNonFocusableFirstField: Story = {
4558
+ args: {
4559
+ compact: true,
4560
+ minColumnWidth: '300px',
4561
+ options: CompactNonFocusableFirstSchema,
4562
+ value: CompactNonFocusableFirstValue,
4563
+ groups: CompactGroups,
4564
+ autoFocusFirstRequired: true,
4565
+ },
4566
+ play: async () => {
4567
+ // Smoke: both required-but-unset fields render (both land in "needs
4568
+ // attention"), with the boolean first. Focus behaviour is intentionally left
4569
+ // for manual observation — see the note above.
4570
+ await _testsWaitForText('Enabled');
4571
+ await _testsWaitForText('Name');
4572
+ },
4573
+ };
@@ -94,6 +94,7 @@ import {
94
94
  import { OptionFieldMessages } from './OptionFieldMessages';
95
95
  import { OptionsHelpDialog } from './OptionsHelpDialog';
96
96
  import {
97
+ getFirstAttentionOptionName,
97
98
  getOptionGroup,
98
99
  getOptionGroupLabel,
99
100
  getReadFirstBucket,
@@ -564,6 +565,22 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
564
565
  * plumbing for the inherit_props scope-forwarding contract.
565
566
  */
566
567
  inheritedFromParent?: Record<string, unknown>;
568
+
569
+ /**
570
+ * Opt-in: on mount, drop the user straight into the first field they must
571
+ * fill — the first empty, focusable field (in schema/sort order) that is
572
+ * `required` or a member of a still-unsatisfied one-of `required_groups`
573
+ * group. Disabled, readonly, read-only-form, and dependency-locked fields are
574
+ * skipped. In compact (read-first) mode the target row is expanded through
575
+ * the engine's own `expandedOptions` state, so its editor gains focus with no
576
+ * DOM scraping or synthetic clicks. Strictly one-shot: it fires the first
577
+ * time the form has focusable content (so an async-loaded schema is covered)
578
+ * and then never again — a later value edit, server-driven field update, or
579
+ * in-place schema reload will not re-focus; only a remount re-arms it. It also
580
+ * never steals focus from a control the user has already moved into. No-op in
581
+ * classic (non-compact) mode. Default: off.
582
+ */
583
+ autoFocusFirstRequired?: boolean;
567
584
  }
568
585
 
569
586
  // Option types rendered full-width (IDE Options parity, commit 8e6b7781).
@@ -602,6 +619,7 @@ export const FormEngine = ({
602
619
  optionActions,
603
620
  componentOverrides,
604
621
  inheritedFromParent,
622
+ autoFocusFirstRequired,
605
623
  ...rest
606
624
  }: IFormEngineProps) => {
607
625
  const [options, setOptions] = useState<IQorusFormSchema | undefined>(rest?.options || undefined);
@@ -1517,6 +1535,94 @@ export const FormEngine = ({
1517
1535
  [expandMode]
1518
1536
  );
1519
1537
 
1538
+ // --- First-attention-field autofocus (opt-in) -----------------------------
1539
+ // With `autoFocusFirstRequired`, drop the user straight into the first field
1540
+ // they must fix. We reuse the engine's own ordering (`availableOptions`), the
1541
+ // very same `getOptionBucket` the status boxes use to decide "needs attention"
1542
+ // (so we cover empty-required, unsatisfied one-of, AND filled-but-invalid rows
1543
+ // without ever drifting from what the user sees), and dependency gating
1544
+ // (`dependencyLockedNames`), then expand the target row through
1545
+ // `expandedOptions` — the row's editor-focus effect does the rest. No DOM
1546
+ // scraping, no synthetic clicks, no polling.
1547
+ //
1548
+ // Strictly one-shot: it fires the first time the form has focusable content
1549
+ // (so an async-loaded schema is still covered — the empty first pass doesn't
1550
+ // count), then never again for the life of this instance. A server-driven
1551
+ // field update or an in-place schema reload will NOT re-grab focus; only a
1552
+ // genuine remount (a fresh instance) auto-focuses again, which is the intended
1553
+ // on-mount behaviour.
1554
+ const hasAutoFocusedRef = useRef(false);
1555
+ // The field expanded programmatically for autofocus. A ref (not state) so
1556
+ // CompactRow's 60ms focus timer reads the current value regardless of render
1557
+ // batching; CompactRow focuses this one with `preventScroll` so an off-screen
1558
+ // (or below-the-fold) form is never scrolled into view on mount.
1559
+ const autoFocusNameRef = useRef<string | undefined>(undefined);
1560
+ useEffect(() => {
1561
+ if (!autoFocusFirstRequired || !compact || !options) {
1562
+ return;
1563
+ }
1564
+ if (hasAutoFocusedRef.current) {
1565
+ return;
1566
+ }
1567
+
1568
+ const orderedNames = Object.keys(availableOptions);
1569
+ if (!orderedNames.length) {
1570
+ return;
1571
+ }
1572
+
1573
+ // Never grab focus from a control the user is already in — including a field
1574
+ // *inside* this form. If a slow/async schema lets the user start typing
1575
+ // before this first-required scan runs, we must not steal their caret. On a
1576
+ // clean mount focus rests on the body, so the intended "drop into the first
1577
+ // field" still fires. Bailing here leaves `hasAutoFocusedRef` false, so it
1578
+ // retries once focus is free.
1579
+ const active = document.activeElement as HTMLElement | null;
1580
+ if (active && active !== document.body) {
1581
+ return;
1582
+ }
1583
+
1584
+ const target = getFirstAttentionOptionName(orderedNames, (fieldName) => {
1585
+ const schema = options[fieldName];
1586
+ if (!schema) {
1587
+ return undefined;
1588
+ }
1589
+ return {
1590
+ focusable:
1591
+ !readOnly &&
1592
+ !schema.disabled &&
1593
+ !(schema as { readonly?: boolean }).readonly &&
1594
+ !dependencyLockedNames.includes(fieldName),
1595
+ // Single source of truth: the same bucket the read-first status boxes
1596
+ // show, so a filled-but-invalid required row is a target too.
1597
+ needsAttention: getOptionBucket(fieldName) === 'attention',
1598
+ };
1599
+ });
1600
+
1601
+ // Mark done even when nothing needs attention, so later value edits or an
1602
+ // in-place schema reload never re-scan or re-focus. Only a remount arms it
1603
+ // again.
1604
+ hasAutoFocusedRef.current = true;
1605
+
1606
+ if (target) {
1607
+ // Set the ref before the state update so CompactRow's focus timer sees it.
1608
+ autoFocusNameRef.current = target;
1609
+ setExpandedOptions((prev) =>
1610
+ prev.includes(target) ? prev
1611
+ : expandMode === 'multi' ? [...prev, target]
1612
+ : [target]
1613
+ );
1614
+ }
1615
+ }, [
1616
+ autoFocusFirstRequired,
1617
+ compact,
1618
+ options,
1619
+ availableOptions,
1620
+ readOnly,
1621
+ dependencyLockedNames,
1622
+ getOptionBucket,
1623
+ expandMode,
1624
+ ]);
1625
+
1520
1626
  // Read-first completion summary (how many shown options have a value set),
1521
1627
  // surfaced as a progress meter at the top of the compact form.
1522
1628
  const readFirstCompletion = useMemo(
@@ -1871,6 +1977,7 @@ export const FormEngine = ({
1871
1977
  showFieldTypes,
1872
1978
  showAllDescriptions,
1873
1979
  expandedOptions,
1980
+ autoFocusNameRef,
1874
1981
  highlightedOptions,
1875
1982
  flashedOptions,
1876
1983
  infoPanelOverrides,
@@ -1915,6 +2022,7 @@ export const FormEngine = ({
1915
2022
  showFieldTypes,
1916
2023
  showAllDescriptions,
1917
2024
  expandedOptions,
2025
+ autoFocusNameRef,
1918
2026
  highlightedOptions,
1919
2027
  flashedOptions,
1920
2028
  infoPanelOverrides,
@@ -34,6 +34,12 @@ export interface ICompactRowContext {
34
34
  // display name in the read-first chip.
35
35
  templates?: IReqoreFormTemplates;
36
36
  expandedOptions: string[];
37
+ // Name of the field the engine expanded programmatically for
38
+ // `autoFocusFirstRequired`. CompactRow focuses this one with `preventScroll`
39
+ // so an off-screen / below-the-fold form is never scrolled into view on
40
+ // mount. A ref (not state) so it's current when the 60ms focus timer fires,
41
+ // regardless of render batching.
42
+ autoFocusNameRef?: MutableRefObject<string | undefined>;
37
43
  highlightedOptions: string[];
38
44
  flashedOptions: string[];
39
45
  infoPanelOverrides: Record<string, boolean>;
@@ -502,3 +502,45 @@ export const getReadFirstCompletion = (
502
502
 
503
503
  return { total, set, pct };
504
504
  };
505
+
506
+ /** What the "first field to fix" selector needs to know about one field.
507
+ * Kept deliberately minimal — both verdicts are supplied by the caller — so the
508
+ * selector is decoupled from the engine's value/schema shapes and unit-testable
509
+ * in isolation. */
510
+ export interface IFirstAttentionFieldMeta {
511
+ /** Whether the field can actually receive focus — the caller folds in
512
+ * disabled / readonly / read-only-form / unmet-dependency gating here. */
513
+ focusable: boolean;
514
+ /** Whether the form considers this field to "need attention". The caller
515
+ * supplies this from the SAME `getOptionBucket` the read-first status boxes
516
+ * use, so it covers every attention case — empty-required, an unsatisfied
517
+ * one-of group, AND a filled-but-invalid value — and the autofocus target can
518
+ * never drift from the visible needs-attention set. */
519
+ needsAttention: boolean;
520
+ }
521
+
522
+ /**
523
+ * Pick the first field the user must fix: the first focusable field — in the
524
+ * supplied (already ordered) name list — that the form buckets as "needs
525
+ * attention".
526
+ *
527
+ * Pure and side-effect free. It deliberately does NOT re-derive emptiness /
528
+ * required / validity itself; the caller passes `needsAttention` straight from
529
+ * the engine's own `getOptionBucket`, so there is a single source of truth and
530
+ * the "focus the first field to fix" affordance can never disagree with the
531
+ * per-row status the user sees (including filled-but-invalid rows). Returns
532
+ * `undefined` when nothing needs attention.
533
+ */
534
+ export const getFirstAttentionOptionName = (
535
+ orderedNames: string[],
536
+ getFieldMeta: (name: string) => IFirstAttentionFieldMeta | undefined
537
+ ): string | undefined => {
538
+ for (const name of orderedNames) {
539
+ const meta = getFieldMeta(name);
540
+ if (meta?.focusable && meta.needsAttention) {
541
+ return name;
542
+ }
543
+ }
544
+
545
+ return undefined;
546
+ };
@@ -0,0 +1,90 @@
1
+ import { ReqoreControlGroup } from '@qoretechnologies/reqore';
2
+ import { StoryObj } from '@storybook/react-vite';
3
+ import { useState } from 'react';
4
+ import { expect, fn, userEvent, waitFor, within } from 'storybook/test';
5
+
6
+ import { StoryMeta } from '../../../../types';
7
+ import { ReqraftBinaryFormField } from './Binary';
8
+
9
+ const meta = {
10
+ component: ReqraftBinaryFormField,
11
+ title: 'Components/Form/Binary',
12
+ args: {
13
+ onChange: fn(),
14
+ 'aria-label': 'Binary',
15
+ },
16
+ render(args) {
17
+ const [value, setValue] = useState(args.value);
18
+
19
+ return (
20
+ <ReqoreControlGroup>
21
+ <ReqraftBinaryFormField
22
+ {...args}
23
+ value={value}
24
+ onChange={(value) => {
25
+ args.onChange?.(value);
26
+ setValue(value);
27
+ }}
28
+ />
29
+ </ReqoreControlGroup>
30
+ );
31
+ },
32
+ } as StoryMeta<typeof ReqraftBinaryFormField>;
33
+
34
+ export default meta;
35
+ type Story = StoryObj<typeof meta>;
36
+
37
+ export const WithValue: Story = {
38
+ args: {
39
+ value: 'cGFzc3dvcmQ=',
40
+ },
41
+ async play({ canvasElement }) {
42
+ const canvas = within(canvasElement);
43
+ const textarea = canvas.getByLabelText('Binary');
44
+
45
+ await expect(textarea).toBeInTheDocument();
46
+ await expect(textarea).toHaveValue('cGFzc3dvcmQ=');
47
+ },
48
+ };
49
+
50
+ export const TypingUpdatesValue: Story = {
51
+ async play({ canvasElement, args }) {
52
+ const canvas = within(canvasElement);
53
+ const textarea = canvas.getByLabelText('Binary');
54
+
55
+ await userEvent.type(textarea, 'YWJj');
56
+ await expect(textarea).toHaveValue('YWJj');
57
+ await waitFor(() => expect(args.onChange).toHaveBeenLastCalledWith('YWJj'), {
58
+ timeout: 500,
59
+ });
60
+ },
61
+ };
62
+
63
+ export const UploadEncodesFileAsBase64: Story = {
64
+ async play({ canvasElement, args }) {
65
+ const file = new File(['hello'], 'test.bin', { type: 'application/octet-stream' });
66
+ // the hidden file input rendered by the reused ReqraftFileFormField drop zone
67
+ const input = canvasElement.querySelector('input[type="file"]') as HTMLInputElement;
68
+
69
+ await expect(input).toBeInTheDocument();
70
+ await userEvent.upload(input, file);
71
+
72
+ // base64 of "hello" is "aGVsbG8="; the data: URL prefix must be stripped
73
+ await waitFor(() => expect(args.onChange).toHaveBeenLastCalledWith('aGVsbG8='), {
74
+ timeout: 1000,
75
+ });
76
+ },
77
+ };
78
+
79
+ export const Disabled: Story = {
80
+ args: {
81
+ value: 'cGFzc3dvcmQ=',
82
+ disabled: true,
83
+ },
84
+ async play({ canvasElement }) {
85
+ const canvas = within(canvasElement);
86
+ const textarea = canvas.getByLabelText('Binary');
87
+
88
+ await expect(textarea).toBeDisabled();
89
+ },
90
+ };
@@ -0,0 +1,81 @@
1
+ import { ReqoreControlGroup, ReqoreTextarea } from '@qoretechnologies/reqore';
2
+ import { IReqoreTextareaProps } from '@qoretechnologies/reqore/dist/components/Textarea';
3
+ import { ChangeEvent, memo, useCallback, useEffect, useState } from 'react';
4
+ import { useDebounce } from 'react-use';
5
+ import { IFileFormFieldValue, ReqraftFileFormField } from '../file/File';
6
+
7
+ export interface IReqraftBinaryFormFieldProps extends Omit<IReqoreTextareaProps, 'onChange' | 'value'> {
8
+ value?: string;
9
+ onChange?: (value: string) => void;
10
+ }
11
+
12
+ /**
13
+ * Strip a `data:<mime-type>;base64,` prefix so the emitted value carries raw base64, matching the
14
+ * canonical binary wire form the Qorus server expects. `ReqraftFileFormField` reads uploads with
15
+ * `readAsDataURL`, so its `content` always carries that prefix.
16
+ */
17
+ const stripDataUrlPrefix = (dataUrl: string): string => {
18
+ const commaIndex = dataUrl.indexOf(',');
19
+ return commaIndex === -1 ? dataUrl : dataUrl.slice(commaIndex + 1);
20
+ };
21
+
22
+ /**
23
+ * A field for binary values encoded as base64. The value can be typed or pasted directly into the
24
+ * textarea, or populated by uploading a file — the upload reuses {@link ReqraftFileFormField} (the
25
+ * shared Reqraft file picker / drop zone) rather than a bespoke dropzone; its data-URL content is
26
+ * decoded to raw base64 for the field value.
27
+ */
28
+ export const ReqraftBinaryFormField = memo(
29
+ ({ value, onChange, ...rest }: IReqraftBinaryFormFieldProps) => {
30
+ const [localValue, setLocalValue] = useState<string>(value ?? '');
31
+
32
+ useEffect(() => {
33
+ if (value !== localValue) {
34
+ setLocalValue(value ?? '');
35
+ }
36
+ }, [value]);
37
+
38
+ useDebounce(
39
+ () => {
40
+ if (localValue !== value) {
41
+ onChange?.(localValue);
42
+ }
43
+ },
44
+ 100,
45
+ [localValue, onChange]
46
+ );
47
+
48
+ const handleChange = useCallback((event: ChangeEvent<HTMLTextAreaElement>): void => {
49
+ setLocalValue(event.target.value);
50
+ }, []);
51
+
52
+ const handleFileChange = useCallback(
53
+ (file?: IFileFormFieldValue) => {
54
+ const base64 = stripDataUrlPrefix(file?.content ?? '');
55
+ setLocalValue(base64);
56
+ onChange?.(base64);
57
+ },
58
+ [onChange]
59
+ );
60
+
61
+ return (
62
+ <ReqoreControlGroup vertical fluid>
63
+ <ReqoreTextarea
64
+ scaleWithContent
65
+ fluid
66
+ value={localValue}
67
+ onChange={handleChange}
68
+ onClearClick={() => {
69
+ setLocalValue('');
70
+ onChange?.('');
71
+ }}
72
+ placeholder='Base64-encoded binary value'
73
+ {...rest}
74
+ />
75
+ <ReqraftFileFormField disabled={rest.disabled} onChange={handleFileChange} />
76
+ </ReqoreControlGroup>
77
+ );
78
+ }
79
+ );
80
+
81
+ export default ReqraftBinaryFormField;