@onehat/ui 0.4.114 → 0.4.116

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onehat/ui",
3
- "version": "0.4.114",
3
+ "version": "0.4.116",
4
4
  "description": "Base UI for OneHat apps",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -1,4 +1,4 @@
1
- import { cloneElement, forwardRef, isValidElement, useRef } from 'react';
1
+ import { cloneElement, forwardRef, isValidElement, useContext, useRef } from 'react';
2
2
  import {
3
3
  Button,
4
4
  ButtonText,
@@ -10,9 +10,12 @@ import addIconProps from '../../Functions/addIconProps.js';
10
10
  import clsx from 'clsx';
11
11
  import withComponent from '../Hoc/withComponent.js';
12
12
  import withTooltip from '../Hoc/withTooltip.js';
13
+ import FormContext from '../Form/FormContext.js';
13
14
  import _ from 'lodash';
14
15
 
15
16
  const ButtonComponent = forwardRef((props, ref) => {
17
+
18
+ const formContext = useContext(FormContext);
16
19
  let {
17
20
  self,
18
21
  text, // the text to display on the button
@@ -32,6 +35,16 @@ const ButtonComponent = forwardRef((props, ref) => {
32
35
  propsToPass.onPress = propsToPass.handler; // alias
33
36
  }
34
37
 
38
+ const {
39
+ disableOnInvalid,
40
+ ...propsToPassWithoutDisableOnInvalid
41
+ } = propsToPass;
42
+ propsToPass = propsToPassWithoutDisableOnInvalid;
43
+
44
+ if (_.isNil(propsToPass.isDisabled) && disableOnInvalid && formContext && !formContext.isValid) {
45
+ propsToPass.isDisabled = true;
46
+ }
47
+
35
48
  if (icon) {
36
49
  if (isValidElement(icon)) {
37
50
  if (_icon) {
@@ -64,8 +77,10 @@ const ButtonComponent = forwardRef((props, ref) => {
64
77
  'flex',
65
78
  'flex-row',
66
79
  'items-center',
67
- 'disabled:opacity-40',
68
- 'disabled:cursor-not-allowed',
80
+ 'data-[disabled=true]:opacity-40',
81
+ 'data-[disabled=true]:cursor-not-allowed',
82
+ 'web:disabled:opacity-40',
83
+ 'web:disabled:cursor-not-allowed',
69
84
  );
70
85
  if (isExpandToFillVertical) {
71
86
  // IMPORTANT! Otherwise the button will cut off the vertical content due to size classes automatically added by Gluestack (e.g. h-10)
@@ -115,6 +115,7 @@ function Container(props) {
115
115
  southRef = useRef(null),
116
116
  eastRef = useRef(null),
117
117
  westRef = useRef(null),
118
+ centerWidthRef = useRef(null), // used to store the width of the center area, which is used to calculate max widths for east and west
118
119
  northHeightRef = useRef(northInitialHeight),
119
120
  southHeightRef = useRef(southInitialHeight),
120
121
  eastWidthRef = useRef(eastInitialWidth),
@@ -202,6 +203,12 @@ function Container(props) {
202
203
  localEastIsCollapsedRef.current = bool;
203
204
  }
204
205
 
206
+ if (!bool) {
207
+ setSideWidth('east', getEastWidth() ?? eastInitialWidth, {
208
+ ignoreCollapsedCheck: true,
209
+ });
210
+ }
211
+
205
212
  if (id) {
206
213
  setSaved(id + '-eastIsCollapsed', bool);
207
214
  }
@@ -220,6 +227,12 @@ function Container(props) {
220
227
  localWestIsCollapsedRef.current = bool;
221
228
  }
222
229
 
230
+ if (!bool) {
231
+ setSideWidth('west', getWestWidth() ?? westInitialWidth, {
232
+ ignoreCollapsedCheck: true,
233
+ });
234
+ }
235
+
223
236
  if (id) {
224
237
  setSaved(id + '-westIsCollapsed', bool);
225
238
  }
@@ -253,24 +266,76 @@ function Container(props) {
253
266
  getSouthHeight = () => {
254
267
  return southHeightRef.current;
255
268
  },
256
- setEastWidth = (width) => {
257
- if (!getEastIsCollapsed()) {
258
- eastWidthRef.current = width;
269
+ setCenterWidth = (width) => {
270
+ centerWidthRef.current = width;
271
+ },
272
+ getCenterWidth = () => {
273
+ return centerWidthRef.current;
274
+ },
275
+ getMaxSideWidth = () => {
276
+ const width = getCenterWidth();
277
+ if (!_.isFinite(width) || width <= 0) {
278
+ return null;
279
+ }
280
+
281
+ return width;
282
+ },
283
+ clampSideWidth = (width) => {
284
+ if (_.isNil(width)) {
285
+ return width;
286
+ }
287
+
288
+ const maxSideWidth = getMaxSideWidth();
289
+ if (_.isNil(maxSideWidth)) {
290
+ return width;
291
+ }
292
+
293
+ return Math.min(width, maxSideWidth);
294
+ },
295
+ setSideWidth = (side, width, opts = {}) => {
296
+ const
297
+ {
298
+ ignoreCollapsedCheck = false,
299
+ } = opts,
300
+ isCollapsed = side === 'east' ? getEastIsCollapsed() : getWestIsCollapsed();
301
+
302
+ if (!ignoreCollapsedCheck && isCollapsed) {
303
+ return;
304
+ }
305
+
306
+ const clampedWidth = clampSideWidth(width);
307
+ if (side === 'east') {
308
+ eastWidthRef.current = clampedWidth;
259
309
  if (id) {
260
- setSaved(id + '-eastWidth', width);
310
+ setSaved(id + '-eastWidth', clampedWidth);
261
311
  }
312
+ } else {
313
+ westWidthRef.current = clampedWidth;
314
+ if (id) {
315
+ setSaved(id + '-westWidth', clampedWidth);
316
+ }
317
+ }
318
+ },
319
+ normalizeSideWidthForRender = (width, initialWidth) => {
320
+ if (_.isNil(width)) {
321
+ return width;
262
322
  }
323
+
324
+ const maxSideWidth = getMaxSideWidth();
325
+ if (_.isNil(maxSideWidth)) {
326
+ return _.isFinite(initialWidth) && initialWidth > 0 ? initialWidth : width;
327
+ }
328
+
329
+ return Math.min(width, maxSideWidth);
330
+ },
331
+ setEastWidth = (width) => {
332
+ setSideWidth('east', width);
263
333
  },
264
334
  getEastWidth = () => {
265
335
  return eastWidthRef.current;
266
336
  },
267
337
  setWestWidth = (width) => {
268
- if (!getWestIsCollapsed()) {
269
- westWidthRef.current = width;
270
- if (id) {
271
- setSaved(id + '-westWidth', width);
272
- }
273
- }
338
+ setSideWidth('west', width);
274
339
  },
275
340
  getWestWidth = () => {
276
341
  return westWidthRef.current;
@@ -409,6 +474,7 @@ function Container(props) {
409
474
  if (!isReady) {
410
475
  return null;
411
476
  }
477
+
412
478
 
413
479
  let componentProps = { _panel: { ...center?.props?._panel }, },
414
480
  wrapperProps = null,
@@ -533,11 +599,17 @@ function Container(props) {
533
599
  width: 33,
534
600
  };
535
601
  } else {
536
- const eastWidth = getEastWidth();
602
+ const eastWidth = normalizeSideWidthForRender(getEastWidth(), eastInitialWidth);
537
603
  if (_.isNil(eastWidth)) {
538
- wrapperProps.style = { flex: eastInitialFlex || 50, };
604
+ wrapperProps.style = {
605
+ flex: eastInitialFlex || 50,
606
+ maxWidth: '100%',
607
+ };
539
608
  } else {
540
- wrapperProps.style = { width: eastWidth, };
609
+ wrapperProps.style = {
610
+ width: eastWidth,
611
+ maxWidth: '100%',
612
+ };
541
613
  }
542
614
  }
543
615
  componentProps._panel.collapseDirection = HORIZONTAL;
@@ -576,11 +648,17 @@ function Container(props) {
576
648
  width: 33,
577
649
  };
578
650
  } else {
579
- const westWidth = getWestWidth();
651
+ const westWidth = normalizeSideWidthForRender(getWestWidth(), westInitialWidth);
580
652
  if (_.isNil(westWidth)) {
581
- wrapperProps.style = { flex: westInitialFlex || 50, };
653
+ wrapperProps.style = {
654
+ flex: westInitialFlex || 50,
655
+ maxWidth: '100%',
656
+ };
582
657
  } else {
583
- wrapperProps.style = { width: westWidth, };
658
+ wrapperProps.style = {
659
+ width: westWidth,
660
+ maxWidth: '100%',
661
+ };
584
662
  }
585
663
  }
586
664
  componentProps._panel.collapseDirection = HORIZONTAL;
@@ -600,13 +678,31 @@ function Container(props) {
600
678
  {cloneElement(west, componentProps)}
601
679
  </BoxNative>;
602
680
  }
603
- return <VStack className="Container-all w-full flex-1">
681
+ return <VStack className="Container-all flex-1 min-w-0">
604
682
  {northComponent}
605
683
  {!getNorthIsCollapsed() && northSplitter}
606
- <HStack className="Container-mid w-full flex-[100]">
684
+ <HStack
685
+ className="Container-mid w-full flex-[100] min-w-0"
686
+ onLayout={(e) => {
687
+ // Measure available horizontal space for side panels.
688
+ const width = parseFloat(e.nativeEvent.layout.width);
689
+ if (width && width !== getCenterWidth()) {
690
+ // Save latest width and clamp east/west if they exceed it.
691
+ setCenterWidth(width);
692
+ if (getEastWidth() > width) {
693
+ setEastWidth(width);
694
+ }
695
+ if (getWestWidth() > width) {
696
+ setWestWidth(width);
697
+ }
698
+ // Trigger a render so updated widths are applied immediately.
699
+ forceUpdate();
700
+ }
701
+ }}
702
+ >
607
703
  {westComponent}
608
704
  {!getWestIsCollapsed() && westSplitter}
609
- <VStack className="Container-center h-full overflow-auto flex-[100]">
705
+ <VStack className="Container-center h-full overflow-auto flex-[100] min-w-0">
610
706
  {centerComponent}
611
707
  </VStack>
612
708
  {!getEastIsCollapsed() && eastSplitter}
@@ -1,4 +1,4 @@
1
- import { useEffect, useCallback, useState, useRef, isValidElement, } from 'react';
1
+ import { useEffect, useCallback, useState, useRef, isValidElement, cloneElement, Children, } from 'react';
2
2
  import {
3
3
  Box,
4
4
  HStack,
@@ -59,6 +59,7 @@ import Xmark from '../Icons/Xmark.js';
59
59
  import Check from '../Icons/Check.js';
60
60
  import Footer from '../Layout/Footer.js';
61
61
  import Label from '../Form/Label.js';
62
+ import FormContext from '../Form/FormContext.js';
62
63
  import _ from 'lodash';
63
64
 
64
65
  // TODO: memoize field Components
@@ -261,6 +262,7 @@ function Form(props) {
261
262
  context: { isPhantom },
262
263
  }),
263
264
  currentEditorMode = getEditorMode(),
265
+ resolvedEditorMode = currentEditorMode || props.editorMode || null,
264
266
  buildFromColumnsConfig = () => {
265
267
  // Only used in InlineEditor
266
268
  // Build the fields that match the current columnsConfig in the grid
@@ -1119,12 +1121,63 @@ function Form(props) {
1119
1121
  alert(errors.message);
1120
1122
  }
1121
1123
  },
1124
+ decorateAdditionalFooterItems = (elements) => {
1125
+ const decorateElement = (element) => {
1126
+ if (!isValidElement(element)) {
1127
+ return element;
1128
+ }
1129
+
1130
+ const
1131
+ elementProps = element.props || {},
1132
+ propsToInject = {};
1133
+
1134
+ if (elementProps.disableOnInvalid && !formState.isValid) {
1135
+ propsToInject.isDisabled = true;
1136
+ }
1137
+
1138
+ if (typeof elementProps.onPress === 'function' && (elementProps.disableOnInvalid || elementProps.skipSubmit || elementProps.submitWithForm)) {
1139
+ const originalOnPress = elementProps.onPress;
1140
+ if (elementProps.skipSubmit) {
1141
+ propsToInject.onPress = () => originalOnPress();
1142
+ } else {
1143
+ propsToInject.onPress = (e) => handleSubmit(originalOnPress, onSubmitError)(e);
1144
+ }
1145
+ }
1146
+
1147
+ if (elementProps.children) {
1148
+ propsToInject.children = Children.map(elementProps.children, decorateElement);
1149
+ }
1150
+
1151
+ if (_.isEmpty(propsToInject)) {
1152
+ return element;
1153
+ }
1154
+
1155
+ return cloneElement(element, propsToInject);
1156
+ };
1157
+
1158
+ return Children.map(elements, decorateElement);
1159
+ },
1122
1160
  doReset = (values) => {
1123
1161
  reset(values);
1124
1162
  if (onReset) {
1125
1163
  onReset(values, formSetValue, formGetValues, trigger);
1126
1164
  }
1127
1165
  },
1166
+ resetFromRecord = () => {
1167
+ if (!record || _.isArray(record) || record.isDestroyed) {
1168
+ return;
1169
+ }
1170
+
1171
+ const
1172
+ currentValues = formGetValues(),
1173
+ recordValues = record.submitValues || record,
1174
+ nextValues = {
1175
+ ...currentValues,
1176
+ ..._.pick(recordValues, Object.keys(currentValues)),
1177
+ };
1178
+
1179
+ doReset(nextValues);
1180
+ },
1128
1181
  onSaveDecorated = async (data, e) => {
1129
1182
  // reset the form after a save
1130
1183
  const result = await onSave(data, e);
@@ -1189,6 +1242,21 @@ function Form(props) {
1189
1242
  }
1190
1243
  }, [record]);
1191
1244
 
1245
+ useEffect(() => {
1246
+ // If this form is bound to a single record, reset the form whenever that record emits a 'reload' event.
1247
+ // This ensures the form stays in sync with the latest data for that record.
1248
+
1249
+ if (skipAll || !isSingle || !record?.on || !record?.off) {
1250
+ return;
1251
+ }
1252
+
1253
+ record.on('reload', resetFromRecord);
1254
+
1255
+ return () => {
1256
+ record.off('reload', resetFromRecord);
1257
+ };
1258
+ }, [record, skipAll, isSingle]);
1259
+
1192
1260
  useEffect(() => {
1193
1261
  if (skipAll) {
1194
1262
  return;
@@ -1313,10 +1381,18 @@ function Form(props) {
1313
1381
  showCancelBtn = false,
1314
1382
  showSaveBtn = false,
1315
1383
  showSubmitBtn = false,
1316
- isAddMode = getEditorMode() === EDITOR_MODE__ADD,
1317
- isEditableMode =
1318
- getEditorMode() === EDITOR_MODE__ADD ||
1319
- getEditorMode() === EDITOR_MODE__EDIT;
1384
+ isAddMode = resolvedEditorMode === EDITOR_MODE__ADD,
1385
+ isEditableMode = (() => {
1386
+ // Keep explicit editor modes authoritative, but preserve legacy modal behavior:
1387
+ // if no mode is supplied, treat forms with onSave as editable so Save can render.
1388
+ if (resolvedEditorMode === EDITOR_MODE__ADD || resolvedEditorMode === EDITOR_MODE__EDIT) {
1389
+ return true;
1390
+ }
1391
+ if (resolvedEditorMode === EDITOR_MODE__VIEW) {
1392
+ return false;
1393
+ }
1394
+ return !!onSave;
1395
+ })();
1320
1396
  if (containerWidth) { // we need to render this component twice in order to get the container width. Skip this on first render
1321
1397
 
1322
1398
  // create editor
@@ -1341,7 +1417,7 @@ function Form(props) {
1341
1417
  additionalButtons = buildAdditionalButtons(additionalEditButtons);
1342
1418
 
1343
1419
  if (inArray(editorType, [EDITOR_TYPE__SIDE, EDITOR_TYPE__SMART, EDITOR_TYPE__WINDOWED]) &&
1344
- isSingle && getEditorMode() === EDITOR_MODE__EDIT &&
1420
+ isSingle && resolvedEditorMode === EDITOR_MODE__EDIT &&
1345
1421
  (onBack || onViewMode || isEditorModeControlledByParent)) {
1346
1422
  modeHeader = <Toolbar>
1347
1423
  <HStack className="flex-1 items-center">
@@ -1377,7 +1453,7 @@ function Form(props) {
1377
1453
  />}
1378
1454
  </Toolbar>;
1379
1455
  }
1380
- if (getEditorMode() === EDITOR_MODE__EDIT && !_.isEmpty(additionalButtons)) {
1456
+ if (resolvedEditorMode === EDITOR_MODE__EDIT && !_.isEmpty(additionalButtons)) {
1381
1457
  formButtons = <Toolbar className="justify-end flex-wrap gap-2">
1382
1458
  {additionalButtons}
1383
1459
  </Toolbar>;
@@ -1402,7 +1478,7 @@ function Form(props) {
1402
1478
  if (_.isEmpty(formState.dirtyFields) && !isPhantom && !isAddMode) {
1403
1479
  isSaveDisabled = true;
1404
1480
  }
1405
- if (onDelete && getEditorMode() === EDITOR_MODE__EDIT && isSingle) {
1481
+ if (onDelete && resolvedEditorMode === EDITOR_MODE__EDIT && isSingle) {
1406
1482
  showDeleteBtn = true;
1407
1483
  }
1408
1484
  if (!isEditorViewOnly && isEditableMode && !hideResetButton) {
@@ -1444,7 +1520,7 @@ function Form(props) {
1444
1520
  }
1445
1521
  footerItems =
1446
1522
  <>
1447
- {additionalFooterItems}
1523
+ {decorateAdditionalFooterItems(additionalFooterItems)}
1448
1524
  {!additionalFooterItems && additionalFooterButtons && _.map(additionalFooterButtons, (props, ix) => {
1449
1525
  let isDisabled = false;
1450
1526
  if (props.disableOnInvalid) {
@@ -1597,7 +1673,8 @@ function Form(props) {
1597
1673
  className += ' ' + props.className;
1598
1674
  }
1599
1675
  const scrollToTopAnchor = <Box ref={(el) => (ancillaryItemsRef.current[0] = el)} className="h-0" />;
1600
- return <VStackNative
1676
+ return <FormContext.Provider value={{ isValid: formState.isValid }}>
1677
+ <VStackNative
1601
1678
  ref={formRef}
1602
1679
  {...testProps(self)}
1603
1680
  style={style}
@@ -1639,7 +1716,8 @@ function Form(props) {
1639
1716
  {isFabVisible && fab}
1640
1717
 
1641
1718
  </>}
1642
- </VStackNative>;
1719
+ </VStackNative>
1720
+ </FormContext.Provider>;
1643
1721
  }
1644
1722
 
1645
1723
  // helper fns
@@ -0,0 +1,7 @@
1
+ import { createContext } from 'react';
2
+
3
+ const FormContext = createContext({
4
+ isValid: true,
5
+ });
6
+
7
+ export default FormContext;
@@ -93,7 +93,7 @@ function FileCardCustom(props) {
93
93
  onPress={() => {
94
94
  downloadInBackground(downloadUrl);
95
95
  }}
96
- className="Pressable px-3 py-1 items-center flex-row rounded-[5px] border border-primary.700"
96
+ className="Pressable max-w-full px-3 py-1 items-center flex-row rounded-[5px] border border-primary.700"
97
97
  >
98
98
  {isDownloading &&
99
99
  <Spinner className="mr-2" />}
@@ -103,7 +103,7 @@ function FileCardCustom(props) {
103
103
  icon={Eye}
104
104
  onPress={() => onSee(id)}
105
105
  />}
106
- <Text>{filename}</Text>
106
+ <Text className="max-w-[220px] truncate">{filename}</Text>
107
107
  {onDelete &&
108
108
  <IconButton
109
109
  className="ml-1"
@@ -868,7 +868,9 @@ function AttachmentsElement(props) {
868
868
  className={clsx(
869
869
  'AttachmentsElement-HStack',
870
870
  'flex-1',
871
+ 'w-full',
871
872
  'min-h-0',
873
+ 'min-w-0',
872
874
  'overflow-y-auto',
873
875
  'gap-2',
874
876
  'flex-wrap',
@@ -908,7 +910,7 @@ function AttachmentsElement(props) {
908
910
 
909
911
  return <Box
910
912
  key={file.id}
911
- className="mr-2"
913
+ className="mr-2 min-w-0 max-w-full"
912
914
  >
913
915
  {useFileMosaic &&
914
916
  <DraggableFileMosaic
@@ -1142,7 +1144,7 @@ function AttachmentsElement(props) {
1142
1144
  className: '!hidden',
1143
1145
  deleteFiles: false,
1144
1146
  }}
1145
- className="attachments-dropzone flex-1 h-full" // Add flex classes to ensure full height
1147
+ className="attachments-dropzone flex-1 h-full min-w-0 overflow-x-hidden" // Keep horizontal containment while allowing vertical scrolling
1146
1148
  onUploadStart={onUploadStart}
1147
1149
  onUploadFinish={onUploadFinish}
1148
1150
  background={styles.ATTACHMENTS_BG}
@@ -1285,6 +1287,8 @@ function AttachmentsElement(props) {
1285
1287
  'AttachmentsElement',
1286
1288
  'testx',
1287
1289
  'w-full',
1290
+ 'min-w-0',
1291
+ 'overflow-x-hidden',
1288
1292
  'h-[400px]',
1289
1293
  'border-2',
1290
1294
  'rounded-[5px]',
@@ -1,14 +1,19 @@
1
- html, body {
2
- /* -webkit-user-select: none;
3
- user-select: none; */
4
- }
5
-
6
1
  /* to fix the inline editor */
7
2
  [data-testid="gridContainer"] > [data-testid="ScrollView"] > div {
8
3
  height: 100%;
9
4
  }
10
5
 
11
6
  /* Custom styles for attachments dropzone */
7
+ .attachments-dropzone {
8
+ width: 100%;
9
+ min-width: 0;
10
+ overflow-x: hidden;
11
+ }
12
+
12
13
  .attachments-dropzone .files-ui-dropzone-children-container {
13
14
  padding: 0 !important;
15
+ width: 100%;
16
+ min-width: 0;
17
+ min-height: 0;
18
+ overflow-x: hidden;
14
19
  }