@revisium/schema-toolkit-ui 0.6.8 → 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) => {
@@ -11117,6 +11126,9 @@ function clearPendingContextMenu() {
11117
11126
  function setPendingContextMenu(value) {
11118
11127
  pendingContextMenu = value;
11119
11128
  }
11129
+ function hasPendingContextMenu() {
11130
+ return pendingContextMenu !== null;
11131
+ }
11120
11132
  const cellMenuRegistry = /* @__PURE__ */ new WeakMap();
11121
11133
  function useCellContextMenu(cell, cellRef, deferredEdit) {
11122
11134
  const [menuAnchor, setMenuAnchor] = (0, react.useState)(null);
@@ -11373,7 +11385,7 @@ const CellWrapper = (0, mobx_react_lite.observer)(({ cell, children, onDoubleCli
11373
11385
  const handleMouseDown = (0, react.useCallback)((e) => {
11374
11386
  if (e.detail === 2 && state !== "readonly" && state !== "readonlyFocused") e.preventDefault();
11375
11387
  if (e.button === 2) {
11376
- openContextMenuAt(e.clientX, e.clientY);
11388
+ if (!hasPendingContextMenu()) openContextMenuAt(e.clientX, e.clientY);
11377
11389
  return;
11378
11390
  }
11379
11391
  if (!e.shiftKey && e.button === 0 && state !== "editing") {