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
7 changes: 7 additions & 0 deletions .changeset/button-controls-accessibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'json-edit-react': major
---

The clickable icon controls — the ✓ / ✗ confirm/cancel pair and the edit/copy/delete/add icons — are now real `<button>` elements instead of `<div onClick>`, so assistive tech announces them as actionable and reads an `aria-label` (always present, independent of `showIconTooltips`). Their appearance is unchanged (the default button chrome is reset in the bundled CSS) and they carry `tabIndex={-1}`, so the editor's field-to-field Tab navigation is unaffected. Two new localisation keys (`TOOLTIP_OK`, `TOOLTIP_CANCEL`) provide the confirm/cancel labels.

Breaking only for custom CSS that targets these controls by tag name: a selector like `.jer-confirm-buttons > div` must become `.jer-confirm-buttons > button`. Wrapper-class and icon selectors are unaffected, and consumer-supplied `customButtons` remain `<div>`-wrapped.
7 changes: 7 additions & 0 deletions .changeset/text-input-host-css-hardening.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'json-edit-react': patch
---

Harden the text-editing fields against host-app CSS. The string and raw-JSON editors now pin `box-sizing` and an explicit `line-height` instead of leaving them to inherit, so a consuming app's global reset (e.g. `* { box-sizing: border-box }`) or an element-level `textarea` / `input` rule can no longer distort their layout or text wrapping. This also keeps the auto-growing textarea's hidden measuring element locked to the real `<textarea>`, fixing a latent case where the raw-JSON editor could mis-measure its height even with no host reset present.

The pinned `box-sizing` is `content-box` (the model the editor was designed against), so it's a no-op for consumers without a global reset. The raw-JSON editor's line spacing is now set explicitly and may shift very slightly.
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1024,11 +1024,15 @@ Localise your implementation (or just customise the default messages) by passing
DEFAULT_NEW_KEY: 'key',
SHOW_LESS: '(Show less)',
EMPTY_STRING: '<empty string>' // Displayed when property key is ""
// Tooltips only appear if `showIconTooltips` prop is enabled
// These label the icon controls (which are <button>s) for assistive tech via
// `aria-label`, and also show as visible tooltips when the `showIconTooltips`
// prop is enabled.
TOOLTIP_COPY: 'Copy to clipboard',
TOOLTIP_EDIT: 'Edit',
TOOLTIP_DELETE: 'Delete',
TOOLTIP_ADD: 'Add',
TOOLTIP_OK: 'OK',
TOOLTIP_CANCEL: 'Cancel',
}
```

Expand Down
27 changes: 27 additions & 0 deletions demo/src/RawHtmlPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useState } from 'react'
import { JsonEditor, type JsonData } from 'json-edit-react'
import { data } from './demoData/data'

// A deliberately bare page: rendered OUTSIDE ChakraProvider (unlike every other
// route) and with no UI-framework CSS reset in scope — only the editor's own
// bundled styles apply, plus index.css's body font. This is what a consumer who
// drops `JsonEditor` onto a plain HTML page sees, so it's a handy reference for
// checking the library's self-contained styling (e.g. the <button> appearance
// reset in core's style.css). Reachable at /raw-html.
export const RawHtmlPage = () => {
const [jsonData, setJsonData] = useState<JsonData>(data.starWars as JsonData)

return (
<div style={{ padding: '2em', maxWidth: 800, margin: '0 auto' }}>
<h1>Bare JsonEditor — no UI library</h1>
<p>
This page is rendered outside <code>ChakraProvider</code> and any UI-framework CSS reset, so
it shows how <code>JsonEditor</code> looks with only its own bundled styles — the
bare-consumer experience.
</p>
<JsonEditor data={jsonData} setData={setJsonData} collapse={2} showIconTooltips />
</div>
)
}

export default RawHtmlPage
13 changes: 13 additions & 0 deletions demo/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ const ExamplesIndex = lazy(() =>
import('./examples/ExamplesIndex').then((m) => ({ default: m.ExamplesIndex }))
)

// A bare editor rendered with no ChakraProvider / UI-framework reset, as a
// reference for how the library looks for a plain-HTML consumer. Lazy-loaded so
// it stays out of the main entry chunk.
const RawHtmlPage = lazy(() =>
import('./RawHtmlPage').then((m) => ({ default: m.RawHtmlPage }))
)

const exampleFallback = (
<Flex h="100vh" justify="center" align="center">
<Spinner />
Expand Down Expand Up @@ -52,6 +59,12 @@ createRoot(document.getElementById('root')!).render(
</Suspense>
</ChakraProvider>
</Route>
<Route path="/raw-html">
{/* Intentionally NOT wrapped in ChakraProvider — see RawHtmlPage. */}
<Suspense fallback={<div style={{ padding: 40, textAlign: 'center' }}>Loading…</div>}>
<RawHtmlPage />
</Suspense>
</Route>
<Route>
<ChakraProvider theme={theme}>
<App />
Expand Down
28 changes: 24 additions & 4 deletions migration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -446,21 +446,25 @@ Both receive the standard flat `NodeData` — `currentData` / `currentValue` / `

`JerError` keeps its name and `{ code, message }` shape. What changes is its `code`: it's now the exported `JerErrorCode` union, which gains three forward-looking members — `RENAME_ERROR`, `MOVE_ERROR` and `CLIPBOARD_ERROR` — covering the new rename/move rejection and clipboard-failure paths. The additions are backward-compatible; you only need to act if you exhaustively `switch` on `error.code` and want to handle the new cases. (`onError`'s own payload also moves to flat `NodeData` — see [Observers reshaped](#10-observers-reshaped-oneditevent-lifecycle-stream-flat-onerror--oncollapse-oncopy-error).)

### New localisation keys: `ERROR_RENAME` / `ERROR_MOVE`
### New localisation keys

Rejected `rename` and `move` operations now show operation-specific messages (`'Rename unsuccessful'` / `'Move unsuccessful'`) instead of the generic `'Update unsuccessful'`, mirroring `ERROR_ADD` / `ERROR_DELETE`. Their `onError` codes are likewise `RENAME_ERROR` / `MOVE_ERROR` (additive members of `JerErrorCode`).
v2 adds several localisation keys. None require action — a `translations` object doesn't have to be exhaustive, so any key you don't define falls back to its English default. But if you ship a localised `translations` object and want full coverage, add them:

No action is strictly required — a `translations` object doesn't have to be exhaustive, so any key you don't define falls back to the English default. But if you ship a localised `translations` object and want these two messages translated too, add the new keys:
- `ERROR_RENAME` / `ERROR_MOVE` — rejected `rename` and `move` operations now show operation-specific messages (`'Rename unsuccessful'` / `'Move unsuccessful'`) instead of the generic `'Update unsuccessful'`, mirroring `ERROR_ADD` / `ERROR_DELETE`. (Their `onError` codes are likewise `RENAME_ERROR` / `MOVE_ERROR` — additive members of `JerErrorCode`.)
- `TOOLTIP_OK` / `TOOLTIP_CANCEL` — labels for the ✓ / ✗ confirm and cancel controls, now that those are real `<button>`s. Always applied as `aria-label`s, and shown as visible tooltips when `showIconTooltips` is enabled.

```diff
translations={{
// ...existing keys
ERROR_UPDATE: '…',
+ ERROR_RENAME: '…',
+ ERROR_MOVE: '…',
+ TOOLTIP_OK: '…',
+ TOOLTIP_CANCEL: '…',
}}
```

One further new key, `ITEMS_FILTERED`, is covered alongside the `showCollectionCount` default change — see [Display / config prop renames](#7-display--config-prop-renames).

### Removed localisation key: `DEFAULT_STRING`

The `DEFAULT_STRING` key (`'New data!'`) is gone — if your `translations` object defines it, remove it (TypeScript rejects unknown keys). It was the placeholder substituted into the edit buffer when switching a custom node's type to `string`; type-switching now converts the node's actual value instead (e.g. a `null`/`undefined` source becomes an empty string), so the placeholder no longer exists.
Expand Down Expand Up @@ -688,6 +692,22 @@ If you used `toPathString`'s output as an HTML `name` or `id` attribute (e.g. in

The exported `ThemeStyles` type is now `Partial<Record<ThemeableElement, …>>` (every key optional). If you imported it and relied on it being a *total* record, it's now optional-per-key — more permissive, so most code needs no change. See [Themes & Styles](README.md#themes--styles) in the README.

### Icon controls are now `<button>` elements

The clickable icon controls — the ✓ / ✗ confirm/cancel pair and the edit/copy/delete/add icons — are now real `<button>` elements instead of `<div>`s, so assistive tech announces them as actionable and reads their `aria-label`. Their appearance is unchanged (the default button chrome is reset in the bundled CSS), and they carry `tabIndex={-1}` so the editor's existing field-to-field Tab navigation is unaffected.

The only thing to act on is **custom CSS that targets these controls by tag name**. If you styled them via a `div` selector, switch it to `button`:

```css
/* Before (v1) */
.jer-confirm-buttons > div { … }

/* After (v2) */
.jer-confirm-buttons > button { … }
```

Selectors that target the wrapper classes (`.jer-confirm-buttons`, `.jer-edit-buttons`) or the icons themselves are unaffected. Consumer-supplied custom buttons (`customButtons`) remain wrapped in a `<div>`, so their markup is unchanged.

---

## Need help?
Expand Down
81 changes: 65 additions & 16 deletions src/ButtonPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ interface EditButtonProps {
eventMap: Partial<Record<keyof KeyboardControlsFull, () => void>>
) => void
getNewKeyOptions?: (nodeDate: NodeData) => string[] | null | void
editConfirmRef: React.RefObject<HTMLDivElement | null>
editConfirmRef: React.RefObject<HTMLButtonElement | null>
jsonStringify: (
data: JsonData,
// eslint-disable-next-line
Expand Down Expand Up @@ -184,44 +184,64 @@ export const EditButtons: React.FC<EditButtonProps> = ({
onClick={(e) => e.stopPropagation()}
>
{showClipboardButton && (
<div
// tabIndex={-1} keeps the control out of the editor's field-to-field
// Tab flow (owned by `keyboardControls`) while keeping the native
// button role + the aria-label for assistive tech. `aria-label` is
// unconditional; `title` (the visible tooltip) stays gated on
// `showIconTooltips`.
<button
type="button"
tabIndex={-1}
onClick={handleCopy}
className="jer-copy-pulse"
aria-label={translate('TOOLTIP_COPY', nodeData)}
title={showIconTooltips ? translate('TOOLTIP_COPY', nodeData) : ''}
>
<Icon name="copy" nodeData={nodeData} />
</div>
</button>
)}
{startEdit && (
<div
<button
type="button"
tabIndex={-1}
onClick={startEdit}
aria-label={translate('TOOLTIP_EDIT', nodeData)}
title={showIconTooltips ? translate('TOOLTIP_EDIT', nodeData) : ''}
>
<Icon name="edit" nodeData={nodeData} />
</div>
</button>
)}
{handleDelete && (
<div
<button
type="button"
tabIndex={-1}
onClick={handleDelete}
aria-label={translate('TOOLTIP_DELETE', nodeData)}
title={showIconTooltips ? translate('TOOLTIP_DELETE', nodeData) : ''}
>
<Icon name="delete" nodeData={nodeData} />
</div>
</button>
)}
{handleAdd && (
<div
<button
type="button"
tabIndex={-1}
onClick={() => {
// Objects open a key-entry session; arrays add a default value
// immediately (no key to fill — a one-shot, as before).
if (type === 'object') openAdd()
else handleAdd('')
}}
aria-label={translate('TOOLTIP_ADD', nodeData)}
title={showIconTooltips ? translate('TOOLTIP_ADD', nodeData) : ''}
>
<Icon name="add" nodeData={nodeData} />
</div>
</button>
)}
{customButtons?.map(({ Element, onClick }, i) => (
// Custom buttons stay <div>s: the inner `Element` is consumer-owned and
// may itself be interactive, so wrapping it in a <button> risks nested
// interactive content.
<div key={i} onClick={(e) => onClick && onClick(nodeData, e)}>
<Element nodeData={nodeData} />
</div>
Expand Down Expand Up @@ -260,6 +280,8 @@ export const EditButtons: React.FC<EditButtonProps> = ({
onOk={() => commitAdd()}
onCancel={() => cancel()}
nodeData={nodeData}
translate={translate}
showIconTooltips={showIconTooltips}
editConfirmRef={editConfirmRef}
hideOk={hasKeyOptionsList}
/>
Expand All @@ -273,20 +295,47 @@ export const InputButtons: React.FC<{
onOk: () => void
onCancel: () => void
nodeData: NodeData
editConfirmRef: React.RefObject<HTMLDivElement | null>
translate: TranslateFunction
showIconTooltips: boolean
editConfirmRef: React.RefObject<HTMLButtonElement | null>
hideOk?: boolean
}> = ({ onOk, onCancel, nodeData, editConfirmRef, hideOk = false }) => {
}> = ({
onOk,
onCancel,
nodeData,
translate,
showIconTooltips,
editConfirmRef,
hideOk = false,
}) => {
// tabIndex={-1}: the field itself commits/cancels via Enter/Escape
// (`keyboardControls`), so these are pointer affordances — kept out of the
// Tab order, but real <button>s for screen-reader semantics. `aria-label` is
// unconditional; `title` (the visible tooltip) stays gated on
// `showIconTooltips`, matching the edit icons.
return (
<div className="jer-confirm-buttons">
{!hideOk && (
// Pass an anonymous function to prevent passing event to onOk
<div onClick={onOk} ref={editConfirmRef as React.RefObject<HTMLDivElement>}>
<button
type="button"
tabIndex={-1}
onClick={onOk}
aria-label={translate('TOOLTIP_OK', nodeData)}
title={showIconTooltips ? translate('TOOLTIP_OK', nodeData) : ''}
ref={editConfirmRef as React.RefObject<HTMLButtonElement>}
>
<Icon name="ok" nodeData={nodeData} />
</div>
</button>
)}
<div onClick={onCancel}>
<button
type="button"
tabIndex={-1}
onClick={onCancel}
aria-label={translate('TOOLTIP_CANCEL', nodeData)}
title={showIconTooltips ? translate('TOOLTIP_CANCEL', nodeData) : ''}
>
<Icon name="cancel" nodeData={nodeData} />
</div>
</button>
</div>
)
}
2 changes: 2 additions & 0 deletions src/CollectionNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,8 @@ const CollectionNodeBase: React.FC<CollectionNodeProps> = (props) => {
onOk={handleEdit}
onCancel={handleCancel}
nodeData={nodeData}
translate={translate}
showIconTooltips={showIconTooltips}
editConfirmRef={editConfirmRef}
/>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/JsonEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -539,7 +539,7 @@ const Editor: React.FC<
}
}, [customNodeDefinitions, jsonParse])

const editConfirmRef = useRef<HTMLDivElement>(null)
const editConfirmRef = useRef<HTMLButtonElement>(null)
const { setCollapseState } = useCollapse()

// Common "sort" method for ordering nodes, based on the `sortKeys` prop
Expand Down
2 changes: 2 additions & 0 deletions src/ValueNodeWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,8 @@ const ValueNodeWrapperBase: React.FC<ValueNodeProps> = (props) => {
onOk={handleEdit}
onCancel={handleCancel}
nodeData={nodeData}
translate={translate}
showIconTooltips={showIconTooltips}
editConfirmRef={editConfirmRef}
/>
) : (
Expand Down
2 changes: 2 additions & 0 deletions src/localisation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ const localisedStrings = {
TOOLTIP_EDIT: 'Edit',
TOOLTIP_DELETE: 'Delete',
TOOLTIP_ADD: 'Add',
TOOLTIP_OK: 'OK',
TOOLTIP_CANCEL: 'Cancel',
}

export type LocalisedStrings = typeof localisedStrings
Expand Down
29 changes: 29 additions & 0 deletions src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -190,13 +190,20 @@ select:focus + .focus {
}

.jer-collection-text-area {
/* `box-sizing` and `line-height` are pinned (not just inherited) so the
AutogrowTextArea's real <textarea> and its hidden measuring <span> can't
drift apart when a host rule targets `textarea`/`input` or applies a global
`* { box-sizing: border-box }` reset. content-box is the model the sizing
was designed against, so consumers without a reset see no change. */
box-sizing: content-box;
resize: both;
padding-top: 0.2em;
padding-left: 0.5em;
padding-right: 0.5em;
padding-bottom: 0;
overflow: hidden;
max-height: 40em;
line-height: 1.25em;
font-family: inherit;
font-size: 0.85em;
}
Expand Down Expand Up @@ -266,6 +273,9 @@ select:focus + .focus {
}

.jer-input-text {
/* See `.jer-collection-text-area` — pinned so the autogrow <textarea> and its
measuring <span> stay in sync under a host CSS reset. */
box-sizing: content-box;
resize: none;
margin: 0;
height: 1.4em;
Expand Down Expand Up @@ -334,6 +344,25 @@ select:focus + .focus {
height: 1em;
}

/* The icon controls are real <button>s for accessibility. This neutralises the
user-agent button chrome so they render identically to surrounding elements.
Only properties a <button> won't otherwise take from context are listed:
background/border/margin/padding/appearance don't inherit, and form controls
are UA-special-cased so `font`/`color` must be re-asserted (`font: inherit`
is load-bearing — the icons are sized in `em`). `cursor` inherits from the
wrapper rule above, whose flexbox also centres the button, so neither is
needed here. Scoped with `>` so it never leaks into custom-button content. */
.jer-edit-buttons > button,
.jer-confirm-buttons > button {
appearance: none;
background: none;
border: none;
margin: 0;
padding: 0;
font: inherit;
color: inherit;
}

.jer-input-buttons {
gap: 0.4em;
}
Expand Down
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,7 @@ interface BaseNodeProps {
e: React.KeyboardEvent,
eventMap: Partial<Record<keyof KeyboardControlsFull, () => void>>
) => void
editConfirmRef: React.RefObject<HTMLDivElement | null>
editConfirmRef: React.RefObject<HTMLButtonElement | null>
jsonStringify: (
data: JsonData,
// eslint-disable-next-line
Expand Down
Loading
Loading