@revisium/schema-toolkit-ui 0.6.7 → 0.6.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.
package/README.md CHANGED
@@ -284,6 +284,54 @@ core.applyViewState(s) // restore view settings
284
284
  core.dispose() // cleanup
285
285
  ```
286
286
 
287
+ ## Hooks
288
+
289
+ ### `useContentEditable`
290
+
291
+ A controlled `contenteditable` hook that manages DOM synchronization, cursor position, and keyboard shortcuts.
292
+
293
+ ```tsx
294
+ import { useContentEditable } from '@revisium/schema-toolkit-ui';
295
+
296
+ const editableProps = useContentEditable({
297
+ value,
298
+ onChange: (v) => setValue(v),
299
+ onBlur: () => { /* called after DOM is synced */ },
300
+ onFocus: () => {},
301
+ onEnter: () => { /* Enter key pressed with non-empty value */ },
302
+ onEscape: () => { /* Escape key pressed */ },
303
+ restrict: /^[a-z0-9-]$/, // optional — block disallowed keys
304
+ autoFocus: true, // optional — focus on mount
305
+ focusTrigger: n, // optional — increment to focus programmatically
306
+ });
307
+
308
+ return <div data-testid="editable" {...editableProps} />;
309
+ ```
310
+
311
+ #### How it works
312
+
313
+ `dangerouslySetInnerHTML` sets the initial DOM content. After that React does not overwrite the DOM on re-renders (standard contenteditable contract). The hook handles synchronization explicitly:
314
+
315
+ | Event | Behaviour |
316
+ |-------|-----------|
317
+ | User types | `onChange(value)` called on every `input` event. Cursor position is preserved across re-renders. |
318
+ | `value` prop changes while **not focused** | DOM updated immediately via `textContent`. |
319
+ | `value` prop changes while **focused** | DOM is left untouched so the user can keep typing without interruption. |
320
+ | `blur` | If `value` diverged from DOM (e.g. external revert happened while focused), DOM is synced to `value` before `onBlur` is called. |
321
+ | `Enter` | `blur()` called then `onEnter()` (only when `value` is non-empty). |
322
+ | `Escape` | `blur()` called then `onEscape()`. |
323
+
324
+ #### Controlled revert pattern
325
+
326
+ Because DOM sync on blur is guaranteed, a revert triggered while the element is focused will correctly reset the displayed text when the user leaves the field:
327
+
328
+ ```tsx
329
+ // External state revert while user is typing:
330
+ // value prop → 'original'
331
+ // DOM → 'user typed...' (untouched during focus)
332
+ // on blur → DOM is reset to 'original' ✓
333
+ ```
334
+
287
335
  ### Cleanup
288
336
 
289
337
  Call `vm.dispose()` (or `core.dispose()` for `TableEditorCore`) when the editor is unmounted.
package/dist/index.cjs CHANGED
@@ -2550,6 +2550,11 @@ function useContentEditable(options) {
2550
2550
  const prevFocusTriggerRef = (0, react.useRef)(focusTrigger);
2551
2551
  const optionsRef = (0, react.useRef)(options);
2552
2552
  optionsRef.current = options;
2553
+ const isFocusedRef = (0, react.useRef)(false);
2554
+ (0, react.useEffect)(() => {
2555
+ const el = elementRef.current;
2556
+ if (el && !isFocusedRef.current && el.textContent !== value) el.textContent = value;
2557
+ }, [value]);
2553
2558
  const ref = (0, react.useCallback)((node) => {
2554
2559
  elementRef.current = node;
2555
2560
  }, []);
@@ -2592,10 +2597,14 @@ function useContentEditable(options) {
2592
2597
  optionsRef.current.onChange?.(val);
2593
2598
  }, []);
2594
2599
  const handleBlur = (0, react.useCallback)(() => {
2600
+ isFocusedRef.current = false;
2601
+ const el = elementRef.current;
2602
+ if (el && el.textContent !== optionsRef.current.value) el.textContent = optionsRef.current.value;
2595
2603
  optionsRef.current.onBlur?.();
2596
2604
  cursorPosition.current = null;
2597
2605
  }, []);
2598
2606
  const handleFocus = (0, react.useCallback)(() => {
2607
+ isFocusedRef.current = true;
2599
2608
  optionsRef.current.onFocus?.();
2600
2609
  }, []);
2601
2610
  const handleKeyDown = (0, react.useCallback)((event) => {
@@ -7078,7 +7087,7 @@ function resolveRefColumn(child, fieldPath) {
7078
7087
  systemFieldId: systemDef.id,
7079
7088
  isDeprecated,
7080
7089
  hasFormula,
7081
- isSortable: !isDeprecated && !hasFormula
7090
+ isSortable: !isDeprecated
7082
7091
  };
7083
7092
  }
7084
7093
  if (refValue === _revisium_schema_toolkit.SystemSchemaIds.File) return resolveFileRefColumns(child, fieldPath);
@@ -7104,7 +7113,8 @@ function stripDataPrefix(fieldPath) {
7104
7113
  }
7105
7114
  function createColumn(fieldPath, child, fieldType) {
7106
7115
  const isDeprecated = child.metadata().deprecated ?? false;
7107
- const hasFormula = child.hasFormula();
7116
+ const formula = child.formula();
7117
+ const hasFormula = formula !== void 0;
7108
7118
  return {
7109
7119
  field: fieldPath,
7110
7120
  label: stripDataPrefix(fieldPath),
@@ -7112,7 +7122,8 @@ function createColumn(fieldPath, child, fieldType) {
7112
7122
  isSystem: false,
7113
7123
  isDeprecated,
7114
7124
  hasFormula,
7115
- isSortable: !isDeprecated && !hasFormula && fieldType !== FilterFieldType.File
7125
+ formulaExpression: formula?.expression(),
7126
+ isSortable: !isDeprecated && fieldType !== FilterFieldType.File
7116
7127
  };
7117
7128
  }
7118
7129
 
@@ -11115,6 +11126,9 @@ function clearPendingContextMenu() {
11115
11126
  function setPendingContextMenu(value) {
11116
11127
  pendingContextMenu = value;
11117
11128
  }
11129
+ function hasPendingContextMenu() {
11130
+ return pendingContextMenu !== null;
11131
+ }
11118
11132
  const cellMenuRegistry = /* @__PURE__ */ new WeakMap();
11119
11133
  function useCellContextMenu(cell, cellRef, deferredEdit) {
11120
11134
  const [menuAnchor, setMenuAnchor] = (0, react.useState)(null);
@@ -11371,7 +11385,7 @@ const CellWrapper = (0, mobx_react_lite.observer)(({ cell, children, onDoubleCli
11371
11385
  const handleMouseDown = (0, react.useCallback)((e) => {
11372
11386
  if (e.detail === 2 && state !== "readonly" && state !== "readonlyFocused") e.preventDefault();
11373
11387
  if (e.button === 2) {
11374
- openContextMenuAt(e.clientX, e.clientY);
11388
+ if (!hasPendingContextMenu()) openContextMenuAt(e.clientX, e.clientY);
11375
11389
  return;
11376
11390
  }
11377
11391
  if (!e.shiftKey && e.button === 0 && state !== "editing") {
@@ -13673,6 +13687,35 @@ const RowActionsMenu = ({ rowId, onSelect, onDuplicate, onDelete }) => {
13673
13687
  });
13674
13688
  };
13675
13689
 
13690
+ //#endregion
13691
+ //#region src/table-editor/Status/model/CellInfoModel.ts
13692
+ var CellInfoModel = class {
13693
+ _cellFSM;
13694
+ _columnsModel;
13695
+ constructor(cellFSM, columnsModel) {
13696
+ this._cellFSM = cellFSM;
13697
+ this._columnsModel = columnsModel;
13698
+ (0, mobx.makeAutoObservable)(this, {}, { autoBind: true });
13699
+ }
13700
+ get _focusedColumn() {
13701
+ const focused = this._cellFSM.focusedCell;
13702
+ if (!focused) return null;
13703
+ return this._columnsModel.allColumns.find((c) => c.field === focused.field) ?? null;
13704
+ }
13705
+ get isVisible() {
13706
+ return this._focusedColumn !== null && !this._cellFSM.hasSelection;
13707
+ }
13708
+ get fieldLabel() {
13709
+ return this._focusedColumn?.label ?? "";
13710
+ }
13711
+ get formulaExpression() {
13712
+ return this._focusedColumn?.formulaExpression;
13713
+ }
13714
+ get foreignKeyTableId() {
13715
+ return this._focusedColumn?.foreignKeyTableId;
13716
+ }
13717
+ };
13718
+
13676
13719
  //#endregion
13677
13720
  //#region src/table-editor/Status/model/RowCountModel.ts
13678
13721
  var RowCountModel = class {
@@ -13786,6 +13829,7 @@ var TableEditorCore = class {
13786
13829
  cellFSM;
13787
13830
  selection;
13788
13831
  rowCount;
13832
+ cellInfo;
13789
13833
  _dataSource;
13790
13834
  _pageSize;
13791
13835
  _breadcrumbs;
@@ -13814,6 +13858,7 @@ var TableEditorCore = class {
13814
13858
  this.cellFSM = new CellFSM();
13815
13859
  this.selection = new SelectionModel();
13816
13860
  this.rowCount = new RowCountModel();
13861
+ this.cellInfo = new CellInfoModel(this.cellFSM, this.columns);
13817
13862
  this.columns.setOnChange(() => this._handleColumnsChange());
13818
13863
  this.filters.setOnChange(() => this._handleFilterChange());
13819
13864
  this.filters.setOnApply((_where) => this._handleFilterApply());
@@ -14158,6 +14203,50 @@ var MockDataSource = class {
14158
14203
  }
14159
14204
  };
14160
14205
 
14206
+ //#endregion
14207
+ //#region src/table-editor/Status/ui/CellInfoWidget.tsx
14208
+ const CellInfoWidget = (0, mobx_react_lite.observer)(({ model }) => {
14209
+ if (!model.isVisible) return null;
14210
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_chakra_ui_react.Flex, {
14211
+ alignItems: "center",
14212
+ gap: 2,
14213
+ overflow: "hidden",
14214
+ flexShrink: 1,
14215
+ minW: 0,
14216
+ children: [
14217
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_chakra_ui_react.Text, {
14218
+ fontSize: "sm",
14219
+ color: "gray.500",
14220
+ overflow: "hidden",
14221
+ textOverflow: "ellipsis",
14222
+ whiteSpace: "nowrap",
14223
+ "data-testid": "cell-info-field",
14224
+ children: model.fieldLabel
14225
+ }),
14226
+ model.formulaExpression && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_chakra_ui_react.Text, {
14227
+ fontSize: "sm",
14228
+ color: "purple.400",
14229
+ fontFamily: "mono",
14230
+ overflow: "hidden",
14231
+ textOverflow: "ellipsis",
14232
+ whiteSpace: "nowrap",
14233
+ "data-testid": "cell-info-formula",
14234
+ children: ["= ", model.formulaExpression]
14235
+ }),
14236
+ model.foreignKeyTableId && !model.formulaExpression && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_chakra_ui_react.Text, {
14237
+ fontSize: "sm",
14238
+ color: "blue.400",
14239
+ fontFamily: "mono",
14240
+ overflow: "hidden",
14241
+ textOverflow: "ellipsis",
14242
+ whiteSpace: "nowrap",
14243
+ "data-testid": "cell-info-fk",
14244
+ children: ["→ ", model.foreignKeyTableId]
14245
+ })
14246
+ ]
14247
+ });
14248
+ });
14249
+
14161
14250
  //#endregion
14162
14251
  //#region src/table-editor/Status/ui/RowCountWidget.tsx
14163
14252
  const RowCountWidget = (0, mobx_react_lite.observer)(({ model }) => {
@@ -14340,14 +14429,24 @@ const TableEditor = (0, mobx_react_lite.observer)(({ viewModel, useWindowScroll
14340
14429
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_chakra_ui_react.Flex, {
14341
14430
  px: 3,
14342
14431
  py: 2,
14343
- justifyContent: "space-between",
14432
+ alignItems: "center",
14433
+ overflow: "hidden",
14344
14434
  ...useWindowScroll && {
14345
14435
  position: "sticky",
14346
14436
  bottom: 0,
14347
14437
  bg: "white",
14348
14438
  zIndex: 3
14349
14439
  },
14350
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(RowCountWidget, { model: viewModel.rowCount }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ViewSettingsBadge, { model: viewModel.viewBadge })]
14440
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(RowCountWidget, { model: viewModel.rowCount }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_chakra_ui_react.Flex, {
14441
+ alignItems: "center",
14442
+ gap: 3,
14443
+ overflow: "hidden",
14444
+ flexShrink: 1,
14445
+ minW: 0,
14446
+ flex: 1,
14447
+ justifyContent: "flex-end",
14448
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(CellInfoWidget, { model: viewModel.cellInfo }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ViewSettingsBadge, { model: viewModel.viewBadge })]
14449
+ })]
14351
14450
  })
14352
14451
  ]
14353
14452
  });