From 4f3140fe0e3a626c4244d0b6dcab0910bee0b76f Mon Sep 17 00:00:00 2001 From: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:06:50 +1300 Subject: [PATCH 1/3] Add Chips component for arrays of strings (#355) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders an array of strings as removable chips with an inline add. Hovering a chip reveals an "×" to remove it, and "+" adds one as free text, or from a dropdown when `componentProps.options` is set. `color` and `textColor` set the chip colours (default sky blue on dark blue). Outside an edit session each change commits on its own via `handleEdit(value)`; inside one, the same controls edit the buffer for ✓ to commit. The guard matches any all-string array, empty ones included, and the Type selector converts to and from a comma-separated string. Added to the Custom Component Library demo, with a second, targeted definition limited to a list of options. Co-Authored-By: Claude Opus 5.5 --- .../custom-component-library/Example.tsx | 18 ++- .../static/custom-component-library/data.ts | 5 + packages/components/CHANGELOG.md | 4 + packages/components/README.md | 25 ++++ .../components/scripts/verify-treeshake.mjs | 6 +- packages/components/src/Chips/component.tsx | 123 ++++++++++++++++++ packages/components/src/Chips/definition.ts | 41 ++++++ packages/components/src/Chips/index.ts | 2 + packages/components/src/Chips/style.css | 65 +++++++++ packages/components/src/index.ts | 1 + packages/components/test/Chips.test.tsx | 90 +++++++++++++ 11 files changed, 377 insertions(+), 3 deletions(-) create mode 100644 packages/components/src/Chips/component.tsx create mode 100644 packages/components/src/Chips/definition.ts create mode 100644 packages/components/src/Chips/index.ts create mode 100644 packages/components/src/Chips/style.css create mode 100644 packages/components/test/Chips.test.tsx diff --git a/demo/src/examples/static/custom-component-library/Example.tsx b/demo/src/examples/static/custom-component-library/Example.tsx index a7cec0e3..56ba70b1 100644 --- a/demo/src/examples/static/custom-component-library/Example.tsx +++ b/demo/src/examples/static/custom-component-library/Example.tsx @@ -20,6 +20,7 @@ import { colorPickerDefinition, markdownDefinition, numberFormatterDefinition, + chipsDefinition, } from '@json-edit-react/components' import { ReactDatePicker } from '@json-edit-react/components/widgets' import { byKey } from '@json-edit-react/utils/filters' @@ -33,7 +34,7 @@ import { SearchBox, useEditorDefaults } from '@example-resources' // an "enhanced" link, date/time editors, a UNIX-timestamp // editor, Markdown, an image, a colour picker, the // non-JSON types (`undefined`, `NaN`, `Symbol`, `BigInt`), -// and locale-aware number formatting. +// locale-aware number formatting, and tag-style chips. export { initialData, type CustomComponentLibraryData } @@ -133,6 +134,21 @@ export const customNodeDefinitions = (currentData: JsonData) => { symbolDefinition(), bigIntDefinition(), colorPickerDefinition(), + // Definitions match first-come, so the targeted Chips + // (new chips picked from `options`, in a custom colour) + // goes ahead of the default one, which takes every + // other array of strings. It's kept out of the Type + // selector, which would otherwise list "Chips" twice. + chipsDefinition({ + condition: byKey('Pick from options'), + showInTypeSelector: false, + componentProps: { + options: ['Mercury', 'Venus', 'Earth', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune'], + color: '#fbcfe8', + textColor: '#831843', + }, + }), + chipsDefinition(), // "Number Formatting": one NumberFormatter per field, // all sharing the `locale` picked above. It's // display-only, so editing any of them shows the raw diff --git a/demo/src/examples/static/custom-component-library/data.ts b/demo/src/examples/static/custom-component-library/data.ts index 70d20c2d..eba23705 100644 --- a/demo/src/examples/static/custom-component-library/data.ts +++ b/demo/src/examples/static/custom-component-library/data.ts @@ -22,6 +22,7 @@ export const initialData = { - Symbol - Image - ColorPicker + - Chips Click [here](https://github.com/CarlosNZ/json-edit-react/blob/main/packages/components/README.md) for more info `, @@ -76,6 +77,10 @@ export const initialData = { }, }, 'Color Picker': '#ff69B4', // Hot Pink + Chips: { + Tags: ['json', 'react', 'editor'], + 'Pick from options': ['Mercury', 'Venus'], + }, } export type CustomComponentLibraryData = typeof initialData diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 8d4a3747..a0886464 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -1,5 +1,9 @@ # @json-edit-react/components +## 1.1.0 + +- Add a `Chips` component: arrays of strings shown as removable chips, with an inline add from free text or a fixed list of options. See the [README](README.md#chips--tags-and-labels). + ## 1.0.0 - First stable release, alongside `json-edit-react` 2.0.0. See the [README](README.md) for the full component list. diff --git a/packages/components/README.md b/packages/components/README.md index 8fefa146..b9a103bb 100644 --- a/packages/components/README.md +++ b/packages/components/README.md @@ -41,6 +41,7 @@ Each component ships a React component plus a definition factory that produces a | `ErrorIndicator` | Wraps a node with a glyph (default ⚠️) to flag the nodes you target via `condition` — e.g. validation errors ([details](#errorindicator--flag-nodes-with-a-glyph)) | — | | `AutoType` | Edit-only text input on every value node that infers the type from what you type — `12.3` → number, `true` → boolean, `{…}`/`[…]` → object/array, otherwise a string ([details](#autotype--type-follows-the-input)) | — | | `NumberFormatter` | Numbers formatted via `Intl.NumberFormat` (thousands separators, currency, percent, …), edited as the raw number ([details](#numberformatter--locale-aware-number-formatting)) | — | +| `Chips` | Arrays of strings shown as removable chips, with an inline add — free text, or picked from a fixed list ([details](#chips--tags-and-labels)) | — | ### Editor slot widgets @@ -338,6 +339,30 @@ numberFormatterDefinition({ Because it's display-only, the value your data holds never changes: a `percent` style shows `0.5` as "50%" and a `currency` style rounds the *display* to the currency's fraction digits, but the underlying number keeps its full precision. A malformed `options` (for example `style: 'currency'` with no `currency` code) throws when the formatter is built — deliberately loud, so the misconfiguration surfaces in development rather than silently showing a raw number. +### `Chips` — tags and labels + +Renders an array of strings as a row of chips — handy for `tags`, `labels`, `aliases` and the like, which are clunky to edit one element at a time. Hover a chip and click its × to remove it; click + to type a new one (Enter adds it and keeps the input open for the next; Esc or an empty Enter closes it). Each change commits straight away. Inside the node's edit session (the edit button, or switching a node to "Chips" in the Type selector) the same controls edit a draft, which ✓ commits and Esc cancels. + +```jsx +import { chipsDefinition } from '@json-edit-react/components' +import { byKey } from '@json-edit-react/utils' + +chipsDefinition({ + condition: byKey('tags'), + componentProps: { + color: '#fbcfe8', // chip background (default sky blue) + textColor: '#831843', // chip text (default dark blue) + options: ['draft', 'review', 'published'], + }, +}) +``` + +With `options`, new chips are picked from a dropdown of the options not already present (rendered with the editor's `Select`, so it follows a custom `Select` prop), instead of typed as free text. Adding a chip that's already present does nothing. + +The guard matches any array whose elements are all strings — **including empty arrays**, so that removing the last chip leaves an empty list you can still add to. That means every empty array in the tree renders as Chips too, so narrow it with a `condition` if your data has other arrays. Switching a string to "Chips" in the Type selector splits it on commas; switching away joins the chips with `, `. + +**Saving to JSON:** the value is a plain array of strings, so there's nothing special to serialise. + ## Tree-shaking This package is ESM with `"sideEffects": false`. Modern bundlers (Webpack 4+, Vite, Rollup, esbuild, Parcel 2+) drop unused components and their imports from the final bundle. Heavy components (`DatePicker`, `Markdown`, `ColorPicker`, `ReactSelect`, `CodeEditor`) additionally use `React.lazy` for their third-party libraries, so even when imported they don't load those libraries until first render. diff --git a/packages/components/scripts/verify-treeshake.mjs b/packages/components/scripts/verify-treeshake.mjs index f8ac1ab1..c4ac037d 100644 --- a/packages/components/scripts/verify-treeshake.mjs +++ b/packages/components/scripts/verify-treeshake.mjs @@ -43,8 +43,8 @@ const widgetsEsm = path.join(pkgRoot, 'build', 'widgets.esm.js') // stylesheet would be an input-stage failure that hits both, the check costs // a `readFileSync` and this is the list of files that actually publish. const BUNDLES = { - 'index.esm.js': ['loader', 'unix', 'errorIndicator'], - 'index.cjs.js': ['loader', 'unix', 'errorIndicator'], + 'index.esm.js': ['loader', 'unix', 'errorIndicator', 'chips'], + 'index.cjs.js': ['loader', 'unix', 'errorIndicator', 'chips'], 'widgets.esm.js': ['loader', 'datePicker', 'datePickerLib'], 'widgets.cjs.js': ['loader', 'datePicker', 'datePickerLib'], } @@ -73,6 +73,7 @@ const STYLES = { loader: '.jer-simple-loader{', unix: '.jer-unix-badge{', errorIndicator: '.jer-error-indicator-wrapper{', + chips: '.jer-chips{', datePicker: '.react-datepicker-popper{', datePickerLib: 'react-datepicker/dist/react-datepicker.css', } @@ -82,6 +83,7 @@ const STYLE_SOURCES = { loader: 'src/_common/style.css', unix: 'src/UnixTimestamp/style.css', errorIndicator: 'src/ErrorIndicator/style.css', + chips: 'src/Chips/style.css', datePicker: 'src/widgets/ReactDatePicker/style.css', } diff --git a/packages/components/src/Chips/component.tsx b/packages/components/src/Chips/component.tsx new file mode 100644 index 00000000..459cf8a5 --- /dev/null +++ b/packages/components/src/Chips/component.tsx @@ -0,0 +1,123 @@ +/** + * Renders an array of strings as a row of chips (tags, labels, aliases). + * Hovering a chip reveals an "×" to remove it, and a "+" button adds one: as + * free text, or, when `componentProps.options` is set, picked from that list. + * + * Outside an edit session each change commits on its own, via + * `handleEdit(value)`. Inside one (the edit button, or a type switch to Chips) + * the same controls write to the edit buffer, which ✓ commits. + */ + +import React, { useState } from 'react' +import { type CustomComponentProps } from 'json-edit-react' +import css from './style.css?inline' +import { useStyles } from '../_common/useStyles' + +export interface ChipsCustomProps { + // Chip background. Default sky blue. + color?: string + // Chip text. Default dark blue. + textColor?: string + // Restricts new chips to these values, picked from a dropdown rather than + // typed as free text. + options?: string[] +} + +export const Chips = (props: CustomComponentProps) => { + useStyles('jer-chips', css) + const { value, setValue, handleEdit, isEditing, canEdit, Select, componentProps } = props + const { color = '#bae6fd', textColor = '#0c4a6e', options } = componentProps ?? {} + + const [isAdding, setIsAdding] = useState(false) + + const chips = Array.isArray(value) ? value.map(String) : [] + + const update = (newChips: string[]) => (isEditing ? setValue(newChips) : handleEdit(newChips)) + + const addChip = (chip: string) => { + const text = chip.trim() + if (text && !chips.includes(text)) update([...chips, text]) + } + + // With `options`, only the ones not already present can be added. + const available = options?.filter((option) => !chips.includes(option)) + const canAdd = canEdit && (!available || available.length > 0) + + return ( +
+ {chips.map((chip, index) => ( + + {chip} + {canEdit && ( + + )} + + ))} + {canAdd && + (!isAdding ? ( + + ) : available ? ( + setIsAdding(false)}> + setIsAdding(false)} + onKeyDown={(e) => { + if (e.key === 'Escape') setIsAdding(false) + if (e.key !== 'Enter') return + // Enter adds and clears, ready for the next chip; on an empty + // input it closes. + if (!e.currentTarget.value.trim()) setIsAdding(false) + addChip(e.currentTarget.value) + e.currentTarget.value = '' + }} + /> + ))} +
+ ) +} diff --git a/packages/components/src/Chips/definition.ts b/packages/components/src/Chips/definition.ts new file mode 100644 index 00000000..572cdfe3 --- /dev/null +++ b/packages/components/src/Chips/definition.ts @@ -0,0 +1,41 @@ +/** + * A tags / chips component: arrays of short strings rendered as removable + * chips with an inline add. Tags, labels and aliases fields are everywhere in + * config and content data, and editing them element-by-element is clunky. + */ + +import { type CustomNodeDefinition } from 'json-edit-react' +import { createDefinitionFactory } from '../_common/createDefinitionFactory' +import { Chips, type ChipsCustomProps } from './component' + +const splitChips = (text: string) => + text + .split(',') + .map((chip) => chip.trim()) + .filter(Boolean) + +// The condition doubles as the guard: consumer `condition` overrides are +// targeting, ANDed with this by the factory. It matches empty arrays too, so +// removing the last chip leaves an empty chip list that can still be added to; +// narrow by key (`tags`, `labels`, …) to leave other empty arrays alone. +const ChipsDefinition: CustomNodeDefinition = { + condition: ({ value }) => Array.isArray(value) && value.every((v) => typeof v === 'string'), + component: Chips, + name: 'Chips', // shown in the Type selector menu + showInTypeSelector: true, + editOnTypeSwitch: true, + showOnEdit: true, + renderCollectionAsValue: true, + defaultValue: [], + // The primitive form is a comma-separated list, which `fromStandardType` + // splits back into chips. + toStandardType: (value) => (Array.isArray(value) ? value.join(', ') : String(value)), + fromStandardType: (value) => { + // A confirm's buffer already holds the chip array — pass it through. + if (Array.isArray(value)) return value.map(String) + if (['string', 'number', 'boolean'].includes(typeof value)) return splitChips(String(value)) + return [] + }, +} + +export const chipsDefinition = createDefinitionFactory(ChipsDefinition) diff --git a/packages/components/src/Chips/index.ts b/packages/components/src/Chips/index.ts new file mode 100644 index 00000000..618b7d06 --- /dev/null +++ b/packages/components/src/Chips/index.ts @@ -0,0 +1,2 @@ +export * from './definition' +export * from './component' diff --git a/packages/components/src/Chips/style.css b/packages/components/src/Chips/style.css new file mode 100644 index 00000000..8ef89c22 --- /dev/null +++ b/packages/components/src/Chips/style.css @@ -0,0 +1,65 @@ +/* Chip colours come from `--jer-chip-color` / `--jer-chip-text`, set inline + from `componentProps`. */ +.jer-chips { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4em; +} + +.jer-chip { + position: relative; + font-size: 0.8em; + font-weight: 600; + padding: 0.3em 0.6em; + border-radius: 0.4em; + background: var(--jer-chip-color); + color: var(--jer-chip-text); + user-select: none; +} + +/* Overlaid on the chip's corner, so revealing it doesn't shift the row. */ +.jer-chip-remove { + position: absolute; + top: -0.5em; + right: -0.5em; + display: none; + align-items: center; + justify-content: center; + width: 1.3em; + height: 1.3em; + padding: 0; + border: none; + border-radius: 50%; + background: var(--jer-chip-text); + color: var(--jer-chip-color); + cursor: pointer; +} + +.jer-chip:hover .jer-chip-remove { + display: inline-flex; +} + +.jer-chip-add { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.8em; + padding: 0.35em 0.5em; + border: 1px dashed var(--jer-chip-text); + border-radius: 0.4em; + background: none; + color: var(--jer-chip-text); + opacity: 0.6; + cursor: pointer; +} + +.jer-chip-add:hover { + opacity: 1; +} + +.jer-chip-input { + font: inherit; + font-size: 0.8em; + width: 8em; +} diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index f2e4bfe8..fa56b1f2 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -14,6 +14,7 @@ export * from './ColorPicker' export * from './ErrorIndicator' export * from './AutoType' export * from './NumberFormatter' +export * from './Chips' // The definition factories' override surface. The factory builder itself, // `createDefinitionFactory`, stays internal. diff --git a/packages/components/test/Chips.test.tsx b/packages/components/test/Chips.test.tsx new file mode 100644 index 00000000..cc67c19f --- /dev/null +++ b/packages/components/test/Chips.test.tsx @@ -0,0 +1,90 @@ +import { useState } from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { JsonEditor, type CustomNodeDefinition, type JsonData } from 'json-edit-react' +import { chipsDefinition } from '../src/Chips' + +const Harness = ({ + initial, + defs, + onData, + readOnly, +}: { + initial: JsonData + // Mirrors core's `customNodeDefinitions` prop type (permissive `any` + // componentProps), so a typed definition assigns. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + defs: CustomNodeDefinition>[] + onData?: (d: JsonData) => void + readOnly?: boolean +}) => { + const [data, setData] = useState(initial) + return ( + { + onData?.(d) + setData(d) + }} + customNodeDefinitions={defs} + allowEdit={!readOnly} + /> + ) +} + +describe('Chips — string arrays as removable chips', () => { + test('renders each string as a chip', () => { + render() + expect(screen.getByText('red')).toHaveClass('jer-chip') + expect(screen.getByText('green')).toHaveClass('jer-chip') + }) + + test("doesn't match an array containing a non-string", () => { + const { container } = render( + + ) + expect(container.querySelector('.jer-chip')).toBeNull() + }) + + test('removing a chip commits the shorter array', async () => { + const onData = jest.fn() + render( + + ) + await userEvent.click(screen.getByRole('button', { name: 'Remove red' })) + expect(onData).toHaveBeenLastCalledWith({ tags: ['green'] }) + }) + + test('adds a typed chip on Enter, trimmed and without duplicates', async () => { + const onData = jest.fn() + render() + await userEvent.click(screen.getByRole('button', { name: 'Add chip' })) + await userEvent.keyboard(' blue {Enter}') + expect(onData).toHaveBeenLastCalledWith({ tags: ['red', 'blue'] }) + onData.mockClear() + await userEvent.keyboard('red{Enter}') + expect(onData).not.toHaveBeenCalled() + }) + + test('with `options`, adds from a dropdown of the unused ones', async () => { + const onData = jest.fn() + render( + + ) + await userEvent.click(screen.getByRole('button', { name: 'Add chip' })) + const select = screen.getByRole('combobox') + expect(screen.queryByRole('option', { name: 'red' })).not.toBeInTheDocument() + await userEvent.selectOptions(select, 'blue') + expect(onData).toHaveBeenLastCalledWith({ tags: ['red', 'blue'] }) + }) + + test('hides the add and remove controls on a read-only node', () => { + render() + expect(screen.queryByRole('button', { name: 'Remove red' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Add chip' })).not.toBeInTheDocument() + }) +}) From 8f0c89fd76fa774e7f43f1251f0ef644065d965b Mon Sep 17 00:00:00 2001 From: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:01:23 +1300 Subject: [PATCH 2/3] Commit a type switch to Chips straight away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A type switch to Chips opened an edit session on the converted value, which left ✓/✗ showing with nothing to confirm, since the chips are edited in place. The component now commits it as soon as it renders, recognising a switch by the committed value not yet being a string array. A session opened from the edit button stays open, as it's where the Type selector lives. `isStringArray` moves to its own module, shared by the guard and the component. Co-Authored-By: Claude Opus 5.5 --- packages/components/README.md | 4 ++-- packages/components/src/Chips/component.tsx | 24 +++++++++++++++---- packages/components/src/Chips/definition.ts | 6 ++++- .../components/src/Chips/isStringArray.ts | 3 +++ packages/components/test/Chips.test.tsx | 21 +++++++++++++++- 5 files changed, 50 insertions(+), 8 deletions(-) create mode 100644 packages/components/src/Chips/isStringArray.ts diff --git a/packages/components/README.md b/packages/components/README.md index b9a103bb..b64520f5 100644 --- a/packages/components/README.md +++ b/packages/components/README.md @@ -341,7 +341,7 @@ Because it's display-only, the value your data holds never changes: a `percent` ### `Chips` — tags and labels -Renders an array of strings as a row of chips — handy for `tags`, `labels`, `aliases` and the like, which are clunky to edit one element at a time. Hover a chip and click its × to remove it; click + to type a new one (Enter adds it and keeps the input open for the next; Esc or an empty Enter closes it). Each change commits straight away. Inside the node's edit session (the edit button, or switching a node to "Chips" in the Type selector) the same controls edit a draft, which ✓ commits and Esc cancels. +Renders an array of strings as a row of chips — handy for `tags`, `labels`, `aliases` and the like, which are clunky to edit one element at a time. Hover a chip and click its × to remove it; click + to type a new one (Enter adds it and keeps the input open for the next; Esc or an empty Enter closes it). Each change commits straight away. Inside the node's edit session, opened by the edit button, the same controls edit a draft, which ✓ commits and Esc cancels. ```jsx import { chipsDefinition } from '@json-edit-react/components' @@ -359,7 +359,7 @@ chipsDefinition({ With `options`, new chips are picked from a dropdown of the options not already present (rendered with the editor's `Select`, so it follows a custom `Select` prop), instead of typed as free text. Adding a chip that's already present does nothing. -The guard matches any array whose elements are all strings — **including empty arrays**, so that removing the last chip leaves an empty list you can still add to. That means every empty array in the tree renders as Chips too, so narrow it with a `condition` if your data has other arrays. Switching a string to "Chips" in the Type selector splits it on commas; switching away joins the chips with `, `. +The guard matches any array whose elements are all strings — **including empty arrays**, so that removing the last chip leaves an empty list you can still add to. That means every empty array in the tree renders as Chips too, so narrow it with a `condition` if your data has other arrays. Switching a string to "Chips" in the Type selector splits it on commas and saves straight away; switching away joins the chips with `, `. **Saving to JSON:** the value is a plain array of strings, so there's nothing special to serialise. diff --git a/packages/components/src/Chips/component.tsx b/packages/components/src/Chips/component.tsx index 459cf8a5..1add1dec 100644 --- a/packages/components/src/Chips/component.tsx +++ b/packages/components/src/Chips/component.tsx @@ -4,14 +4,16 @@ * free text, or, when `componentProps.options` is set, picked from that list. * * Outside an edit session each change commits on its own, via - * `handleEdit(value)`. Inside one (the edit button, or a type switch to Chips) - * the same controls write to the edit buffer, which ✓ commits. + * `handleEdit(value)`. Inside one, opened by the edit button, the same controls + * write to the edit buffer, which ✓ commits. A type switch to Chips commits as + * soon as it renders instead, since the chips are edited in place. */ -import React, { useState } from 'react' +import React, { useEffect, useRef, useState } from 'react' import { type CustomComponentProps } from 'json-edit-react' import css from './style.css?inline' import { useStyles } from '../_common/useStyles' +import { isStringArray } from './isStringArray' export interface ChipsCustomProps { // Chip background. Default sky blue. @@ -25,11 +27,25 @@ export interface ChipsCustomProps { export const Chips = (props: CustomComponentProps) => { useStyles('jer-chips', css) - const { value, setValue, handleEdit, isEditing, canEdit, Select, componentProps } = props + const { value, setValue, handleEdit, isEditing, canEdit, Select, nodeData, componentProps } = + props const { color = '#bae6fd', textColor = '#0c4a6e', options } = componentProps ?? {} const [isAdding, setIsAdding] = useState(false) + // An edit session whose committed value isn't yet a string array is a type + // switch to Chips, already converted into the buffer. With nothing left to + // confirm, it commits straight away. A session opened from the edit button + // stays open, as it's where the Type selector lives. The ref stops a second + // commit when Strict Mode runs the effect twice. + const switchCommitted = useRef(false) + useEffect(() => { + if (!isEditing || isStringArray(nodeData.value) || switchCommitted.current) return + switchCommitted.current = true + handleEdit() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isEditing]) + const chips = Array.isArray(value) ? value.map(String) : [] const update = (newChips: string[]) => (isEditing ? setValue(newChips) : handleEdit(newChips)) diff --git a/packages/components/src/Chips/definition.ts b/packages/components/src/Chips/definition.ts index 572cdfe3..cdd1128f 100644 --- a/packages/components/src/Chips/definition.ts +++ b/packages/components/src/Chips/definition.ts @@ -7,6 +7,7 @@ import { type CustomNodeDefinition } from 'json-edit-react' import { createDefinitionFactory } from '../_common/createDefinitionFactory' import { Chips, type ChipsCustomProps } from './component' +import { isStringArray } from './isStringArray' const splitChips = (text: string) => text @@ -19,10 +20,13 @@ const splitChips = (text: string) => // removing the last chip leaves an empty chip list that can still be added to; // narrow by key (`tags`, `labels`, …) to leave other empty arrays alone. const ChipsDefinition: CustomNodeDefinition = { - condition: ({ value }) => Array.isArray(value) && value.every((v) => typeof v === 'string'), + condition: ({ value }) => isStringArray(value), component: Chips, name: 'Chips', // shown in the Type selector menu showInTypeSelector: true, + // Deferred, so the switch seeds through `fromStandardType` and converts the + // current value rather than discarding it for `defaultValue`. The component + // commits the seeded value as soon as it renders. editOnTypeSwitch: true, showOnEdit: true, renderCollectionAsValue: true, diff --git a/packages/components/src/Chips/isStringArray.ts b/packages/components/src/Chips/isStringArray.ts new file mode 100644 index 00000000..d8fa0dc5 --- /dev/null +++ b/packages/components/src/Chips/isStringArray.ts @@ -0,0 +1,3 @@ +// The Chips guard: an array whose elements are all strings, empty included. +export const isStringArray = (value: unknown): value is string[] => + Array.isArray(value) && value.every((v) => typeof v === 'string') diff --git a/packages/components/test/Chips.test.tsx b/packages/components/test/Chips.test.tsx index cc67c19f..377356a7 100644 --- a/packages/components/test/Chips.test.tsx +++ b/packages/components/test/Chips.test.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { render, screen } from '@testing-library/react' +import { render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { JsonEditor, type CustomNodeDefinition, type JsonData } from 'json-edit-react' import { chipsDefinition } from '../src/Chips' @@ -82,6 +82,25 @@ describe('Chips — string arrays as removable chips', () => { expect(onData).toHaveBeenLastCalledWith({ tags: ['red', 'blue'] }) }) + test('a type switch to Chips commits straight away, with no edit session left open', async () => { + const onData = jest.fn() + render() + await userEvent.dblClick(screen.getByText('"red, green"')) + await userEvent.selectOptions(screen.getByRole('combobox'), 'Chips') + expect(onData).toHaveBeenLastCalledWith({ tags: ['red', 'green'] }) + expect(screen.queryByRole('button', { name: 'OK' })).not.toBeInTheDocument() + }) + + test('the edit button keeps the session open, for the Type selector', async () => { + const onData = jest.fn() + render() + const row = screen.getByText('red').closest('.jer-component') as HTMLElement + await userEvent.click(within(row).getByRole('button', { name: 'Edit' })) + expect(within(row).getByRole('combobox')).toBeInTheDocument() + expect(within(row).getByRole('button', { name: 'OK' })).toBeInTheDocument() + expect(onData).not.toHaveBeenCalled() + }) + test('hides the add and remove controls on a read-only node', () => { render() expect(screen.queryByRole('button', { name: 'Remove red' })).not.toBeInTheDocument() From 657c93687d083951c76964b30f5ae90c03098015 Mon Sep 17 00:00:00 2001 From: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:04:07 +1300 Subject: [PATCH 3/3] Make Chips read-only while its edit session is open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every chip add and remove commits on its own, so the session the edit button opens is only there for the Type selector. The "+" and the hover "×" are now hidden while it's open, which also drops the buffered edit path the controls used inside a session. Co-Authored-By: Claude Opus 5.5 --- packages/components/README.md | 2 +- packages/components/src/Chips/component.tsx | 22 ++++++++++----------- packages/components/test/Chips.test.tsx | 4 +++- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/packages/components/README.md b/packages/components/README.md index b64520f5..c0995454 100644 --- a/packages/components/README.md +++ b/packages/components/README.md @@ -341,7 +341,7 @@ Because it's display-only, the value your data holds never changes: a `percent` ### `Chips` — tags and labels -Renders an array of strings as a row of chips — handy for `tags`, `labels`, `aliases` and the like, which are clunky to edit one element at a time. Hover a chip and click its × to remove it; click + to type a new one (Enter adds it and keeps the input open for the next; Esc or an empty Enter closes it). Each change commits straight away. Inside the node's edit session, opened by the edit button, the same controls edit a draft, which ✓ commits and Esc cancels. +Renders an array of strings as a row of chips — handy for `tags`, `labels`, `aliases` and the like, which are clunky to edit one element at a time. Hover a chip and click its × to remove it; click + to type a new one (Enter adds it and keeps the input open for the next; Esc or an empty Enter closes it). Each change commits straight away. The edit button opens the usual edit session for the Type selector, and the chips are read-only while it's open. ```jsx import { chipsDefinition } from '@json-edit-react/components' diff --git a/packages/components/src/Chips/component.tsx b/packages/components/src/Chips/component.tsx index 1add1dec..7738fca9 100644 --- a/packages/components/src/Chips/component.tsx +++ b/packages/components/src/Chips/component.tsx @@ -3,10 +3,10 @@ * Hovering a chip reveals an "×" to remove it, and a "+" button adds one: as * free text, or, when `componentProps.options` is set, picked from that list. * - * Outside an edit session each change commits on its own, via - * `handleEdit(value)`. Inside one, opened by the edit button, the same controls - * write to the edit buffer, which ✓ commits. A type switch to Chips commits as - * soon as it renders instead, since the chips are edited in place. + * Each change commits on its own, via `handleEdit(value)`, so the chips need + * no edit session. The one the edit button opens is for the Type selector, so + * the chips are read-only while it's open. A type switch to Chips commits as + * soon as it renders. */ import React, { useEffect, useRef, useState } from 'react' @@ -27,8 +27,7 @@ export interface ChipsCustomProps { export const Chips = (props: CustomComponentProps) => { useStyles('jer-chips', css) - const { value, setValue, handleEdit, isEditing, canEdit, Select, nodeData, componentProps } = - props + const { value, handleEdit, isEditing, canEdit, Select, nodeData, componentProps } = props const { color = '#bae6fd', textColor = '#0c4a6e', options } = componentProps ?? {} const [isAdding, setIsAdding] = useState(false) @@ -48,16 +47,17 @@ export const Chips = (props: CustomComponentProps) => { const chips = Array.isArray(value) ? value.map(String) : [] - const update = (newChips: string[]) => (isEditing ? setValue(newChips) : handleEdit(newChips)) + // Read-only during an edit session, as well as on a node `allowEdit` blocks. + const isEditable = canEdit && !isEditing const addChip = (chip: string) => { const text = chip.trim() - if (text && !chips.includes(text)) update([...chips, text]) + if (text && !chips.includes(text)) handleEdit([...chips, text]) } // With `options`, only the ones not already present can be added. const available = options?.filter((option) => !chips.includes(option)) - const canAdd = canEdit && (!available || available.length > 0) + const canAdd = isEditable && (!available || available.length > 0) return (
) => { {chips.map((chip, index) => ( {chip} - {canEdit && ( + {isEditable && (