Skip to content

Commit d15928c

Browse files
authored
64 Tab key navigation (#158)
Including handling non-editable and re-ordered nodes
1 parent 1149f4e commit d15928c

15 files changed

+2275
-123
lines changed

.eslintignore

+2
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
*.css
22
.eslintrc.js
3+
*.test.*
4+
jest.config.js

README.md

+48-47
Large diffs are not rendered by default.

demo/src/App.tsx

+16
Original file line numberDiff line numberDiff line change
@@ -367,13 +367,29 @@ function App() {
367367
: false
368368
}
369369
restrictEdit={restrictEdit}
370+
// restrictEdit={(nodeData) => !(typeof nodeData.value === 'string')}
370371
restrictDelete={restrictDelete}
371372
restrictAdd={restrictAdd}
372373
restrictTypeSelection={dataDefinition?.restrictTypeSelection}
373374
restrictDrag={false}
374375
searchFilter={dataDefinition?.searchFilter}
375376
searchText={searchText}
376377
keySort={sortKeys}
378+
// keySort={
379+
// sortKeys
380+
// ? (a, b) => {
381+
// const nameRev1 = String(a[0]).length
382+
// const nameRev2 = String(b[0]).length
383+
// if (nameRev1 < nameRev2) {
384+
// return -1
385+
// }
386+
// if (nameRev1 > nameRev2) {
387+
// return 1
388+
// }
389+
// return 0
390+
// }
391+
// : false
392+
// }
377393
defaultValue={dataDefinition?.defaultValue ?? defaultNewValue}
378394
showArrayIndices={showIndices}
379395
showStringQuotes={showStringQuotes}

demo/src/demoData/dataDefinitions.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
UpdateFunction,
2222
UpdateFunctionProps,
2323
} from '../json-edit-react/src/types'
24-
import { Input } from 'object-property-assigner/build'
24+
import { type Input } from 'object-property-assigner'
2525
import jsonSchema from './jsonSchema.json'
2626
import customNodesSchema from './customNodesSchema.json'
2727
import Ajv from 'ajv'
@@ -91,6 +91,7 @@ export const demoDataDefinitions: Record<string, DemoData> = {
9191
collapse: 2,
9292
data: data.intro,
9393
customNodeDefinitions: [dateNodeDefinition],
94+
// restrictEdit: ({ key }) => key === 'number',
9495
},
9596
starWars: {
9697
name: '🚀 Star Wars',

jest.config.js

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
module.exports = {
2+
roots: ['<rootDir>/test'],
3+
testMatch: ['**/__tests__/**/*.+(ts|tsx|js)', '**/?(*.)+(spec|test).+(ts|tsx|js)'],
4+
transform: {
5+
'^.+\\.(ts|tsx)$': 'ts-jest',
6+
},
7+
verbose: true,
8+
testTimeout: 10000,
9+
}

package.json

+4
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"homepage": "https://carlosnz.github.io/json-edit-react",
2424
"scripts": {
2525
"setup": "yarn install && cd demo && yarn install",
26+
"test": "jest",
2627
"demo": "cd demo && node ./scripts/getVersion.js && yarn && yarn start",
2728
"build": "rimraf ./build && rollup -c && rimraf ./build/dts",
2829
"lint": "npx eslint \"src/**\"",
@@ -48,6 +49,7 @@
4849
"@rollup/plugin-node-resolve": "^16.0.0",
4950
"@rollup/plugin-terser": "^0.4.4",
5051
"@rollup/plugin-typescript": "^11.1.6",
52+
"@types/jest": "^29.5.14",
5153
"@types/node": "^20.11.17",
5254
"@types/react": ">=16.0.0",
5355
"@typescript-eslint/eslint-plugin": "^6.4.0",
@@ -60,6 +62,7 @@
6062
"eslint-plugin-react": "^7.33.2",
6163
"eslint-plugin-react-hooks": "^4.6.0",
6264
"fs-extra": "^11.2.0",
65+
"jest": "^29.7.0",
6366
"react-dom": ">=16.0.0",
6467
"rimraf": "^5.0.5",
6568
"rollup": "^4.10.0",
@@ -68,6 +71,7 @@
6871
"rollup-plugin-peer-deps-external": "^2.2.4",
6972
"rollup-plugin-sizes": "^1.0.6",
7073
"rollup-plugin-styles": "^4.0.0",
74+
"ts-jest": "^29.2.5",
7175
"ts-node": "^10.9.2",
7276
"tslib": "^2.6.2",
7377
"typescript": "^5.3.3"

src/CollectionNode.tsx

+65-21
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,20 @@ import React, { useEffect, useState, useMemo, useRef } from 'react'
22
import { ValueNodeWrapper } from './ValueNodeWrapper'
33
import { EditButtons, InputButtons } from './ButtonPanels'
44
import { getCustomNode } from './CustomNode'
5-
import { type CollectionNodeProps, type NodeData, type CollectionData } from './types'
5+
import {
6+
type CollectionNodeProps,
7+
type NodeData,
8+
type CollectionData,
9+
type ValueData,
10+
} from './types'
611
import { Icon } from './Icons'
7-
import { filterNode, getModifier, isCollection } from './helpers'
12+
import {
13+
filterNode,
14+
getModifier,
15+
getNextOrPrevious,
16+
insertCharInTextArea,
17+
isCollection,
18+
} from './helpers'
819
import { AutogrowTextArea } from './AutogrowTextArea'
920
import { useTheme, useTreeState } from './contexts'
1021
import { useCollapseTransition, useCommon, useDragNDrop } from './hooks'
@@ -36,7 +47,7 @@ export const CollectionNode: React.FC<CollectionNodeProps> = (props) => {
3647
searchFilter,
3748
searchText,
3849
indent,
39-
keySort,
50+
sort,
4051
showArrayIndices,
4152
defaultValue,
4253
translate,
@@ -101,6 +112,9 @@ export const CollectionNode: React.FC<CollectionNodeProps> = (props) => {
101112
}
102113
}, [collapseState])
103114

115+
// For JSON-editing TextArea
116+
const textAreaRef = useRef<HTMLTextAreaElement>(null)
117+
104118
const getDefaultNewValue = useMemo(
105119
() => (nodeData: NodeData, newKey: string) => {
106120
if (typeof defaultValue !== 'function') return defaultValue
@@ -121,19 +135,38 @@ export const CollectionNode: React.FC<CollectionNodeProps> = (props) => {
121135
showCollectionWrapper = true,
122136
} = useMemo(() => getCustomNode(customNodeDefinitions, nodeData), [])
123137

138+
const childrenEditing = areChildrenBeingEdited(pathString)
139+
140+
// For when children are accessed via Tab
141+
if (childrenEditing && collapsed) animateCollapse(false)
142+
124143
// Early return if this node is filtered out
125-
if (!filterNode('collection', nodeData, searchFilter, searchText) && nodeData.level > 0)
126-
return null
144+
const isVisible =
145+
filterNode('collection', nodeData, searchFilter, searchText) || nodeData.level === 0
146+
if (!isVisible && !childrenEditing) return null
127147

128148
const collectionType = Array.isArray(data) ? 'array' : 'object'
129149
const brackets =
130150
collectionType === 'array' ? { open: '[', close: ']' } : { open: '{', close: '}' }
131151

132-
const handleKeyPressEdit = (e: React.KeyboardEvent) =>
152+
const handleKeyPressEdit = (e: React.KeyboardEvent) => {
153+
// Normal "Tab" key functionality in TextArea
154+
// Defined here explicitly rather than in handleKeyboard as we *don't* want
155+
// to override the normal Tab key with the custom "Tab" key value
156+
if (e.key === 'Tab' && !e.getModifierState('Shift')) {
157+
e.preventDefault()
158+
const newValue = insertCharInTextArea(
159+
textAreaRef as React.MutableRefObject<HTMLTextAreaElement>,
160+
'\t'
161+
)
162+
setStringifiedValue(newValue)
163+
return
164+
}
133165
handleKeyboard(e, {
134166
objectConfirm: handleEdit,
135167
cancel: handleCancel,
136168
})
169+
}
137170

138171
const handleCollapse = (e: React.MouseEvent) => {
139172
const modifier = getModifier(e)
@@ -212,18 +245,13 @@ export const CollectionNode: React.FC<CollectionNodeProps> = (props) => {
212245
const showKey = showLabel && !hideKey && name !== undefined
213246
const showCustomNodeContents =
214247
CustomNode && ((isEditing && showOnEdit) || (!isEditing && showOnView))
215-
const sortKeys = keySort && collectionType === 'object'
216248

217-
const keyValueArray = Object.entries(data).map(([key, value]) => [
218-
collectionType === 'array' ? Number(key) : key,
219-
value,
220-
])
249+
const keyValueArray = Object.entries(data).map(
250+
([key, value]) =>
251+
[collectionType === 'array' ? Number(key) : key, value] as [string | number, ValueData]
252+
)
221253

222-
if (sortKeys) {
223-
keyValueArray.sort(
224-
typeof keySort === 'function' ? (a: string[], b) => keySort(a[0], b[0] as string) : undefined
225-
)
226-
}
254+
if (collectionType === 'object') sort<[string | number, ValueData]>(keyValueArray, (_) => _)
227255

228256
const CollectionChildren = !hasBeenOpened.current ? null : !isEditing ? (
229257
keyValueArray.map(([key, value], index) => {
@@ -271,6 +299,7 @@ export const CollectionNode: React.FC<CollectionNodeProps> = (props) => {
271299
<div className="jer-collection-text-edit">
272300
<div>
273301
<AutogrowTextArea
302+
textAreaRef={textAreaRef}
274303
className="jer-collection-text-area"
275304
name={pathString}
276305
value={stringifiedValue}
@@ -290,7 +319,9 @@ export const CollectionNode: React.FC<CollectionNodeProps> = (props) => {
290319
// no way to open a collapsed custom node, so this ensures it will stay open.
291320
// It can still be displayed collapsed by handling it internally if this is
292321
// desired.
293-
const isCollapsed = !showCollectionWrapper ? false : collapsed
322+
// Also, if the node is editing via "Tab" key, it's parent must be opened,
323+
// hence `childrenEditing` check
324+
const isCollapsed = !showCollectionWrapper ? false : collapsed && !childrenEditing
294325
if (!isCollapsed) hasBeenOpened.current = true
295326

296327
const customNodeAllProps = {
@@ -304,7 +335,7 @@ export const CollectionNode: React.FC<CollectionNodeProps> = (props) => {
304335
handleCancel,
305336
handleKeyPress: handleKeyPressEdit,
306337
isEditing,
307-
setIsEditing: () => setCurrentlyEditingElement(pathString),
338+
setIsEditing: () => setCurrentlyEditingElement(path),
308339
getStyles,
309340
canDragOnto: canEdit,
310341
}
@@ -329,6 +360,19 @@ export const CollectionNode: React.FC<CollectionNodeProps> = (props) => {
329360
handleKeyboard(e, {
330361
stringConfirm: () => handleEditKey((e.target as HTMLInputElement).value),
331362
cancel: handleCancel,
363+
tabForward: () => {
364+
handleEditKey((e.target as HTMLInputElement).value)
365+
const firstChildKey = keyValueArray?.[0][0]
366+
setCurrentlyEditingElement(
367+
firstChildKey
368+
? [...path, firstChildKey]
369+
: getNextOrPrevious(nodeData.fullData, path, 'next', sort)
370+
)
371+
},
372+
tabBack: () => {
373+
handleEditKey((e.target as HTMLInputElement).value)
374+
setCurrentlyEditingElement(getNextOrPrevious(nodeData.fullData, path, 'prev', sort))
375+
},
332376
})
333377
}
334378
style={{ width: `${String(name).length / 1.5 + 0.5}em` }}
@@ -339,7 +383,7 @@ export const CollectionNode: React.FC<CollectionNodeProps> = (props) => {
339383
className="jer-key-text"
340384
style={getStyles('property', nodeData)}
341385
onClick={(e) => e.stopPropagation()}
342-
onDoubleClick={() => canEditKey && setCurrentlyEditingElement(`key_${pathString}`)}
386+
onDoubleClick={() => canEditKey && setCurrentlyEditingElement(path, 'key')}
343387
>
344388
{name === '' ? (
345389
<span className={path.length > 0 ? 'jer-empty-string' : undefined}>
@@ -358,7 +402,7 @@ export const CollectionNode: React.FC<CollectionNodeProps> = (props) => {
358402
canEdit
359403
? () => {
360404
hasBeenOpened.current = true
361-
setCurrentlyEditingElement(pathString)
405+
setCurrentlyEditingElement(path)
362406
}
363407
: undefined
364408
}
@@ -451,7 +495,7 @@ export const CollectionNode: React.FC<CollectionNodeProps> = (props) => {
451495
style={{
452496
overflowY: isCollapsed || isAnimating ? 'clip' : 'visible',
453497
// Prevent collapse if this node or any children are being edited
454-
maxHeight: areChildrenBeingEdited(pathString) ? undefined : maxHeight,
498+
maxHeight: childrenEditing ? undefined : maxHeight,
455499
...getStyles('collectionInner', nodeData),
456500
}}
457501
ref={contentRef}

src/JsonEditor.tsx

+32-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
type JsonData,
2424
type KeyboardControls,
2525
} from './types'
26-
import { useTheme, ThemeProvider, TreeStateProvider, defaultTheme } from './contexts'
26+
import { useTheme, ThemeProvider, TreeStateProvider, defaultTheme, useTreeState } from './contexts'
2727
import { useData } from './hooks/useData'
2828
import { getTranslateFunction } from './localisation'
2929
import { ValueNodeWrapper } from './ValueNodeWrapper'
@@ -75,6 +75,7 @@ const Editor: React.FC<JsonEditorProps> = ({
7575
insertAtTop = false,
7676
}) => {
7777
const { getStyles } = useTheme()
78+
const { setCurrentlyEditingElement } = useTreeState()
7879
const collapseFilter = useCallback(getFilterFunction(collapse), [collapse])
7980
const translate = useCallback(getTranslateFunction(translations, customText), [
8081
translations,
@@ -87,6 +88,7 @@ const Editor: React.FC<JsonEditorProps> = ({
8788
const mainContainerRef = useRef<HTMLDivElement>(null)
8889

8990
useEffect(() => {
91+
setCurrentlyEditingElement(null)
9092
const debounce = setTimeout(() => setDebouncedSearchText(searchText), searchDebounceTime)
9193
return () => clearTimeout(debounce)
9294
}, [searchText, searchDebounceTime])
@@ -263,6 +265,34 @@ const Editor: React.FC<JsonEditorProps> = ({
263265
[keyboardControls]
264266
)
265267

268+
// Common "sort" method for ordering nodes, based on the `keySort` prop
269+
// - If it's false (the default), we do nothing
270+
// - If true, use default array sort on the node's key
271+
// - Otherwise sort via the defined comparison function
272+
// The "nodeMap" is due to the fact that this sort is performed on different
273+
// shaped arrays in different places, so in each implementation we pass a
274+
// function to convert each element into a [key, value] tuple, the shape
275+
// expected by the comparison function
276+
const sort = useCallback(
277+
<T,>(arr: T[], nodeMap: (input: T) => [string | number, unknown]) => {
278+
if (keySort === false) return
279+
280+
if (typeof keySort === 'function') {
281+
arr.sort((a, b) => keySort(nodeMap(a), nodeMap(b)))
282+
return
283+
}
284+
285+
arr.sort((a, b) => {
286+
const A = nodeMap(a)[0]
287+
const B = nodeMap(b)[0]
288+
if (A < B) return -1
289+
if (A > B) return 1
290+
return 0
291+
})
292+
},
293+
[keySort]
294+
)
295+
266296
const otherProps = {
267297
mainContainerRef: mainContainerRef as React.MutableRefObject<Element>,
268298
name: rootName,
@@ -287,6 +317,7 @@ const Editor: React.FC<JsonEditorProps> = ({
287317
searchText: debouncedSearchText,
288318
enableClipboard,
289319
keySort,
320+
sort,
290321
showArrayIndices,
291322
showStringQuotes,
292323
indent,

0 commit comments

Comments
 (0)