@visns-studio/visns-components 6.3.4 → 6.3.6
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.
- package/package.json +1 -1
- package/src/components/Field.jsx +27 -0
- package/src/components/Form.jsx +431 -123
- package/src/components/MultiCheckbox.jsx +82 -6
- package/src/components/SumEqualsSummary.jsx +147 -0
- package/src/components/styles/MultiCheckbox.module.scss +58 -1
- package/src/components/styles/SumEqualsSummary.module.scss +38 -0
package/package.json
CHANGED
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
"react-dom": "^17.0.0 || ^18.0.0"
|
|
92
92
|
},
|
|
93
93
|
"name": "@visns-studio/visns-components",
|
|
94
|
-
"version": "6.3.
|
|
94
|
+
"version": "6.3.6",
|
|
95
95
|
"description": "Various packages to assist in the development of our Custom Applications.",
|
|
96
96
|
"main": "src/index.js",
|
|
97
97
|
"files": [
|
package/src/components/Field.jsx
CHANGED
|
@@ -139,6 +139,11 @@ function Field({
|
|
|
139
139
|
onDeleteJsonTableRow,
|
|
140
140
|
onChangeToggle,
|
|
141
141
|
onFileDownload,
|
|
142
|
+
// Renders another of the form's fields inside this one (today: inside a
|
|
143
|
+
// `multi-checkbox-ajax` option card, via `settings.optionFields`).
|
|
144
|
+
// Supplied by Form so the nested field keeps the form's own state,
|
|
145
|
+
// handlers and validation — see Form.renderCompanionField.
|
|
146
|
+
renderOptionField,
|
|
142
147
|
setFormData,
|
|
143
148
|
settings,
|
|
144
149
|
style,
|
|
@@ -1161,6 +1166,13 @@ function Field({
|
|
|
1161
1166
|
onValueChange={(values) => {
|
|
1162
1167
|
onChangeNumberFormat(values, settings.id);
|
|
1163
1168
|
}}
|
|
1169
|
+
// Numeric inputs ignored both of these: a
|
|
1170
|
+
// `placeholder` in the config never rendered, and a
|
|
1171
|
+
// read-only field stayed typable. The placeholder is
|
|
1172
|
+
// also how a `sumEquals` group offers its remainder
|
|
1173
|
+
// as a hint (see SumEqualsSummary).
|
|
1174
|
+
placeholder={settings.placeholder}
|
|
1175
|
+
disabled={isFieldReadOnly}
|
|
1164
1176
|
thousandSeparator={
|
|
1165
1177
|
settings.hasOwnProperty('thousandSeperator')
|
|
1166
1178
|
? settings.thousandSeperator
|
|
@@ -2182,6 +2194,21 @@ function Field({
|
|
|
2182
2194
|
isEditable={settings.isEditable ?? false}
|
|
2183
2195
|
creatableConfig={settings.creatableConfig || {}}
|
|
2184
2196
|
editableConfig={settings.editableConfig || {}}
|
|
2197
|
+
// The checkbox renderer previously ignored the field's
|
|
2198
|
+
// read-only state, leaving `readOnlyWhen` configs
|
|
2199
|
+
// claiming a lock the UI didn't enforce — the same gap
|
|
2200
|
+
// the radio renderers had.
|
|
2201
|
+
disabled={isFieldReadOnly}
|
|
2202
|
+
optionFields={settings.optionFields}
|
|
2203
|
+
// A read-only host locks the fields in its cards too
|
|
2204
|
+
renderOptionField={
|
|
2205
|
+
renderOptionField
|
|
2206
|
+
? (fieldId) =>
|
|
2207
|
+
renderOptionField(fieldId, {
|
|
2208
|
+
readOnly: isFieldReadOnly,
|
|
2209
|
+
})
|
|
2210
|
+
: undefined
|
|
2211
|
+
}
|
|
2185
2212
|
style={style}
|
|
2186
2213
|
/>
|
|
2187
2214
|
);
|
package/src/components/Form.jsx
CHANGED
|
@@ -18,6 +18,10 @@ import '@visns-studio/visns-datagrid-enterprise/index.css';
|
|
|
18
18
|
import CustomFetch from './Fetch';
|
|
19
19
|
import Download from './Download';
|
|
20
20
|
import Field from './Field';
|
|
21
|
+
import SumEqualsSummary, {
|
|
22
|
+
computeSumEqualsState,
|
|
23
|
+
sumEqualsHintFor,
|
|
24
|
+
} from './SumEqualsSummary';
|
|
21
25
|
import Table from './DataGrid';
|
|
22
26
|
|
|
23
27
|
import styles from './styles/Form.module.scss';
|
|
@@ -91,6 +95,56 @@ const evaluateDisableWhen = (rule, data) => {
|
|
|
91
95
|
}
|
|
92
96
|
};
|
|
93
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Field visibility rules (`show`).
|
|
100
|
+
*
|
|
101
|
+
* A field carries `show: [{ id: '<other field id>', <condition> }]`, meaning
|
|
102
|
+
* "when MY value satisfies this condition, reveal the named field; otherwise
|
|
103
|
+
* hide it". With `self: true` the roles swap: the condition is read from the
|
|
104
|
+
* NAMED field's value and applied to the field carrying the rule.
|
|
105
|
+
*
|
|
106
|
+
* Supported conditions:
|
|
107
|
+
*
|
|
108
|
+
* { value: [...] } the value equals one of the listed literals. What
|
|
109
|
+
* toggles ({ value: [true] }) and radios
|
|
110
|
+
* ({ value: [1, 2] }) use.
|
|
111
|
+
*
|
|
112
|
+
* { minSelected: n } the value is an array holding at least n entries.
|
|
113
|
+
* For the multi-* types (multi-checkbox-ajax,
|
|
114
|
+
* multi-dropdown-ajax, …) whose value is a list of
|
|
115
|
+
* selected options — e.g. reveal a pair of per-item
|
|
116
|
+
* quantity inputs only once two boxes are ticked.
|
|
117
|
+
*
|
|
118
|
+
* Rules are evaluated when the record loads (the effect keyed on
|
|
119
|
+
* `fetchTrigger`) and again on every change to the field that carries them,
|
|
120
|
+
* so a revealed field cannot linger after the value that revealed it goes
|
|
121
|
+
* away. See also `required_rely` (required-ness that follows another field's
|
|
122
|
+
* value) and `sumEquals` (a group of numbers that must total a target).
|
|
123
|
+
*/
|
|
124
|
+
const hasShowCondition = (condition) =>
|
|
125
|
+
!!condition &&
|
|
126
|
+
(condition.minSelected !== undefined ||
|
|
127
|
+
(Array.isArray(condition.value) && condition.value.length > 0));
|
|
128
|
+
|
|
129
|
+
const matchesShowCondition = (condition, value) => {
|
|
130
|
+
if (!condition) {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (condition.minSelected !== undefined) {
|
|
135
|
+
return (
|
|
136
|
+
Array.isArray(value) &&
|
|
137
|
+
value.length >= Number(condition.minSelected)
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (Array.isArray(condition.value)) {
|
|
142
|
+
return condition.value.includes(value);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return false;
|
|
146
|
+
};
|
|
147
|
+
|
|
94
148
|
function Form({
|
|
95
149
|
ajaxSetting,
|
|
96
150
|
api,
|
|
@@ -465,18 +519,71 @@ function Form({
|
|
|
465
519
|
});
|
|
466
520
|
};
|
|
467
521
|
|
|
522
|
+
/**
|
|
523
|
+
* Re-evaluate this field's `show` rules against the value it has just
|
|
524
|
+
* been given. The other two change handlers do this from the DOM
|
|
525
|
+
* node's `data-show`, which a select / multi-checkbox never emits, so
|
|
526
|
+
* without this a rule on one of those types would only ever run at
|
|
527
|
+
* load time — the reveal would not follow the user's clicks.
|
|
528
|
+
*/
|
|
529
|
+
const applyShowConditions = (newValue) => {
|
|
530
|
+
const sourceField = formSettings.fields.find((f) => f.id === id);
|
|
531
|
+
const conditions = sourceField?.show || [];
|
|
532
|
+
|
|
533
|
+
if (conditions.length === 0) {
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const _fields = [...formSettings.fields];
|
|
538
|
+
let changed = false;
|
|
539
|
+
|
|
540
|
+
conditions.forEach((showObject) => {
|
|
541
|
+
if (!showObject?.id || !hasShowCondition(showObject)) {
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const targetIndex = _fields.findIndex(
|
|
546
|
+
(f) => f.id === showObject.id
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
if (targetIndex < 0) {
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const shouldShow = matchesShowCondition(showObject, newValue);
|
|
554
|
+
const target = showObject.self
|
|
555
|
+
? _fields.find((f) => f.id === id)
|
|
556
|
+
: _fields[targetIndex];
|
|
557
|
+
|
|
558
|
+
if (target) {
|
|
559
|
+
target.hide = !shouldShow;
|
|
560
|
+
changed = true;
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
if (changed && updateForm) {
|
|
565
|
+
updateForm((prevState) => ({
|
|
566
|
+
...prevState,
|
|
567
|
+
fields: _fields,
|
|
568
|
+
}));
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
|
|
468
572
|
// // Switch cases to handle different actions
|
|
469
573
|
switch (action.action) {
|
|
470
574
|
case 'select-option':
|
|
471
575
|
case 'create-option': // New case to handle create-option
|
|
472
576
|
case 'deselect-option': // Handle unchecking tags
|
|
473
577
|
handleSelectOption();
|
|
578
|
+
applyShowConditions(inputValue);
|
|
474
579
|
break;
|
|
475
580
|
case 'clear':
|
|
476
581
|
updateFormData({ [id]: '' });
|
|
582
|
+
applyShowConditions([]);
|
|
477
583
|
break;
|
|
478
584
|
case 'remove-value':
|
|
479
585
|
updateFormData({ [id]: inputValue });
|
|
586
|
+
applyShowConditions(inputValue);
|
|
480
587
|
break;
|
|
481
588
|
}
|
|
482
589
|
};
|
|
@@ -1303,6 +1410,82 @@ function Form({
|
|
|
1303
1410
|
_inputClass[item.id] = styles.inputError;
|
|
1304
1411
|
}
|
|
1305
1412
|
}
|
|
1413
|
+
|
|
1414
|
+
/**
|
|
1415
|
+
* `sumEquals`: a set of numeric fields that must add
|
|
1416
|
+
* up to a target.
|
|
1417
|
+
*
|
|
1418
|
+
* sumEquals: {
|
|
1419
|
+
* fields: ['qty_a', 'qty_b'],
|
|
1420
|
+
* equals: 'qty_total', // field id, or a number
|
|
1421
|
+
* message: '…' // optional override
|
|
1422
|
+
* }
|
|
1423
|
+
*
|
|
1424
|
+
* Declared once, on any one of the fields in the
|
|
1425
|
+
* group. Every named field is marked in error so the
|
|
1426
|
+
* user sees which inputs to reconcile, not just a
|
|
1427
|
+
* message.
|
|
1428
|
+
*
|
|
1429
|
+
* The rule is skipped while its own field is hidden,
|
|
1430
|
+
* so a group that only appears in a revealed section
|
|
1431
|
+
* (see the `show` documentation above) cannot block an
|
|
1432
|
+
* ordinary save. While visible it also stands in for
|
|
1433
|
+
* `required` on the whole group: a blank member is as
|
|
1434
|
+
* wrong as a wrong total.
|
|
1435
|
+
*
|
|
1436
|
+
* Declaring the rule also turns on live feedback
|
|
1437
|
+
* while the operator types, all of it automatic —
|
|
1438
|
+
* no extra config, and nothing a form has to opt
|
|
1439
|
+
* into beyond `sumEquals` itself:
|
|
1440
|
+
*
|
|
1441
|
+
* - an allocation line under the group, reading
|
|
1442
|
+
* "Item Qty 40 · 25 entered · 15 remaining",
|
|
1443
|
+
* "40 of 40 allocated ✓" or "over by 5" (see
|
|
1444
|
+
* SumEqualsSummary);
|
|
1445
|
+
* - the remainder offered as the placeholder of the
|
|
1446
|
+
* group's one empty field — a hint only, never an
|
|
1447
|
+
* auto-filled value.
|
|
1448
|
+
*
|
|
1449
|
+
* `targetLabel` on the rule names the total in that
|
|
1450
|
+
* copy; it otherwise takes the label of the field
|
|
1451
|
+
* `equals` points at.
|
|
1452
|
+
*/
|
|
1453
|
+
if (item.sumEquals && item.hide !== true) {
|
|
1454
|
+
const rule = item.sumEquals;
|
|
1455
|
+
// Same computation the live summary and the
|
|
1456
|
+
// placeholder hint use, so what the operator was
|
|
1457
|
+
// told while typing is exactly what is enforced
|
|
1458
|
+
const state = computeSumEqualsState(rule, formData);
|
|
1459
|
+
|
|
1460
|
+
if (state) {
|
|
1461
|
+
const labelFor = (fieldId) =>
|
|
1462
|
+
fields.find((f) => f.id === fieldId)
|
|
1463
|
+
?.label || fieldId;
|
|
1464
|
+
const groupLabels = state.ids
|
|
1465
|
+
.map(labelFor)
|
|
1466
|
+
.join(' and ');
|
|
1467
|
+
|
|
1468
|
+
const markGroup = () =>
|
|
1469
|
+
state.ids.forEach((fieldId) => {
|
|
1470
|
+
_inputClass[fieldId] =
|
|
1471
|
+
styles.inputError;
|
|
1472
|
+
});
|
|
1473
|
+
|
|
1474
|
+
if (state.emptyIds.length > 0) {
|
|
1475
|
+
validation += `${groupLabels} are required.<br/>`;
|
|
1476
|
+
markGroup();
|
|
1477
|
+
} else if (
|
|
1478
|
+
state.hasTarget &&
|
|
1479
|
+
state.status !== 'balanced'
|
|
1480
|
+
) {
|
|
1481
|
+
validation +=
|
|
1482
|
+
(rule.message ||
|
|
1483
|
+
`${groupLabels} must add up to ${state.target} - they add up to ${state.sum}.`) +
|
|
1484
|
+
'<br/>';
|
|
1485
|
+
markGroup();
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1306
1489
|
});
|
|
1307
1490
|
|
|
1308
1491
|
setInputClass(_inputClass);
|
|
@@ -2676,9 +2859,9 @@ function Form({
|
|
|
2676
2859
|
const showConditions = field.show || [];
|
|
2677
2860
|
|
|
2678
2861
|
showConditions.forEach((showObject) => {
|
|
2679
|
-
const { id,
|
|
2862
|
+
const { id, self } = showObject;
|
|
2680
2863
|
|
|
2681
|
-
if (id != '' &&
|
|
2864
|
+
if (id != '' && hasShowCondition(showObject)) {
|
|
2682
2865
|
const targetField = formSettings.fields.find(
|
|
2683
2866
|
(f) => f.id === id
|
|
2684
2867
|
);
|
|
@@ -2695,7 +2878,10 @@ function Form({
|
|
|
2695
2878
|
valueChecker !== undefined &&
|
|
2696
2879
|
valueChecker !== null
|
|
2697
2880
|
) {
|
|
2698
|
-
const shouldShow =
|
|
2881
|
+
const shouldShow = matchesShowCondition(
|
|
2882
|
+
showObject,
|
|
2883
|
+
valueChecker
|
|
2884
|
+
);
|
|
2699
2885
|
|
|
2700
2886
|
if (self) {
|
|
2701
2887
|
field.hide = !shouldShow;
|
|
@@ -2716,6 +2902,193 @@ function Form({
|
|
|
2716
2902
|
});
|
|
2717
2903
|
}, [fetchTrigger]);
|
|
2718
2904
|
|
|
2905
|
+
/**
|
|
2906
|
+
* The `sumEquals` rule a field belongs to, wherever it was declared (the
|
|
2907
|
+
* rule sits on one member and names the whole group).
|
|
2908
|
+
*/
|
|
2909
|
+
const sumEqualsRuleFor = (fieldId) =>
|
|
2910
|
+
formSettings?.fields?.find(
|
|
2911
|
+
(f) =>
|
|
2912
|
+
f.sumEquals &&
|
|
2913
|
+
Array.isArray(f.sumEquals.fields) &&
|
|
2914
|
+
f.sumEquals.fields.includes(fieldId)
|
|
2915
|
+
)?.sumEquals || null;
|
|
2916
|
+
|
|
2917
|
+
/**
|
|
2918
|
+
* Field ids a `multi-checkbox-ajax` host has claimed through
|
|
2919
|
+
* `optionFields`: those render inside the option cards, not as rows of
|
|
2920
|
+
* their own, so the main loop must skip them.
|
|
2921
|
+
*/
|
|
2922
|
+
const optionHostedFieldIds = new Set(
|
|
2923
|
+
(formSettings?.fields || []).flatMap((f) =>
|
|
2924
|
+
Array.isArray(f.optionFields) ? f.optionFields : []
|
|
2925
|
+
)
|
|
2926
|
+
);
|
|
2927
|
+
|
|
2928
|
+
/**
|
|
2929
|
+
* The field the group's allocation line renders under: the last member
|
|
2930
|
+
* that the main loop draws itself, or — when every member is nested in an
|
|
2931
|
+
* option card — the host that draws them.
|
|
2932
|
+
*/
|
|
2933
|
+
const sumEqualsAnchorId = (rule) => {
|
|
2934
|
+
const ids = Array.isArray(rule?.fields) ? rule.fields : [];
|
|
2935
|
+
const ownRows = ids.filter((id) => !optionHostedFieldIds.has(id));
|
|
2936
|
+
|
|
2937
|
+
if (ownRows.length > 0) {
|
|
2938
|
+
return ownRows[ownRows.length - 1];
|
|
2939
|
+
}
|
|
2940
|
+
|
|
2941
|
+
return (
|
|
2942
|
+
(formSettings?.fields || []).find(
|
|
2943
|
+
(f) =>
|
|
2944
|
+
Array.isArray(f.optionFields) &&
|
|
2945
|
+
ids.some((id) => f.optionFields.includes(id))
|
|
2946
|
+
)?.id || null
|
|
2947
|
+
);
|
|
2948
|
+
};
|
|
2949
|
+
|
|
2950
|
+
/**
|
|
2951
|
+
* The allocation line to draw after `item`, or null. Rendered once per
|
|
2952
|
+
* group, under the group's last field (or under the checkbox host whose
|
|
2953
|
+
* cards contain them), and never while that anchor is hidden.
|
|
2954
|
+
*/
|
|
2955
|
+
const renderSumEqualsSummary = (item) => {
|
|
2956
|
+
const rules = (formSettings?.fields || [])
|
|
2957
|
+
.map((f) => f.sumEquals)
|
|
2958
|
+
.filter(Boolean)
|
|
2959
|
+
.filter((rule) => sumEqualsAnchorId(rule) === item.id);
|
|
2960
|
+
|
|
2961
|
+
if (rules.length === 0) {
|
|
2962
|
+
return null;
|
|
2963
|
+
}
|
|
2964
|
+
|
|
2965
|
+
return rules.map((rule, i) => {
|
|
2966
|
+
// A group whose fields are all hidden says nothing: the section
|
|
2967
|
+
// it belongs to has not been revealed yet
|
|
2968
|
+
const anyVisible = (rule.fields || []).some(
|
|
2969
|
+
(id) =>
|
|
2970
|
+
formSettings.fields.find((f) => f.id === id)?.hide !== true
|
|
2971
|
+
);
|
|
2972
|
+
|
|
2973
|
+
if (!anyVisible) {
|
|
2974
|
+
return null;
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2977
|
+
return (
|
|
2978
|
+
<SumEqualsSummary
|
|
2979
|
+
key={`${item.id}-sumequals-${i}`}
|
|
2980
|
+
rule={rule}
|
|
2981
|
+
formData={formData}
|
|
2982
|
+
fields={formSettings.fields}
|
|
2983
|
+
/>
|
|
2984
|
+
);
|
|
2985
|
+
});
|
|
2986
|
+
};
|
|
2987
|
+
|
|
2988
|
+
/**
|
|
2989
|
+
* `item`, with the remainder offered as its placeholder when it is the
|
|
2990
|
+
* one field of its `sumEquals` group still empty. Returns the original
|
|
2991
|
+
* object when there is nothing to add, so React sees no new prop.
|
|
2992
|
+
*/
|
|
2993
|
+
const withSumEqualsHint = (item) => {
|
|
2994
|
+
const rule = sumEqualsRuleFor(item.id);
|
|
2995
|
+
|
|
2996
|
+
if (!rule) {
|
|
2997
|
+
return item;
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
const hint = sumEqualsHintFor(item.id, rule, formData);
|
|
3001
|
+
|
|
3002
|
+
if (hint === undefined) {
|
|
3003
|
+
return item;
|
|
3004
|
+
}
|
|
3005
|
+
|
|
3006
|
+
return { ...item, placeholder: hint };
|
|
3007
|
+
};
|
|
3008
|
+
|
|
3009
|
+
/**
|
|
3010
|
+
* One `<Field>`, wired to this form's state and handlers. Single factory
|
|
3011
|
+
* so a field rendered inside an option card (see renderCompanionField)
|
|
3012
|
+
* is wired identically to one rendered on a row of its own.
|
|
3013
|
+
*/
|
|
3014
|
+
const renderFormField = (item, key) => (
|
|
3015
|
+
<Field
|
|
3016
|
+
api={api}
|
|
3017
|
+
autocompleteCallback={autocompleteSelect}
|
|
3018
|
+
childDropdownCallback={childDropdownCallback}
|
|
3019
|
+
fetchData={fetchData}
|
|
3020
|
+
formData={formData}
|
|
3021
|
+
formSettings={formSettings}
|
|
3022
|
+
inputClass={inputClass}
|
|
3023
|
+
inputValue={renderInputValue(item, formData)}
|
|
3024
|
+
key={key}
|
|
3025
|
+
mapbox={mapbox}
|
|
3026
|
+
onChange={handleChange}
|
|
3027
|
+
onChangeCanvas={handleChangeCanvas}
|
|
3028
|
+
onChangeCheckbox={handleChangeCheckbox}
|
|
3029
|
+
onChangeCheckboxManual={handleChangeCheckboxManual}
|
|
3030
|
+
onChangeColour={handleChangeColour}
|
|
3031
|
+
onChangeDate={handleChangeDate}
|
|
3032
|
+
onChangeNumberFormat={handleChangeNumberFormat}
|
|
3033
|
+
onChangeRicheditor={handleChangeRicheditor}
|
|
3034
|
+
onChangeSignature={handleChangeSignature}
|
|
3035
|
+
onChangeSelect={handleChangeSelect}
|
|
3036
|
+
onChangeToggle={handleChangeToggle}
|
|
3037
|
+
onChangeJsonTable={handleChangeJsonTable}
|
|
3038
|
+
onAddJsonTableRow={handleAddJsonTableRow}
|
|
3039
|
+
onDeleteJsonTableRow={handleDeleteJsonTableRow}
|
|
3040
|
+
onFileDownload={handleFileDownload}
|
|
3041
|
+
renderOptionField={renderCompanionField}
|
|
3042
|
+
settings={item}
|
|
3043
|
+
setFormData={setFormData}
|
|
3044
|
+
style={style}
|
|
3045
|
+
uploadProgress={uploadProgress}
|
|
3046
|
+
userProfile={userProfile}
|
|
3047
|
+
/>
|
|
3048
|
+
);
|
|
3049
|
+
|
|
3050
|
+
/**
|
|
3051
|
+
* Render one of this form's fields somewhere other than its own row —
|
|
3052
|
+
* today, inside a `multi-checkbox-ajax` option card (see `optionFields`
|
|
3053
|
+
* in MultiCheckbox). It is the SAME field: same id, same formData entry,
|
|
3054
|
+
* same handlers, same validation and same `sumEquals` membership. Only
|
|
3055
|
+
* the mount point differs, so there is no second source of truth.
|
|
3056
|
+
*
|
|
3057
|
+
* @param {string} fieldId
|
|
3058
|
+
* @param {{readOnly?: boolean}} options host state to inherit
|
|
3059
|
+
*/
|
|
3060
|
+
const renderCompanionField = (fieldId, options = {}) => {
|
|
3061
|
+
const field = formSettings?.fields?.find((f) => f.id === fieldId);
|
|
3062
|
+
|
|
3063
|
+
// A field the `show` rules have not revealed stays hidden wherever it
|
|
3064
|
+
// is mounted — ticking one box must not surface the qty inputs
|
|
3065
|
+
if (!field || field.hide === true) {
|
|
3066
|
+
return null;
|
|
3067
|
+
}
|
|
3068
|
+
|
|
3069
|
+
const settings = {
|
|
3070
|
+
...withSumEqualsHint(field),
|
|
3071
|
+
// The card is the field's context ("Station A"), so the label
|
|
3072
|
+
// inside it can be short. The full label stays the group's
|
|
3073
|
+
// accessible name below.
|
|
3074
|
+
label: field.compactLabel || field.label,
|
|
3075
|
+
// Companion fields fill their card, whatever width they were
|
|
3076
|
+
// given for a row of their own
|
|
3077
|
+
size: 'full',
|
|
3078
|
+
// A read-only host locks what it contains
|
|
3079
|
+
readOnly: field.readOnly === true || options.readOnly === true,
|
|
3080
|
+
// Cards do not nest: a companion never hosts companions of
|
|
3081
|
+
// its own
|
|
3082
|
+
optionFields: undefined,
|
|
3083
|
+
};
|
|
3084
|
+
|
|
3085
|
+
return (
|
|
3086
|
+
<div role="group" aria-label={field.label}>
|
|
3087
|
+
{renderFormField(settings, `companion-${fieldId}`)}
|
|
3088
|
+
</div>
|
|
3089
|
+
);
|
|
3090
|
+
};
|
|
3091
|
+
|
|
2719
3092
|
if (type && type === 'inline') {
|
|
2720
3093
|
return (
|
|
2721
3094
|
<div className={styles.formcontainer}>
|
|
@@ -2774,67 +3147,36 @@ function Form({
|
|
|
2774
3147
|
/>
|
|
2775
3148
|
</div>
|
|
2776
3149
|
);
|
|
2777
|
-
default:
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
3150
|
+
default: {
|
|
3151
|
+
// Drawn inside a checkbox option card
|
|
3152
|
+
// instead of on a row of its own
|
|
3153
|
+
if (optionHostedFieldIds.has(item.id)) {
|
|
3154
|
+
return null;
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
const key =
|
|
3158
|
+
index +
|
|
3159
|
+
'-fields-' +
|
|
3160
|
+
(item.hasOwnProperty('key')
|
|
3161
|
+
? item.id + '-' + item.key
|
|
3162
|
+
: item.id);
|
|
3163
|
+
const summary = renderSumEqualsSummary(item);
|
|
3164
|
+
|
|
3165
|
+
return summary ? (
|
|
3166
|
+
<React.Fragment key={`${key}-group`}>
|
|
3167
|
+
{renderFormField(
|
|
3168
|
+
withSumEqualsHint(item),
|
|
3169
|
+
key
|
|
2794
3170
|
)}
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
mapbox={mapbox}
|
|
2803
|
-
onChange={handleChange}
|
|
2804
|
-
onChangeCanvas={handleChangeCanvas}
|
|
2805
|
-
onChangeCheckbox={handleChangeCheckbox}
|
|
2806
|
-
onChangeCheckboxManual={
|
|
2807
|
-
handleChangeCheckboxManual
|
|
2808
|
-
}
|
|
2809
|
-
onChangeDate={handleChangeDate}
|
|
2810
|
-
onChangeNumberFormat={
|
|
2811
|
-
handleChangeNumberFormat
|
|
2812
|
-
}
|
|
2813
|
-
onChangeRicheditor={
|
|
2814
|
-
handleChangeRicheditor
|
|
2815
|
-
}
|
|
2816
|
-
onChangeSelect={handleChangeSelect}
|
|
2817
|
-
onChangeSignature={
|
|
2818
|
-
handleChangeSignature
|
|
2819
|
-
}
|
|
2820
|
-
onChangeToggle={handleChangeToggle}
|
|
2821
|
-
onChangeJsonTable={
|
|
2822
|
-
handleChangeJsonTable
|
|
2823
|
-
}
|
|
2824
|
-
onAddJsonTableRow={
|
|
2825
|
-
handleAddJsonTableRow
|
|
2826
|
-
}
|
|
2827
|
-
onDeleteJsonTableRow={
|
|
2828
|
-
handleDeleteJsonTableRow
|
|
2829
|
-
}
|
|
2830
|
-
onFileDownload={handleFileDownload}
|
|
2831
|
-
settings={item}
|
|
2832
|
-
setFormData={setFormData}
|
|
2833
|
-
style={style}
|
|
2834
|
-
uploadProgress={uploadProgress}
|
|
2835
|
-
userProfile={userProfile}
|
|
2836
|
-
/>
|
|
3171
|
+
{summary}
|
|
3172
|
+
</React.Fragment>
|
|
3173
|
+
) : (
|
|
3174
|
+
renderFormField(
|
|
3175
|
+
withSumEqualsHint(item),
|
|
3176
|
+
key
|
|
3177
|
+
)
|
|
2837
3178
|
);
|
|
3179
|
+
}
|
|
2838
3180
|
}
|
|
2839
3181
|
})}
|
|
2840
3182
|
<div className={`${styles.formItem} ${styles.fwItem}`}>
|
|
@@ -3444,68 +3786,34 @@ function Form({
|
|
|
3444
3786
|
return null;
|
|
3445
3787
|
}
|
|
3446
3788
|
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3789
|
+
// Drawn inside a checkbox option card
|
|
3790
|
+
// instead of on a row of its own
|
|
3791
|
+
if (optionHostedFieldIds.has(item.id)) {
|
|
3792
|
+
return null;
|
|
3793
|
+
}
|
|
3794
|
+
|
|
3795
|
+
const key =
|
|
3796
|
+
index +
|
|
3797
|
+
'-fields-' +
|
|
3798
|
+
(item.hasOwnProperty('key')
|
|
3799
|
+
? item.id + '-' + item.key
|
|
3800
|
+
: item.id);
|
|
3801
|
+
const summary =
|
|
3802
|
+
renderSumEqualsSummary(item);
|
|
3803
|
+
|
|
3804
|
+
return summary ? (
|
|
3805
|
+
<React.Fragment key={`${key}-group`}>
|
|
3806
|
+
{renderFormField(
|
|
3807
|
+
withSumEqualsHint(item),
|
|
3808
|
+
key
|
|
3463
3809
|
)}
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
mapbox={mapbox}
|
|
3472
|
-
onChange={handleChange}
|
|
3473
|
-
onChangeCanvas={handleChangeCanvas}
|
|
3474
|
-
onChangeCheckbox={
|
|
3475
|
-
handleChangeCheckbox
|
|
3476
|
-
}
|
|
3477
|
-
onChangeCheckboxManual={
|
|
3478
|
-
handleChangeCheckboxManual
|
|
3479
|
-
}
|
|
3480
|
-
onChangeColour={handleChangeColour}
|
|
3481
|
-
onChangeDate={handleChangeDate}
|
|
3482
|
-
onChangeNumberFormat={
|
|
3483
|
-
handleChangeNumberFormat
|
|
3484
|
-
}
|
|
3485
|
-
onChangeRicheditor={
|
|
3486
|
-
handleChangeRicheditor
|
|
3487
|
-
}
|
|
3488
|
-
onChangeSignature={
|
|
3489
|
-
handleChangeSignature
|
|
3490
|
-
}
|
|
3491
|
-
onChangeSelect={handleChangeSelect}
|
|
3492
|
-
onChangeToggle={handleChangeToggle}
|
|
3493
|
-
onChangeJsonTable={
|
|
3494
|
-
handleChangeJsonTable
|
|
3495
|
-
}
|
|
3496
|
-
onAddJsonTableRow={
|
|
3497
|
-
handleAddJsonTableRow
|
|
3498
|
-
}
|
|
3499
|
-
onDeleteJsonTableRow={
|
|
3500
|
-
handleDeleteJsonTableRow
|
|
3501
|
-
}
|
|
3502
|
-
onFileDownload={handleFileDownload}
|
|
3503
|
-
settings={item}
|
|
3504
|
-
setFormData={setFormData}
|
|
3505
|
-
style={style}
|
|
3506
|
-
uploadProgress={uploadProgress}
|
|
3507
|
-
userProfile={userProfile}
|
|
3508
|
-
/>
|
|
3810
|
+
{summary}
|
|
3811
|
+
</React.Fragment>
|
|
3812
|
+
) : (
|
|
3813
|
+
renderFormField(
|
|
3814
|
+
withSumEqualsHint(item),
|
|
3815
|
+
key
|
|
3816
|
+
)
|
|
3509
3817
|
);
|
|
3510
3818
|
})}
|
|
3511
3819
|
<div
|
|
@@ -16,6 +16,31 @@ function MultiCheckbox({
|
|
|
16
16
|
isEditable = false,
|
|
17
17
|
creatableConfig = {},
|
|
18
18
|
editableConfig = {},
|
|
19
|
+
// The checkbox renderer used to ignore the field's read-only state, so a
|
|
20
|
+
// `readOnlyWhen` config claimed a lock the UI never enforced (the same
|
|
21
|
+
// gap the radio renderers had). Disabling the boxes also takes the
|
|
22
|
+
// create/edit affordances away — they mutate the option list.
|
|
23
|
+
disabled = false,
|
|
24
|
+
/**
|
|
25
|
+
* Companion form fields to draw INSIDE the option cards, so each option
|
|
26
|
+
* reads as one self-contained unit ("Station A [✓] Qty 25") rather than a
|
|
27
|
+
* detached row of inputs below the group.
|
|
28
|
+
*
|
|
29
|
+
* optionFields: ['split_station_qty_1', 'split_station_qty_2']
|
|
30
|
+
*
|
|
31
|
+
* Positional against the SELECTED options **in the order the options are
|
|
32
|
+
* rendered**, not the order they were clicked — so the mapping is stable
|
|
33
|
+
* however the operator ticks the boxes, and lines up with a backend
|
|
34
|
+
* reading the same list in the same order.
|
|
35
|
+
*
|
|
36
|
+
* The fields are the form's own: `renderOptionField(fieldId)` mounts the
|
|
37
|
+
* real field (same id, same value, same validation, same sumEquals
|
|
38
|
+
* membership), so there is no second source of truth. It returns null for
|
|
39
|
+
* a field the form's `show` rules have not revealed — which is what keeps
|
|
40
|
+
* the quantity inputs hidden until a second box is ticked.
|
|
41
|
+
*/
|
|
42
|
+
optionFields = [],
|
|
43
|
+
renderOptionField,
|
|
19
44
|
}) {
|
|
20
45
|
const [checkboxOptions, setCheckboxOptions] = useState([]);
|
|
21
46
|
const [selectedValues, setSelectedValues] = useState([]);
|
|
@@ -75,8 +100,12 @@ function MultiCheckbox({
|
|
|
75
100
|
const filteredOptions = checkboxOptions;
|
|
76
101
|
|
|
77
102
|
const handleCheckboxChange = (option, isChecked) => {
|
|
103
|
+
if (disabled) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
78
107
|
let newSelected;
|
|
79
|
-
|
|
108
|
+
|
|
80
109
|
if (isChecked) {
|
|
81
110
|
// Add to selection
|
|
82
111
|
newSelected = [...selectedValues, option];
|
|
@@ -182,11 +211,33 @@ function MultiCheckbox({
|
|
|
182
211
|
|
|
183
212
|
const maxColumns = settings.maxColumns || 3;
|
|
184
213
|
|
|
214
|
+
/**
|
|
215
|
+
* The companion field id for an option: its position among the SELECTED
|
|
216
|
+
* options, counted in render order (see the `optionFields` docs above).
|
|
217
|
+
* Null when the option is unselected, unmapped, or nothing was supplied
|
|
218
|
+
* to render it with.
|
|
219
|
+
*/
|
|
220
|
+
const selectedInRenderOrder = filteredOptions.filter((option) =>
|
|
221
|
+
isSelected(option.id)
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const optionFieldIdFor = (option) => {
|
|
225
|
+
if (!renderOptionField || optionFields.length === 0) {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const position = selectedInRenderOrder.findIndex(
|
|
230
|
+
(selected) => String(selected.id) === String(option.id)
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
return position >= 0 ? optionFields[position] ?? null : null;
|
|
234
|
+
};
|
|
235
|
+
|
|
185
236
|
return (
|
|
186
237
|
<div className={`${styles.multiCheckbox} ${className || ''}`} style={style}>
|
|
187
238
|
|
|
188
239
|
{/* Create new tag section */}
|
|
189
|
-
{isCreatable && (
|
|
240
|
+
{isCreatable && !disabled && (
|
|
190
241
|
<div className={styles.createSection}>
|
|
191
242
|
{!isCreating ? (
|
|
192
243
|
<button
|
|
@@ -248,8 +299,19 @@ function MultiCheckbox({
|
|
|
248
299
|
gridTemplateColumns: `repeat(${maxColumns}, 1fr)`
|
|
249
300
|
}}
|
|
250
301
|
>
|
|
251
|
-
{filteredOptions.map((option, index) =>
|
|
252
|
-
|
|
302
|
+
{filteredOptions.map((option, index) => {
|
|
303
|
+
const optionFieldId = optionFieldIdFor(option);
|
|
304
|
+
const optionField = optionFieldId
|
|
305
|
+
? renderOptionField(optionFieldId, option)
|
|
306
|
+
: null;
|
|
307
|
+
|
|
308
|
+
return (
|
|
309
|
+
<div
|
|
310
|
+
key={option.id || `option-${index}`}
|
|
311
|
+
className={`${styles.checkboxItem} ${
|
|
312
|
+
optionField ? styles.hasOptionField : ''
|
|
313
|
+
}`}
|
|
314
|
+
>
|
|
253
315
|
{isEditing === option.id ? (
|
|
254
316
|
<div className={styles.editForm}>
|
|
255
317
|
<input
|
|
@@ -294,9 +356,10 @@ function MultiCheckbox({
|
|
|
294
356
|
checked={isSelected(option.id)}
|
|
295
357
|
onChange={(e) => handleCheckboxChange(option, e.target.checked)}
|
|
296
358
|
className={styles.checkbox}
|
|
359
|
+
disabled={disabled}
|
|
297
360
|
/>
|
|
298
361
|
<span className={styles.labelText}>{option.label}</span>
|
|
299
|
-
{isEditable && (
|
|
362
|
+
{isEditable && !disabled && (
|
|
300
363
|
<button
|
|
301
364
|
type="button"
|
|
302
365
|
onClick={(e) => {
|
|
@@ -312,8 +375,21 @@ function MultiCheckbox({
|
|
|
312
375
|
)}
|
|
313
376
|
</label>
|
|
314
377
|
)}
|
|
378
|
+
{/*
|
|
379
|
+
* Outside the <label> on purpose: an input nested in
|
|
380
|
+
* a label toggles that label's checkbox, so typing a
|
|
381
|
+
* quantity would untick the station it belongs to.
|
|
382
|
+
* Sitting here also gives the natural tab order —
|
|
383
|
+
* checkbox, its own quantity, then the next card.
|
|
384
|
+
*/}
|
|
385
|
+
{optionField && (
|
|
386
|
+
<div className={styles.optionField}>
|
|
387
|
+
{optionField}
|
|
388
|
+
</div>
|
|
389
|
+
)}
|
|
315
390
|
</div>
|
|
316
|
-
|
|
391
|
+
);
|
|
392
|
+
})}
|
|
317
393
|
</div>
|
|
318
394
|
|
|
319
395
|
{/* No options message */}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import styles from './styles/SumEqualsSummary.module.scss';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Live feedback for a `sumEquals` field group — see the rule's documentation
|
|
6
|
+
* in Form.jsx.
|
|
7
|
+
*
|
|
8
|
+
* The rule declares a set of numeric fields that must add up to a target:
|
|
9
|
+
*
|
|
10
|
+
* sumEquals: { fields: ['qty_a', 'qty_b'], equals: 'qty_total' }
|
|
11
|
+
*
|
|
12
|
+
* At submit time Form validates it. Everything here is the progressive half:
|
|
13
|
+
* while the operator types, the group says what the target is, how much is
|
|
14
|
+
* already allocated and how much is left, and the one empty field offers the
|
|
15
|
+
* remainder as a placeholder. None of it replaces the validation — a form
|
|
16
|
+
* that never renders the summary still refuses a wrong total.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const isBlank = (value) =>
|
|
20
|
+
value === null ||
|
|
21
|
+
value === undefined ||
|
|
22
|
+
value === '' ||
|
|
23
|
+
isNaN(Number(value));
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The group's running state, or null when the rule names no fields.
|
|
27
|
+
*
|
|
28
|
+
* @param {object} rule the field's `sumEquals` declaration
|
|
29
|
+
* @param {object} formData current form values
|
|
30
|
+
* @returns {{
|
|
31
|
+
* ids: string[], target: number, hasTarget: boolean, sum: number,
|
|
32
|
+
* remaining: number|null, entered: number, emptyIds: string[],
|
|
33
|
+
* status: 'under'|'balanced'|'over'|'unknown'
|
|
34
|
+
* }|null}
|
|
35
|
+
*/
|
|
36
|
+
export const computeSumEqualsState = (rule, formData = {}) => {
|
|
37
|
+
const ids = Array.isArray(rule?.fields) ? rule.fields : [];
|
|
38
|
+
|
|
39
|
+
if (ids.length === 0) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const target =
|
|
44
|
+
typeof rule.equals === 'number'
|
|
45
|
+
? rule.equals
|
|
46
|
+
: Number(formData[rule.equals]);
|
|
47
|
+
const hasTarget = !isNaN(target);
|
|
48
|
+
|
|
49
|
+
const emptyIds = ids.filter((id) => isBlank(formData[id]));
|
|
50
|
+
const sum = ids.reduce(
|
|
51
|
+
(total, id) => (isBlank(formData[id]) ? total : total + Number(formData[id])),
|
|
52
|
+
0
|
|
53
|
+
);
|
|
54
|
+
const remaining = hasTarget ? target - sum : null;
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
ids,
|
|
58
|
+
target,
|
|
59
|
+
hasTarget,
|
|
60
|
+
sum,
|
|
61
|
+
remaining,
|
|
62
|
+
entered: ids.length - emptyIds.length,
|
|
63
|
+
emptyIds,
|
|
64
|
+
status: !hasTarget
|
|
65
|
+
? 'unknown'
|
|
66
|
+
: remaining > 0
|
|
67
|
+
? 'under'
|
|
68
|
+
: remaining < 0
|
|
69
|
+
? 'over'
|
|
70
|
+
: 'balanced',
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The remainder to offer as `fieldId`'s placeholder, or undefined.
|
|
76
|
+
*
|
|
77
|
+
* Only when that field is the ONLY empty one in its group and something is
|
|
78
|
+
* actually left to allocate — a placeholder on two empty fields would be a
|
|
79
|
+
* guess, and one showing "0" or a negative would be wrong. Deliberately a
|
|
80
|
+
* placeholder and never a value: the operator types the number themselves.
|
|
81
|
+
*/
|
|
82
|
+
export const sumEqualsHintFor = (fieldId, rule, formData = {}) => {
|
|
83
|
+
const state = computeSumEqualsState(rule, formData);
|
|
84
|
+
|
|
85
|
+
if (
|
|
86
|
+
!state ||
|
|
87
|
+
!state.hasTarget ||
|
|
88
|
+
state.emptyIds.length !== 1 ||
|
|
89
|
+
state.emptyIds[0] !== fieldId ||
|
|
90
|
+
state.remaining <= 0
|
|
91
|
+
) {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return String(state.remaining);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* One line under a `sumEquals` group:
|
|
100
|
+
*
|
|
101
|
+
* under "Item Qty 40 · 25 entered · 15 remaining" (muted)
|
|
102
|
+
* balanced "40 of 40 allocated ✓" (success)
|
|
103
|
+
* over "Item Qty 40 · 45 entered · over by 5" (error)
|
|
104
|
+
*
|
|
105
|
+
* The height is reserved in CSS so moving between the three never shifts the
|
|
106
|
+
* form. `targetLabel` names the total in the copy: the rule's own
|
|
107
|
+
* `targetLabel`, else the label of the field `equals` points at, else
|
|
108
|
+
* "Total".
|
|
109
|
+
*/
|
|
110
|
+
const SumEqualsSummary = ({ rule, formData, fields = [] }) => {
|
|
111
|
+
const state = computeSumEqualsState(rule, formData);
|
|
112
|
+
|
|
113
|
+
if (!state) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const targetLabel =
|
|
118
|
+
rule.targetLabel ||
|
|
119
|
+
fields.find((f) => f.id === rule.equals)?.label ||
|
|
120
|
+
'Total';
|
|
121
|
+
|
|
122
|
+
let text = ' ';
|
|
123
|
+
let tone = styles.neutral;
|
|
124
|
+
|
|
125
|
+
if (state.hasTarget) {
|
|
126
|
+
if (state.status === 'balanced') {
|
|
127
|
+
text = `${state.sum} of ${state.target} allocated ✓`;
|
|
128
|
+
tone = styles.balanced;
|
|
129
|
+
} else if (state.status === 'over') {
|
|
130
|
+
text = `${targetLabel} ${state.target} · ${state.sum} entered · over by ${Math.abs(
|
|
131
|
+
state.remaining
|
|
132
|
+
)}`;
|
|
133
|
+
tone = styles.over;
|
|
134
|
+
} else {
|
|
135
|
+
text = `${targetLabel} ${state.target} · ${state.sum} entered · ${state.remaining} remaining`;
|
|
136
|
+
tone = styles.neutral;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<div className={`${styles.summary} ${tone}`} aria-live="polite">
|
|
142
|
+
{text}
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export default SumEqualsSummary;
|
|
@@ -99,8 +99,65 @@
|
|
|
99
99
|
display: grid;
|
|
100
100
|
gap: 8px;
|
|
101
101
|
margin-bottom: 12px;
|
|
102
|
-
|
|
102
|
+
/*
|
|
103
|
+
* Cards carrying a companion field grow taller than bare ones.
|
|
104
|
+
* Stretching keeps every card in a row the same height, so two
|
|
105
|
+
* stations stay aligned while one of them is being filled in.
|
|
106
|
+
*/
|
|
107
|
+
align-items: stretch;
|
|
108
|
+
|
|
103
109
|
.checkboxItem {
|
|
110
|
+
/*
|
|
111
|
+
* A card is one unit: the option's checkbox, and — when
|
|
112
|
+
* `optionFields` maps one — its companion input underneath.
|
|
113
|
+
*/
|
|
114
|
+
display: flex;
|
|
115
|
+
flex-direction: column;
|
|
116
|
+
|
|
117
|
+
/*
|
|
118
|
+
* With a companion inside, the card takes the frame and the label
|
|
119
|
+
* sheds its own, so the two read as one control instead of a box
|
|
120
|
+
* inside a box.
|
|
121
|
+
*/
|
|
122
|
+
&.hasOptionField {
|
|
123
|
+
border: 1px solid var(--border-color, #e2e8f0);
|
|
124
|
+
border-radius: 6px;
|
|
125
|
+
background: var(--alternate-color, #fafbfc);
|
|
126
|
+
overflow: hidden;
|
|
127
|
+
|
|
128
|
+
> .checkboxLabel {
|
|
129
|
+
border: 0;
|
|
130
|
+
border-radius: 0;
|
|
131
|
+
background: transparent;
|
|
132
|
+
|
|
133
|
+
&:hover {
|
|
134
|
+
transform: none;
|
|
135
|
+
box-shadow: none;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.optionField {
|
|
141
|
+
padding: 0 12px 10px;
|
|
142
|
+
|
|
143
|
+
/*
|
|
144
|
+
* The companion is an ordinary form field that fills the card
|
|
145
|
+
* instead of a row. Its own error styling (`.inputError`, a
|
|
146
|
+
* red border on the input) is untouched, so a wrong quantity
|
|
147
|
+
* still reads inside the card.
|
|
148
|
+
*/
|
|
149
|
+
> div {
|
|
150
|
+
width: 100%;
|
|
151
|
+
margin: 0;
|
|
152
|
+
padding: 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
label {
|
|
156
|
+
font-size: var(--font-size-xs, 0.75rem);
|
|
157
|
+
color: var(--muted-color, #6e7276);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
104
161
|
.checkboxLabel {
|
|
105
162
|
display: flex;
|
|
106
163
|
align-items: center;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Live allocation line under a `sumEquals` field group.
|
|
3
|
+
*
|
|
4
|
+
* The height is fixed rather than content-driven: the line moves between
|
|
5
|
+
* "remaining", "allocated" and "over by" on every keystroke, and a form that
|
|
6
|
+
* grew and shrank a row underneath the inputs would push the fields around
|
|
7
|
+
* while the operator is typing into them.
|
|
8
|
+
*/
|
|
9
|
+
.summary {
|
|
10
|
+
display: flex;
|
|
11
|
+
align-items: center;
|
|
12
|
+
min-height: 1.25rem;
|
|
13
|
+
line-height: 1.25rem;
|
|
14
|
+
margin-top: var(--spacing-xs, 0.25rem);
|
|
15
|
+
font-size: var(--font-size-xs, 0.75rem);
|
|
16
|
+
font-weight: var(--font-weight-medium, 500);
|
|
17
|
+
white-space: nowrap;
|
|
18
|
+
overflow: hidden;
|
|
19
|
+
text-overflow: ellipsis;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/* Still to allocate — informational, not a problem yet. */
|
|
23
|
+
.neutral {
|
|
24
|
+
color: var(--muted-color, #6e7276);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* Adds up: the same green the package uses for success elsewhere. */
|
|
28
|
+
.balanced {
|
|
29
|
+
color: var(--success-color, #28a745);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/*
|
|
33
|
+
* Over the target. Matches the red `.inputError` puts on the inputs
|
|
34
|
+
* themselves, so the line and the fields read as one error.
|
|
35
|
+
*/
|
|
36
|
+
.over {
|
|
37
|
+
color: var(--danger-color, #dc3545);
|
|
38
|
+
}
|