Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/disable-keyboard-controls.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1287,7 +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.
- 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 <kbd>Tab</kbd>/<kbd>Shift-Tab</kbd> 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
Expand Down
34 changes: 18 additions & 16 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down
51 changes: 38 additions & 13 deletions src/utils/keyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -104,24 +107,40 @@ 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. 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] && userControls.confirm)
if (userControls[key] === undefined && userControls.confirm !== undefined)
controls[key] = controls.confirm as KeyEvent & React.ModifierKey[]
})

Expand Down Expand Up @@ -190,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 {
Expand Down
82 changes: 82 additions & 0 deletions test/keyboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { type MutableRefObject } from 'react'
import {
getFullKeyboardControlMap,
getNextOrPrevious,
handleKeyPress,
insertCharInTextArea,
} from '../src/utils/keyboard'

Expand Down Expand Up @@ -95,6 +96,87 @@ 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('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.
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<string, boolean> = {}) => {
const preventDefault = jest.fn()
const e = {
key,
preventDefault,
shiftKey: false,
metaKey: false,
ctrlKey: false,
altKey: false,
...modifiers,
} as unknown as Parameters<typeof handleKeyPress>[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', () => {
Expand Down
Loading