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
18 changes: 17 additions & 1 deletion demo/src/examples/static/custom-component-library/Example.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 }

Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions demo/src/examples/static/custom-component-library/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
`,
Expand Down Expand Up @@ -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
4 changes: 4 additions & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
25 changes: 25 additions & 0 deletions packages/components/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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. 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'
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 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.

## 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.
Expand Down
6 changes: 4 additions & 2 deletions packages/components/scripts/verify-treeshake.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
}
Expand Down Expand Up @@ -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',
}
Expand All @@ -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',
}

Expand Down
139 changes: 139 additions & 0 deletions packages/components/src/Chips/component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* 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.
*
* 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'
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.
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<ChipsCustomProps>) => {
useStyles('jer-chips', css)
const { value, 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) : []

// 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)) handleEdit([...chips, text])
}

// With `options`, only the ones not already present can be added.
const available = options?.filter((option) => !chips.includes(option))
const canAdd = isEditable && (!available || available.length > 0)

return (
<div
className="jer-chips"
style={{ '--jer-chip-color': color, '--jer-chip-text': textColor } as React.CSSProperties}
>
{chips.map((chip, index) => (
<span key={index} className="jer-chip">
{chip}
{isEditable && (
<button
type="button"
className="jer-chip-remove"
aria-label={`Remove ${chip}`}
onClick={() => handleEdit(chips.filter((_, i) => i !== index))}
>
<svg viewBox="0 0 10 10" width="0.6em" height="0.6em">
<path
d="M2.5 2.5l5 5M7.5 2.5l-5 5"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
</button>
)}
</span>
))}
{canAdd &&
(!isAdding ? (
<button
type="button"
className="jer-chip-add"
aria-label="Add chip"
onClick={() => setIsAdding(true)}
>
<svg viewBox="0 0 10 10" width="0.7em" height="0.7em">
<path
d="M5 1.5v7M1.5 5h7"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
</button>
) : available ? (
<span onBlur={() => setIsAdding(false)}>
<Select
options={available}
defaultValue=""
placeholder="Add…"
autoFocus
onChange={(option) => {
addChip(option)
setIsAdding(false)
}}
onKeyDown={(e) => e.key === 'Escape' && setIsAdding(false)}
/>
</span>
) : (
<input
className="jer-chip-input"
placeholder="Add…"
autoFocus
onBlur={() => 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 = ''
}}
/>
))}
</div>
)
}
45 changes: 45 additions & 0 deletions packages/components/src/Chips/definition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* 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'
import { isStringArray } from './isStringArray'

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<ChipsCustomProps> = {
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,
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)
2 changes: 2 additions & 0 deletions packages/components/src/Chips/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './definition'
export * from './component'
3 changes: 3 additions & 0 deletions packages/components/src/Chips/isStringArray.ts
Original file line number Diff line number Diff line change
@@ -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')
65 changes: 65 additions & 0 deletions packages/components/src/Chips/style.css
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading