From 5ee682d9179e6cb9e89bc82496bda9c33b7ffec8 Mon Sep 17 00:00:00 2001 From: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:27:27 +1200 Subject: [PATCH 1/3] feat: allow disabling individual keyboard controls with `null` (#333) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Any `keyboardControls` binding can now be set to `null` to disable it. A disabled binding is no longer intercepted, so the key falls through to its native browser behaviour — e.g. `{ tabForward: null, tabBack: null }` restores normal Tab/Shift-Tab focus traversal instead of moving between editable nodes. - `eventMatch` treats a `null` control as "never matches"; since `handleKeyPress` only `preventDefault()`s on a match, native behaviour passes through. - `getFullKeyboardControlMap` now distinguishes `undefined` (keep default) from `null` (disable). The two modifier-array controls store `[]` when disabled so their `.includes()` consumers stay null-safe. - An explicitly disabled per-type confirm is no longer re-enabled by the generic `confirm` fallback. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/disable-keyboard-controls.md | 5 ++ README.md | 1 + src/types.ts | 34 +++++++------ src/utils/keyboard.ts | 41 +++++++++++----- test/keyboard.test.ts | 65 +++++++++++++++++++++++++ 5 files changed, 118 insertions(+), 28 deletions(-) create mode 100644 .changeset/disable-keyboard-controls.md diff --git a/.changeset/disable-keyboard-controls.md b/.changeset/disable-keyboard-controls.md new file mode 100644 index 00000000..b3c119de --- /dev/null +++ b/.changeset/disable-keyboard-controls.md @@ -0,0 +1,5 @@ +--- +'json-edit-react': minor +--- + +Allow any `keyboardControls` binding to be disabled by setting it to `null`. A disabled binding is no longer intercepted and falls through to its native browser behaviour — e.g. `{ tabForward: null, tabBack: null }` restores normal Tab/Shift-Tab focus traversal instead of moving between editable nodes. diff --git a/README.md b/README.md index 670e6e11..5184f542 100644 --- a/README.md +++ b/README.md @@ -1288,6 +1288,7 @@ If (for example), you just wish to change the general "confirmation" action to " - On Mac, "Meta" refers to the "Cmd" key, and "Alt" refers to "Option" - If multiple modifiers are specified (in an array), *any* of them will be accepted (multi-modifier commands not currently supported) - You only need to specify values for `stringConfirm`, `numberConfirm`, and `booleanConfirm` if they should *differ* from your `confirm` value. +- To **disable** a control entirely, set it to `null`. The key is then no longer intercepted and falls through to its native browser behaviour. This is useful when the default bindings aren't appropriate for your data — for example, `{ tabForward: null, tabBack: null }` turns off Tab navigation between editable nodes so that Tab/Shift-Tab resume their normal focus-traversal behaviour. (The two modifier controls, `clipboardModifier` and `collapseModifier`, can likewise be disabled with `null` or an empty array `[]`.) - You won't be able to override system or browser behaviours: for example, on Mac "Ctrl-click" will perform a right-click, so using it as a click modifier won't work (hence we also accept "Meta"/"Cmd" as the default `clipboardModifier`). ## External control diff --git a/src/types.ts b/src/types.ts index f59cf9da..f1e23d10 100644 --- a/src/types.ts +++ b/src/types.ts @@ -379,26 +379,28 @@ export interface KeyEvent { key: string modifier?: React.ModifierKey | React.ModifierKey[] } +// Any control can be set to `null` to disable it — the key then falls through +// to its native browser behaviour (e.g. Tab resumes normal focus traversal). export interface KeyboardControls { - confirm?: KeyEvent | string // value node defaults, key entry - cancel?: KeyEvent | string // all "Cancel" operations - objectConfirm?: KeyEvent | string - objectLineBreak?: KeyEvent | string - stringConfirm?: KeyEvent | string - stringLineBreak?: KeyEvent | string // for Value nodes - booleanConfirm?: KeyEvent | string - booleanToggle?: KeyEvent | string - numberConfirm?: KeyEvent | string - numberUp?: KeyEvent | string - numberDown?: KeyEvent | string - tabForward?: KeyEvent | string - tabBack?: KeyEvent | string - clipboardModifier?: React.ModifierKey | React.ModifierKey[] - collapseModifier?: React.ModifierKey | React.ModifierKey[] + confirm?: KeyEvent | string | null // value node defaults, key entry + cancel?: KeyEvent | string | null // all "Cancel" operations + objectConfirm?: KeyEvent | string | null + objectLineBreak?: KeyEvent | string | null + stringConfirm?: KeyEvent | string | null + stringLineBreak?: KeyEvent | string | null // for Value nodes + booleanConfirm?: KeyEvent | string | null + booleanToggle?: KeyEvent | string | null + numberConfirm?: KeyEvent | string | null + numberUp?: KeyEvent | string | null + numberDown?: KeyEvent | string | null + tabForward?: KeyEvent | string | null + tabBack?: KeyEvent | string | null + clipboardModifier?: React.ModifierKey | React.ModifierKey[] | null + collapseModifier?: React.ModifierKey | React.ModifierKey[] | null } export type KeyboardControlsFull = Omit< - Required<{ [Property in keyof KeyboardControls]: KeyEvent }>, + Required<{ [Property in keyof KeyboardControls]: KeyEvent | null }>, 'clipboardModifier' | 'collapseModifier' > & { clipboardModifier: React.ModifierKey[] diff --git a/src/utils/keyboard.ts b/src/utils/keyboard.ts index 74e9b9e0..df6313b7 100644 --- a/src/utils/keyboard.ts +++ b/src/utils/keyboard.ts @@ -45,9 +45,12 @@ export const getModifier = ( // Determines whether a keyboard event matches a predefined value const eventMatch = ( e: React.KeyboardEvent, - keyEvent: KeyEvent | React.ModifierKey[], + keyEvent: KeyEvent | React.ModifierKey[] | null, definition: string ) => { + // A `null` control means the binding is disabled — never match it, so the + // key falls through to native browser behaviour (e.g. Tab moves focus). + if (!keyEvent) return false const eventKey = e.key const eventModifier = getModifier(e) if (Array.isArray(keyEvent)) return eventModifier ? keyEvent.includes(eventModifier) : false @@ -104,24 +107,38 @@ export const getFullKeyboardControlMap = (userControls: KeyboardControls): Keybo const controls = { ...defaultKeyboardControls } for (const key of Object.keys(defaultKeyboardControls)) { const typedKey = key as keyof KeyboardControls - if (userControls[typedKey]) { - const value = userControls[typedKey] + const value = userControls[typedKey] - const definition = (() => { - if (['clipboardModifier', 'collapseModifier'].includes(key)) - return Array.isArray(value) ? value : [value] - if (typeof value === 'string') return { key: value } - return value - })() as KeyEvent & React.ModifierKey[] + // Not supplied → keep the default binding + if (value === undefined) continue - controls[typedKey] = definition + const isModifierKey = key === 'clipboardModifier' || key === 'collapseModifier' + + // `null` disables the binding. The modifier-array controls + // (`clipboardModifier`/`collapseModifier`) are consumed via `.includes()`, + // which is always false for `[]`, so we store an empty array and they stay + // non-null. Every other (`KeyEvent`) control is stored as `null`, which + // `eventMatch` treats as "never matches". + if (value === null) { + controls[typedKey] = (isModifierKey ? [] : null) as unknown as KeyEvent & React.ModifierKey[] + continue } + + const definition = (() => { + if (isModifierKey) return Array.isArray(value) ? value : [value] + if (typeof value === 'string') return { key: value } + return value + })() as KeyEvent & React.ModifierKey[] + + controls[typedKey] = definition } // Apply the generic "confirm" fallback once, after the loop has fully - // resolved any user-supplied "confirm" control. + // resolved any user-supplied "confirm" control. An explicitly disabled + // (`null`) per-type confirm is left alone — only an unset (`undefined`) one + // inherits the generic confirm. confirmFallbackKeys.forEach((key) => { - if (!userControls[key] && userControls.confirm) + if (userControls[key] === undefined && userControls.confirm) controls[key] = controls.confirm as KeyEvent & React.ModifierKey[] }) diff --git a/test/keyboard.test.ts b/test/keyboard.test.ts index 90e856da..dd2ad866 100644 --- a/test/keyboard.test.ts +++ b/test/keyboard.test.ts @@ -2,6 +2,7 @@ import { type MutableRefObject } from 'react' import { getFullKeyboardControlMap, getNextOrPrevious, + handleKeyPress, insertCharInTextArea, } from '../src/utils/keyboard' @@ -95,6 +96,70 @@ describe('getFullKeyboardControlMap', () => { expect(controls.numberConfirm).toEqual({ key: 'Tab' }) expect(controls.booleanConfirm).toEqual({ key: 'Tab' }) }) + + test('stores `null` for a disabled (null) KeyEvent control', () => { + const controls = getFullKeyboardControlMap({ tabForward: null, tabBack: null }) + expect(controls.tabForward).toBeNull() + expect(controls.tabBack).toBeNull() + // Untouched controls keep their defaults + expect(controls.confirm).toEqual({ key: 'Enter' }) + }) + + test('an explicitly disabled (null) per-type confirm is not re-enabled by the generic confirm fallback', () => { + const controls = getFullKeyboardControlMap({ confirm: 'Enter', stringConfirm: null }) + expect(controls.stringConfirm).toBeNull() + // The other value-node confirms still inherit the generic confirm + expect(controls.numberConfirm).toEqual({ key: 'Enter' }) + expect(controls.booleanConfirm).toEqual({ key: 'Enter' }) + }) + + test('disables a modifier control by storing an empty array, not null', () => { + // `clipboardModifier`/`collapseModifier` are consumed via `.includes()`, so + // an empty array disables them while keeping the field a real array. + const controls = getFullKeyboardControlMap({ clipboardModifier: null, collapseModifier: null }) + expect(controls.clipboardModifier).toEqual([]) + expect(controls.collapseModifier).toEqual([]) + }) +}) + +describe('handleKeyPress with disabled controls', () => { + // A minimal stand-in for the React.KeyboardEvent that the real handler + // receives — only the fields `eventMatch` reads, plus a `preventDefault` spy. + const makeEvent = (key: string, modifiers: Record = {}) => { + const preventDefault = jest.fn() + const e = { + key, + preventDefault, + shiftKey: false, + metaKey: false, + ctrlKey: false, + altKey: false, + ...modifiers, + } as unknown as Parameters[2] + return { e, preventDefault } + } + + test('a disabled (null) binding never fires its action and lets the key fall through (no preventDefault)', () => { + const controls = getFullKeyboardControlMap({ tabForward: null }) + const action = jest.fn() + const { e, preventDefault } = makeEvent('Tab') + + handleKeyPress(controls, { tabForward: action }, e) + + expect(action).not.toHaveBeenCalled() + expect(preventDefault).not.toHaveBeenCalled() + }) + + test('a binding left at its default still fires and suppresses native behaviour (control case)', () => { + const controls = getFullKeyboardControlMap({}) + const action = jest.fn() + const { e, preventDefault } = makeEvent('Tab') + + handleKeyPress(controls, { tabForward: action }, e) + + expect(action).toHaveBeenCalledTimes(1) + expect(preventDefault).toHaveBeenCalledTimes(1) + }) }) describe('getNextOrPrevious', () => { From 5b8d21f3d28b8760f16db3223010993c8ef6cdcb Mon Sep 17 00:00:00 2001 From: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:45:00 +1200 Subject: [PATCH 2/3] fix: make `confirm: null` cascade to disable per-type value confirms The generic `confirm` already cascades its *value* to `stringConfirm` / `numberConfirm` / `booleanConfirm`, but a `null` (disable) was skipped by the fallback, so `confirm: null` left Enter-to-submit working on those nodes. Treat "explicitly provided" (value or `null`) as cascading and only "not provided" (`undefined`) as keeping the per-type defaults, so `confirm: null` disables Enter-submit across string, number, boolean and null editors at once. An explicit per-type confirm still wins. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 4 ++-- demo/src/App.tsx | 28 +++++++++++++--------------- src/utils/keyboard.ts | 18 +++++++++++++----- test/keyboard.test.ts | 17 +++++++++++++++++ 4 files changed, 45 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 5184f542..d70d73cc 100644 --- a/README.md +++ b/README.md @@ -1287,8 +1287,8 @@ If (for example), you just wish to change the general "confirmation" action to " - Accepted modifiers are "Meta", "Control", "Alt", "Shift" - On Mac, "Meta" refers to the "Cmd" key, and "Alt" refers to "Option" - If multiple modifiers are specified (in an array), *any* of them will be accepted (multi-modifier commands not currently supported) -- You only need to specify values for `stringConfirm`, `numberConfirm`, and `booleanConfirm` if they should *differ* from your `confirm` value. -- To **disable** a control entirely, set it to `null`. The key is then no longer intercepted and falls through to its native browser behaviour. This is useful when the default bindings aren't appropriate for your data — for example, `{ tabForward: null, tabBack: null }` turns off Tab navigation between editable nodes so that Tab/Shift-Tab resume their normal focus-traversal behaviour. (The two modifier controls, `clipboardModifier` and `collapseModifier`, can likewise be disabled with `null` or an empty array `[]`.) +- You only need to specify values for `stringConfirm`, `numberConfirm`, and `booleanConfirm` if they should *differ* from your `confirm` value — they inherit whatever you set `confirm` to, including `null`. +- To **disable** a control entirely, set it to `null`. The key is then no longer intercepted and falls through to its native browser behaviour. This is useful when the default bindings aren't appropriate for your data — for example, `{ tabForward: null, tabBack: null }` turns off Tab navigation between editable nodes so that Tab/Shift-Tab resume their normal focus-traversal behaviour. Because the per-type confirms inherit from `confirm`, setting `confirm: null` disables Enter-to-submit across string, number, boolean and null value editors at once (object/array nodes use `objectConfirm`, so disable that separately if needed). The two modifier controls, `clipboardModifier` and `collapseModifier`, can likewise be disabled with `null` or an empty array `[]`. - You won't be able to override system or browser behaviours: for example, on Mac "Ctrl-click" will perform a right-click, so using it as a click modifier won't work (hence we also accept "Meta"/"Cmd" as the default `clipboardModifier`). ## External control diff --git a/demo/src/App.tsx b/demo/src/App.tsx index c113e197..268b7fef 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -636,21 +636,19 @@ function App() { // ]} onChange={dataDefinition?.onChange ?? undefined} jsonParse={JSON5.parse} - keyboardControls={ - { - // cancel: 'Tab', - // confirm: { key: 'Enter', modifier: 'Meta' }, - // objectConfirm: { key: 'Enter', modifier: 'Shift' }, - // stringLineBreak: { key: 'Enter' }, - // stringConfirm: { key: 'Enter', modifier: 'Meta' }, - // clipboardModifier: ['Alt', 'Shift'], - // collapseModifier: 'Control', - // booleanConfirm: 'Enter', - // booleanToggle: 'r', - // tabForward: { key: 'Tab', modifier: 'Shift' }, - // tabBack: { key: 'Tab' }, - } - } + keyboardControls={{ + // cancel: 'Tab', + // confirm: { key: 'UpArrow', modifier: 'Shift' }, + // objectConfirm: { key: 'Enter', modifier: 'Shift' }, + // stringLineBreak: { key: 'Enter' }, + // stringConfirm: { key: 'Enter', modifier: 'Meta' }, + // clipboardModifier: ['Alt', 'Shift'], + // collapseModifier: 'Control', + // booleanConfirm: 'Enter', + booleanToggle: 'r', + tabForward: null, + // tabBack: { key: 'Tab' }, + }} // insertAtBeginning="object" // baseFontSize={20} TextEditor={ diff --git a/src/utils/keyboard.ts b/src/utils/keyboard.ts index df6313b7..02e0ef1a 100644 --- a/src/utils/keyboard.ts +++ b/src/utils/keyboard.ts @@ -134,11 +134,13 @@ export const getFullKeyboardControlMap = (userControls: KeyboardControls): Keybo } // Apply the generic "confirm" fallback once, after the loop has fully - // resolved any user-supplied "confirm" control. An explicitly disabled - // (`null`) per-type confirm is left alone — only an unset (`undefined`) one - // inherits the generic confirm. + // resolved any user-supplied "confirm" control. A per-type confirm inherits + // the generic confirm whenever `confirm` is explicitly provided — including + // `null`, so `confirm: null` disables all of them. An explicitly set + // per-type confirm (value or `null`) always wins, and an unset generic + // confirm leaves the per-type defaults untouched. confirmFallbackKeys.forEach((key) => { - if (userControls[key] === undefined && userControls.confirm) + if (userControls[key] === undefined && userControls.confirm !== undefined) controls[key] = controls.confirm as KeyEvent & React.ModifierKey[] }) @@ -207,7 +209,13 @@ export const getNextOrPrevious = ( let candidate: CollectionKey[] | null if (isCollection(destination.value)) { if (Object.keys(destination.value).length === 0) { - return getNextOrPrevious(fullData, [...parentPath, destination.key], nextOrPrev, sort, isViable) + return getNextOrPrevious( + fullData, + [...parentPath, destination.key], + nextOrPrev, + sort, + isViable + ) } candidate = getChildRecursive(fullData, [...parentPath, destination.key], nextOrPrev, sort) } else { diff --git a/test/keyboard.test.ts b/test/keyboard.test.ts index dd2ad866..73127536 100644 --- a/test/keyboard.test.ts +++ b/test/keyboard.test.ts @@ -113,6 +113,23 @@ describe('getFullKeyboardControlMap', () => { expect(controls.booleanConfirm).toEqual({ key: 'Enter' }) }) + test('a disabled (null) generic confirm cascades to all inheriting value-node confirms', () => { + const controls = getFullKeyboardControlMap({ confirm: null }) + expect(controls.confirm).toBeNull() + expect(controls.stringConfirm).toBeNull() + expect(controls.numberConfirm).toBeNull() + expect(controls.booleanConfirm).toBeNull() + }) + + test('an explicit per-type confirm still wins when the generic confirm is disabled', () => { + const controls = getFullKeyboardControlMap({ confirm: null, stringConfirm: 'Tab' }) + expect(controls.confirm).toBeNull() + expect(controls.stringConfirm).toEqual({ key: 'Tab' }) + // The non-overridden ones still follow the disabled generic confirm + expect(controls.numberConfirm).toBeNull() + expect(controls.booleanConfirm).toBeNull() + }) + test('disables a modifier control by storing an empty array, not null', () => { // `clipboardModifier`/`collapseModifier` are consumed via `.includes()`, so // an empty array disables them while keeping the field a real array. From 747ee1209cd8a980d76d02291a826b243f25c054 Mon Sep 17 00:00:00 2001 From: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:56:25 +1200 Subject: [PATCH 3/3] Revert app changes --- demo/src/App.tsx | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/demo/src/App.tsx b/demo/src/App.tsx index 268b7fef..c113e197 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -636,19 +636,21 @@ function App() { // ]} onChange={dataDefinition?.onChange ?? undefined} jsonParse={JSON5.parse} - keyboardControls={{ - // cancel: 'Tab', - // confirm: { key: 'UpArrow', modifier: 'Shift' }, - // objectConfirm: { key: 'Enter', modifier: 'Shift' }, - // stringLineBreak: { key: 'Enter' }, - // stringConfirm: { key: 'Enter', modifier: 'Meta' }, - // clipboardModifier: ['Alt', 'Shift'], - // collapseModifier: 'Control', - // booleanConfirm: 'Enter', - booleanToggle: 'r', - tabForward: null, - // tabBack: { key: 'Tab' }, - }} + keyboardControls={ + { + // cancel: 'Tab', + // confirm: { key: 'Enter', modifier: 'Meta' }, + // objectConfirm: { key: 'Enter', modifier: 'Shift' }, + // stringLineBreak: { key: 'Enter' }, + // stringConfirm: { key: 'Enter', modifier: 'Meta' }, + // clipboardModifier: ['Alt', 'Shift'], + // collapseModifier: 'Control', + // booleanConfirm: 'Enter', + // booleanToggle: 'r', + // tabForward: { key: 'Tab', modifier: 'Shift' }, + // tabBack: { key: 'Tab' }, + } + } // insertAtBeginning="object" // baseFontSize={20} TextEditor={