From 47af3f5c45fb72d299c54492923649d872245a14 Mon Sep 17 00:00:00 2001 From: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:15:49 +1200 Subject: [PATCH 01/10] Create theme-icons-spec.md --- theme-icons-spec.md | 276 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 theme-icons-spec.md diff --git a/theme-icons-spec.md b/theme-icons-spec.md new file mode 100644 index 00000000..9bf07073 --- /dev/null +++ b/theme-icons-spec.md @@ -0,0 +1,276 @@ +# Theme icons — v2 spec + +Status: design, pre-implementation. A v2 breaking redesign — not constrained by the current implementation. + +## Motivation + +Icons are a significant part of a theme's "look", but today they aren't subsumed into a theme at all. The `icons` prop replaces icon glyphs per-instance, while themes only control icon *styling* (colour/size) via `styles.iconAdd`, `styles.iconEdit`, etc. This splits one visual concern across two unrelated surfaces. In v2, themes own their icon glyphs as well as their styling, and the standalone `icons` prop goes away. + +## Core concepts + +Two orthogonal concerns, kept in separate fields: + +- **Glyph** — *which* icon shape renders. Owned by `Theme.icons` (new). +- **Paint** — *how* it's coloured/sized. Owned by `Theme.styles` under `iconAdd`…`iconCollection` (unchanged). + +You can restyle the default glyph (set `styles.iconAdd`), swap the glyph (`icons.add`), or both. Keeping them in separate fields makes that orthogonality explicit and keeps `styles` homogeneous — every entry stays "CSS for a themeable element", so the compile/merge step never has to special-case icon keys. + +### Why a "data" glyph format, not a full element + +Core renders the wrapping `` itself, rather than accepting a pre-built `` element. This is what lets core normalise size and inject the themed colour with no `cloneElement` and no wrapper gymnastics. The user supplies only what goes *inside* the ``, plus the couple of attributes core can't infer (`viewBox`, and for stroke icons the presentation attributes). This format already exists internally — the built-in `IconSvg` wrapper in [src/Icons.tsx](src/Icons.tsx) is exactly it; v2 just exposes that shape. + +### Naming invariant + +The glyph key and its paint key follow one mechanical rule, no exceptions: + +> **styles key = `icon` + PascalCase(glyph key)** + +| Glyph (`icons`) | Paint (`styles`) | +| --- | --- | +| `add` | `iconAdd` | +| `edit` | `iconEdit` | +| `delete` | `iconDelete` | +| `copy` | `iconCopy` | +| `ok` | `iconOk` | +| `cancel` | `iconCancel` | +| `collection` | `iconCollection` | + +The expand/collapse glyph is keyed `collection`, not `chevron` — it's named after its *function*, since a user-supplied glyph need not be a chevron at all. This also removes the only cross-name (`chevron` ↔ `iconCollection`) that broke the invariant. + +## Types ([src/types.ts](src/types.ts)) + +```ts +/** A themeable icon glyph. Core renders the wrapping itself, so it can + * normalise size and apply the theme's icon styling. Supply only what goes + * inside the , plus the attributes core can't infer. */ +export interface IconDefinition { + /** Inner SVG markup — //… pasted from a source icon, minus + * its outer tag. */ + content: React.ReactNode + /** From the source . Defaults to '0 0 24 24' (by far the most common). */ + viewBox?: string + /** Pass-through attributes — only needed for stroke-based icons + * (Lucide/Feather/…): { fill: 'none', stroke: 'currentColor', strokeWidth: 2 }. + * Colour flows in from the theme via currentColor; don't set size here — + * use `scale` (below). */ + svgProps?: React.SVGProps + /** Per-glyph size correction, multiplied onto the core size baseline + * (ICON_TEXT_SIZE_RATIO, see "How icon sizing works"). Default 1 = the + * standard icon size, so a normal full-bleed glyph needs no `scale` at all. + * This is OUR field — NOT the CSS `scale` property or an SVG `transform`; + * core reads it to compute the rendered em size and it never reaches the + * DOM. Use it only to compensate for a glyph whose artwork under/over-fills + * its viewBox relative to the rest of the set: e.g. `scale: 1.3` renders + * 30% bigger. */ + scale?: number +} + +/** A theme's icon glyphs. Keyed by the bare icon name; the matching paint lives + * in `styles` under `icon` + PascalCase (e.g. `collection` ↔ `iconCollection`). */ +export interface ThemeIcons { + add?: IconDefinition + edit?: IconDefinition + delete?: IconDefinition + copy?: IconDefinition + ok?: IconDefinition + cancel?: IconDefinition + collection?: IconDefinition +} +``` + +`IconReplacements` (was `Record`) is **removed**; `ThemeIcons` replaces it. + +### Theme shape ([types.ts:718](src/types.ts#L718)) + +```ts +export interface Theme { + displayName?: string + fragments?: ThemeFragments + icons?: ThemeIcons // NEW — glyphs + styles: ThemeStyles // paint, incl. iconAdd…iconCollection (unchanged) +} +``` + +`icons` merges across a `ThemeInput` array per-key, later theme wins — exactly parallel to how `styles` already compose. + +`icons` is also **required to be complete on `defaultTheme`**: the seven built-in glyphs are authored as `IconDefinition`s on `defaultTheme.icons`, each carrying its own `scale` correction (`delete` ≈ 1.04, `copy` ≈ 0.86, `ok` ≈ 0.9, `cancel` ≈ 1.3, `collection` ≈ 0.7 — tuned to today's look). Since `defaultTheme` is always merge layer 0, the merged `icons` a node sees is always fully populated, which is what lets the renderer drop its per-name fallback (see Rendering). User themes need only define the glyphs they want to *replace*. The standalone `IconAdd`…`IconChevron` components are absorbed into these definitions — their inner markup becomes the `content` of the corresponding `defaultTheme.icons` entry; the wrapper components themselves are no longer part of the render path. + +### `JsonEditorProps` ([types.ts:18](src/types.ts#L18)) + +```ts +- icons?: IconReplacements // REMOVED +``` + +The per-instance override is now theme-array layering, the same mechanism used for style overrides: + +```tsx +theme={[githubDark, { icons: { add: iconFromSvg('') } }]} +``` + +This unifies composition — styles, fragments, and icons all layer through the same `ThemeInput` array, with precedence equal to explicit array order. The standalone `icons` prop would be the odd one out, and anything it could express now moves to `theme.icons` with no other change. + +## Authoring workflow + +**Fill icon** (most icons — reactsvgicons / Boxicons / Material). Keep the inner paths and the `viewBox`: + +```ts +add: { + viewBox: '0 0 24 24', + content: , +} +``` + +**Stroke icon** (Lucide / Feather / Heroicons-outline). Same, but the `fill`/`stroke` attributes that lived on the source `` move into `svgProps`: + +```ts +ok: { + content: ( + <> + + + + ), + svgProps: { + fill: 'none', + stroke: 'currentColor', + strokeWidth: 2, + strokeLinecap: 'round', + strokeLinejoin: 'round', + }, +} +``` + +The only "hoops" are: strip the outer `` tag, copy the `viewBox` string, and for stroke icons move the presentation attrs into `svgProps`. The `iconFromSvg` utility (below) removes even these. + +## Rendering ([src/Icons.tsx](src/Icons.tsx)) + +The built-in glyphs live in `defaultTheme.icons` (see below), which is always layer 0 of the merged theme — so `icons` is **always fully populated** and there is no separate "built-in fallback component" path. Every icon, default or user-supplied, renders through the same `IconSvg` + `getStyles('iconX')` route, so user glyphs are themeable like the built-ins (today's `icons?.add ?? …` short-circuit, which bypasses styling for user glyphs, is gone). + +Because the glyph key and its paint key follow the naming invariant (`styles key = icon + PascalCase(glyph key)`), the whole per-name `switch` collapses to a single derivation — no case list, no fallback: + +```tsx +const ICON_TEXT_SIZE_RATIO = 1.4 // icon:text ratio — see "How icon sizing works" + +const { getStyles, icons } = useTheme() // merged ThemeIcons — always complete + +const Icon = ({ name, nodeData }: { name: keyof ThemeIcons; nodeData: NodeData }) => { + const def = icons[name] // guaranteed present + const styleKey = `icon${capitalise(name)}` as ThemeableElement // the invariant + const style = getStyles(styleKey, nodeData) // pure colour — no size + return ( + + {def.content} + + ) +} +``` + +So the naming invariant isn't just documentation — it's what lets the renderer drop the hand-written `switch` entirely. (`collection` → `iconCollection` falls out of the same rule; the old `chevron` cross-name was the only thing that previously blocked this.) + +Note what changed versus today's switch: there are **no per-name size constants** (`1.45em`, `1.2em`, the `90%`/`130%` font-size nudges) baked into the render path and **no per-name component branches**. The single `ICON_TEXT_SIZE_RATIO` carries the icon:text ratio, the only per-glyph size input is `def.scale`, and `defaultTheme`'s icon styles carry **colour only — no `fontSize`**. The built-in glyphs' historical corrections move *out* of the switch and *into* their `defaultTheme.icons` `scale` values — so a user-supplied glyph is never silently resized by a tweak that was tuned for a built-in. + +### How icon sizing works + +There is no SVG-intrinsic icon size; the only natural anchor is the ambient font-size. `.jer-editor-container` sets `font-size: 16px` ([style.css:89](src/style.css#L89)), the button wrappers use `font: inherit` ([style.css:368](src/style.css#L368) — flagged "load-bearing — the icons are sized in `em`"), so an icon's `em` resolves against the editor's font-size. `1em` renders the icon at exactly text height. + +Two facts that surprise people: + +- **`viewBox` does not set rendered size.** `width`/`height` (the `em`) do. `viewBox` only declares the internal coordinate space, which is scaled to *fit* that box (`preserveAspectRatio` defaults to fit-and-centre). A `0 0 24 24` glyph and a `0 0 512 512` glyph both fill the same em box. Changing the default `viewBox` would not globally scale anything — leave the path coords alone and you *clip*; scale them to match and you see *no change*. The only global size levers are the `em` and the inherited font-size. +- **The per-glyph corrections aren't a `viewBox` artefact.** They compensate for *source art*: the built-ins come from different icon sets (Boxicons, Lucide, Feather, Typicons, FontAwesome) that draw with different padding inside their viewBox and different stroke weight, so at an identical em box they look different sizes. + +That separates size into two clean inputs — and crucially neither lives in `styles`, so the [opt-out](#opting-out-of-a-themes-icons) scenario can't carry a stale size tweak: + +1. **Policy — `ICON_TEXT_SIZE_RATIO` (core constant, `1.4`).** The icon:text ratio: icons read better a bit larger than body text, so the standard icon is `1.4em` (~22px at the 16px base). One global number, not a `viewBox` or theme-style value. To scale *all* icons, change the ambient font-size (the editor's `font-size`); everything em-sized follows. +2. **Per-glyph correction — `IconDefinition.scale` (default 1).** Multiplied onto the baseline: `size = ICON_TEXT_SIZE_RATIO × (scale ?? 1)` em. `1` = the standard size, so a well-drawn full-bleed glyph needs no `scale`. The built-ins carry small corrections only because their source art under/over-fills its viewBox. + +Keeping the two apart is the point: a user dropping in a normal icon sets no `scale`, gets `1.4em`, and it matches the surrounding icons — because the policy already handled the icon:text ratio. Folding the `1.4` into each `scale` instead would force every user glyph to re-supply the policy (`scale: 1.4`) just to look normal. + +So the two "size" surfaces never compete: `scale` is **per-glyph and intrinsic** (a dedicated `size` prop), `styles.*.fontSize` is **global and thematic** (the slot, carried in the merged `style` object). One sets the `em`, the other sets what `1em` resolves to — they multiply rather than overwrite. + +## Override semantics + +Falls out of `currentColor` — no extra mechanism: + +- **Themeable glyph** → leave fills as `currentColor` (the `IconSvg` default). The composed `iconAdd`/`iconCollection`/… colour flows in via theme array order, like any style. +- **Fixed-colour glyph** → hardcode the fill in `content` (``). A child's own `fill` isn't touched by the colour the theme sets on the parent ``, so there's no need to also add an icon-style entry to "protect" it. + +This is per-*path*, not per-icon: a multi-colour glyph (flag, brand logo) survives theming as long as **every coloured path carries its own explicit `fill`**. A path that omits `fill` inherits the parent ``'s `currentColor` and *will* get repainted — so the protection comes from the explicit `fill`, not from the icon "being a brand icon". Real-world brand/flag SVGs almost always set explicit fills on every shape, so this is rarely a problem in practice. + +So array order still decides *what colour* a theme offers; the glyph's own content decides whether it *adopts* that colour — per-path, no extra style prop. + +## Opting out of a theme's icons + +A user may want a preset theme's *styling* but not its *glyphs* (keep the defaults). Layering can't express this: later array entries only add or override keys, they can't *clear* one — so `theme={[githubDark, { icons: {} }]}` keeps `githubDark`'s glyphs, because for each key its definition is still the last one that set it. The way to opt out is to **not bring them in** — omit the field before passing the theme: + +```tsx +const { icons, ...rest } = githubDark + +``` + +`rest` carries `githubDark`'s `styles` and `fragments` but no `icons`, so the merged glyphs fall back to `defaultTheme`'s (always layer 0). This clean omission is only possible *because* `icons` is its own top-level field — if glyphs lived inside `styles`, you couldn't drop them without also dropping the icon colours. + +**No sizing consequence.** Because size lives entirely in the glyph (`ICON_TEXT_SIZE_RATIO × scale`) and never in `styles`, dropping a theme's icons takes its `scale` corrections with it and the default glyphs bring their own. The theme's *icon colour* (`styles.iconX`) still applies to the default glyphs — usually what you want, since they're `currentColor`. (This is the payoff of moving the `1.4` out of theme styles: under the earlier `fontSize`-in-styles model, a theme that had tuned its slot size would have mis-sized the default glyphs here.) + +If we later want per-icon opt-out via layering, it'd need an explicit reset sentinel (e.g. `icons: { add: null }` → "back to default"). Not in scope; the whole-theme case is covered by the destructure. + +## `iconFromSvg` utility ([@json-edit-react/utils](packages/utils/)) + +Lets a user paste raw SVG markup verbatim instead of hand-authoring an `IconDefinition`. Lives in utils (opt-in sugar) so core stays zero-dep. + +```ts +export function iconFromSvg(svg: string | IconDefinition): IconDefinition +``` + +```ts +import { iconFromSvg } from '@json-edit-react/utils' + +const theme = { + icons: { + add: iconFromSvg(''), + }, +} +``` + +Behaviour: + +- **String** (the headline case): regex-extract the outer-tag attributes → `viewBox` + `svgProps`, and drop the inner markup into `content` via ``. Core still owns the ``, so size + theme colour work as for hand-authored definitions. `dangerouslySetInnerHTML` is benign here — the markup is author-supplied, not user input. +- **`IconDefinition` passthrough**: returned as-is, so `iconFromSvg` is the single front door regardless of source. No scope creep beyond string → `IconDefinition`. +- **Isomorphic**: regex extraction (not `DOMParser`) keeps it SSR-safe, since this is a render-path artefact. +- **Interned by input string**: identical markup returns the same object reference, so an inline `icons: { add: iconFromSvg(code) }` doesn't defeat theme/node memoization (see [dev-docs/PERF-ARCHITECTURE.md](dev-docs/PERF-ARCHITECTURE.md)). Reuses the filter-kit interning approach. + +## Knock-on changes + +- **Migration guide** — what a v1 user must act on: `icons` prop removed → move config to `theme.icons`; each value changes from a `JSX.Element` to an `IconDefinition` (or wrap with `iconFromSvg`); `chevron` → `collection`. +- **README** — drop the `icons` prop row; document `Theme.icons`, `IconDefinition`, and the `currentColor` theming rule (present tense). +- **`defaultTheme`** ([src/contexts/ThemeProvider/defaultTheme.ts](src/contexts/ThemeProvider/defaultTheme.ts)) — gains a complete `icons: ThemeIcons`, one `IconDefinition` per built-in glyph (inner markup from the old `Icon*` components, each with its tuned `scale`). The `iconAdd`…`iconCollection` **style** entries stay **colour-only — no `fontSize`** (the icon:text ratio is core's `ICON_TEXT_SIZE_RATIO`, not a theme-style value). Because the definitions carry JSX `content`, the file emits JSX — it becomes `.tsx` (or imports the glyph content from a `.tsx` sibling). Core's rollup already handles TSX, so no build change for core. +- **`Icons.tsx`** ([src/Icons.tsx](src/Icons.tsx)) — the per-name `switch` and the seven `Icon*` wrapper components are removed; what remains is `IconSvg` plus the small invariant-driven `Icon` renderer (`icon` + PascalCase derivation). `IconSvg` keeps its current shape — it *is* the `IconDefinition` renderer. +- **Themes package build** — a theme that defines `icons` emits JSX → the package gains `react/jsx-runtime` (React is already a peer dep there). Not a new category, since themes already carry `ThemeFunction` code, but the rollup config needs TSX handling. +- **`ThemeProvider`** ([src/contexts/ThemeProvider/ThemeProvider.tsx](src/contexts/ThemeProvider/ThemeProvider.tsx)) — `icons` now sourced from the merged theme rather than a separate prop; `EMPTY_ICONS` / `IconReplacements` references updated. The merged `icons` is exposed via `useTheme()` alongside `getStyles`. +- **Tests** — icon-replacement tests move from the prop to `theme.icons`; add coverage for `iconFromSvg` (string parse, interning stability), a themed `currentColor` glyph picking up the theme colour, a fixed-`fill` glyph resisting it, and a `scale` override changing rendered size. +- **`src/index.ts`** — drop the `IconReplacements` export; add `IconDefinition` and `ThemeIcons`. + +## Resolved decisions + +- **`ThemeIcons`** is the name that replaces `IconReplacements`. ✔ +- **The built-in glyphs move into `defaultTheme.icons`** as `IconDefinition`s, each carrying its tuned `scale`. They are *not* kept as fallback components — `defaultTheme` being merge layer 0 makes the merged `icons` always complete, so the renderer needs no fallback path and the per-name `switch` collapses to the naming invariant. The `Icon*` wrapper components are absorbed (their markup becomes the definitions' `content`). ✔ +- **The icon:text baseline (`1.4`) is a core constant (`ICON_TEXT_SIZE_RATIO`), and `scale` stays a relative multiplier** (default 1 = standard size). It is *not* a `fontSize` in theme styles and *not* absorbed into absolute `scale` values. This keeps size out of `styles` (so the opt-out scenario carries no stale size tweak) while keeping `scale: 1` mean "matches the surrounding icons" — so an untuned user glyph drops in correctly. Icon `styles` are colour-only. ✔ + +## Implementation order + +The work spans core, utils, and themes with hard dependencies (types before consumers; `defaultTheme.icons` before the renderer can drop its fallback; the exported `IconDefinition` type before `iconFromSvg`). Sequence: + +1. **Types + exports (core).** Add `IconDefinition`, `ThemeIcons`; add `icons?` to `Theme`; remove `IconReplacements` and `JsonEditorProps.icons`. Update [src/index.ts](src/index.ts) (drop `IconReplacements`, add the two new types). This compiles with the old renderer still in place. +2. **`defaultTheme.icons` (core).** Author the seven `IconDefinition`s (markup lifted from the `Icon*` components) with their `scale`s. Leave the `iconAdd`…`iconCollection` style entries colour-only (no `fontSize`). Rename `defaultTheme.ts` → `.tsx` (carries JSX now). +3. **Renderer (core).** Rewrite [src/Icons.tsx](src/Icons.tsx): delete the `switch` and `Icon*` components, keep `IconSvg`, add the invariant-driven `Icon` (`icon` + PascalCase) reading from merged `icons` and sizing via the `ICON_TEXT_SIZE_RATIO` constant × `scale`. Wire `ThemeProvider` to expose merged `icons` via `useTheme()`; drop `EMPTY_ICONS` and the separate-prop path. Remove `icons` threading from [JsonEditor.tsx](src/JsonEditor.tsx). +4. **Core tests.** Replacement via `theme.icons`; `currentColor` glyph adopts theme colour; fixed-`fill` glyph resists it; `scale` changes rendered size; `collection` (ex-`chevron`) keyed correctly. +5. **`iconFromSvg` (utils).** Regex parse (string → `IconDefinition`), passthrough, interning by input string. Tests in `packages/utils/test/` (parse correctness + reference stability). +6. **Themes package build.** Confirm `react/jsx-runtime` / TSX handling in the rollup config so a theme can ship `icons`. +7. **Docs + changeset.** README (present tense: `Theme.icons`, `IconDefinition`, `currentColor` rule, `scale`; drop the `icons` prop row); migration guide (prop removed → `theme.icons`; `JSX.Element` → `IconDefinition`/`iconFromSvg`; `chevron` → `collection`); `pnpm changeset` for core + utils (+ themes if its build changes). + +Steps 1–4 are one core PR; 5 is a utils PR; 6–7 fold into whichever PR touches the surface. Each step leaves the tree compiling. From fba710595e5718fc2ec9f55da1968a1b3cd04eea Mon Sep 17 00:00:00 2001 From: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:23:07 +1200 Subject: [PATCH 02/10] Rewrite types --- src/index.ts | 3 ++- src/types.ts | 58 ++++++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/src/index.ts b/src/index.ts index 5ea4fdc0..559a9f73 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,7 +35,8 @@ export { type NewKeyOptionsFunction, type DefaultValueFunction, type CompareFunction, - type IconReplacements, + type IconDefinition, + type ThemeIcons, type CollectionNodeProps, type ValueNodeProps, type CustomComponentProps, diff --git a/src/types.ts b/src/types.ts index f1e23d10..a43fc6e8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -15,7 +15,6 @@ export interface JsonEditorProps { showErrorMessages?: boolean showClipboardButton?: boolean theme?: ThemeInput - icons?: IconReplacements className?: string id?: string indent?: number @@ -174,16 +173,6 @@ export interface EditingState { force?: boolean } -export interface IconReplacements { - add?: JSX.Element - edit?: JSX.Element - delete?: JSX.Element - copy?: JSX.Element - ok?: JSX.Element - cancel?: JSX.Element - chevron?: JSX.Element -} - export interface TextEditorProps { value: string onChange: (value: string) => void @@ -714,10 +703,57 @@ export type ThemeFragments = Record */ export type ThemeStyles = Partial> +/** + * A themeable icon glyph. Core renders the wrapping `` itself, so it can + * normalise size and apply the theme's icon styling. Supply only what goes + * inside the ``, plus the attributes core can't infer. + */ +export interface IconDefinition { + /** + * Inner SVG markup — ``/``/``… pasted from a source icon, + * minus its outer `` tag. + */ + content: React.ReactNode + /** From the source ``. Defaults to '0 0 24 24' (by far the most common). */ + viewBox?: string + /** + * Pass-through `` attributes — only needed for stroke-based icons + * (Lucide/Feather/…): `{ fill: 'none', stroke: 'currentColor', strokeWidth: 2 }`. + * Colour flows in from the theme via `currentColor`; don't set size here — + * use `scale` (below). + */ + svgProps?: React.SVGProps + /** + * Per-glyph size correction, multiplied onto the core size baseline + * (`ICON_TEXT_SIZE_RATIO`). Default 1 = the standard icon size, so a normal + * full-bleed glyph needs no `scale` at all. This is OUR field — NOT the CSS + * `scale` property or an SVG `transform`; core reads it to compute the + * rendered em size and it never reaches the DOM. Use it only to compensate + * for a glyph whose artwork under/over-fills its viewBox relative to the rest + * of the set: e.g. `scale: 1.3` renders 30% bigger. + */ + scale?: number +} + +/** + * A theme's icon glyphs. Keyed by the bare icon name; the matching paint lives + * in `styles` under `icon` + PascalCase (e.g. `collection` ↔ `iconCollection`). + */ +export interface ThemeIcons { + add?: IconDefinition + edit?: IconDefinition + delete?: IconDefinition + copy?: IconDefinition + ok?: IconDefinition + cancel?: IconDefinition + collection?: IconDefinition +} + /** A full theme definition. */ export interface Theme { displayName?: string fragments?: ThemeFragments + icons?: ThemeIcons styles: ThemeStyles } From b1f6bb28c08135f53f917a221e5b7b5907817166 Mon Sep 17 00:00:00 2001 From: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:29:12 +1200 Subject: [PATCH 03/10] Add icons to defaultTheme --- src/contexts/ThemeProvider/defaultTheme.ts | 32 ------ src/contexts/ThemeProvider/defaultTheme.tsx | 117 ++++++++++++++++++++ 2 files changed, 117 insertions(+), 32 deletions(-) delete mode 100644 src/contexts/ThemeProvider/defaultTheme.ts create mode 100644 src/contexts/ThemeProvider/defaultTheme.tsx diff --git a/src/contexts/ThemeProvider/defaultTheme.ts b/src/contexts/ThemeProvider/defaultTheme.ts deleted file mode 100644 index 2a492b4d..00000000 --- a/src/contexts/ThemeProvider/defaultTheme.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { type Theme } from '../../types' - -export const defaultTheme: Theme = { - displayName: 'Default', - styles: { - container: { - backgroundColor: '#f6f6f6', - fontFamily: 'monospace', - }, - // collection: {}, - // collectionInner: {}, - // collectionElement: {}, - // dropZone: {}, - property: '#292929', - bracket: { color: '#002b36', fontWeight: 'bold' }, - itemCount: { color: '#0000004d', fontStyle: 'italic' }, - string: '#cb4b16', - number: '#268bd2', - boolean: 'green', - null: { color: '#dc322f', fontVariant: 'small-caps', fontWeight: 'bold' }, - input: ['#292929'], - inputHighlight: '#b3d8ff', - error: { fontSize: '0.8em', color: 'red', fontWeight: 'bold' }, - iconCollection: '#002b36', - iconEdit: '#2aa198', - iconDelete: '#cb4b16', - iconAdd: '#2aa198', - iconCopy: '#268bd2', - iconOk: 'green', - iconCancel: '#cb4b16', - }, -} diff --git a/src/contexts/ThemeProvider/defaultTheme.tsx b/src/contexts/ThemeProvider/defaultTheme.tsx new file mode 100644 index 00000000..7d31ee87 --- /dev/null +++ b/src/contexts/ThemeProvider/defaultTheme.tsx @@ -0,0 +1,117 @@ +import { type SVGProps } from 'react' +import { type Theme } from '../../types' + +// Shared presentation attributes for stroke-based glyphs (Lucide/Feather): the +// art is drawn as outlines, so colour comes from `stroke` (via currentColor), +// not `fill`. +const strokeIconProps: SVGProps = { + fill: 'none', + stroke: 'currentColor', + strokeLinecap: 'round', + strokeLinejoin: 'round', + strokeWidth: 2, +} + +export const defaultTheme: Theme = { + displayName: 'Default', + // The seven built-in glyphs. Core renders the wrapping ; each definition + // supplies only the inner markup plus the attributes core can't infer. `scale` + // is a per-glyph size correction (multiplied onto core's ICON_TEXT_SIZE_RATIO) + // that compensates for source art under/over-filling its viewBox — the set is + // drawn by different icon families (Boxicons, Lucide, Feather, Typicons, + // FontAwesome), so at an identical em box they'd otherwise look uneven. + icons: { + // icon:bx-plus-circle | Boxicons https://boxicons.com/ | Atisa + add: { + content: ( + <> + + + + ), + }, + // icon:bx-edit | Boxicons https://boxicons.com/ | Atisa + edit: { + content: ( + <> + + + + ), + svgProps: { transform: 'translate(0, 0.5)' }, + }, + // icon:delete-forever | Material Design Icons | Austin Andrews + delete: { + content: ( + + ), + scale: 1.04, + }, + // icon:clipboard-copy | Lucide https://lucide.dev/ + copy: { + content: ( + <> + + + + + ), + svgProps: strokeIconProps, + scale: 0.86, + }, + // icon:check-circle | FeatherIcons https://feathericons.com/ | Cole Bemis + ok: { + content: ( + <> + + + + ), + svgProps: strokeIconProps, + scale: 0.9, + }, + // icon:cancel | Typicons https://www.s-ings.com/typicons/ | Stephen Hutchings + cancel: { + content: ( + + ), + svgProps: { baseProfile: 'tiny' }, + scale: 1.3, + }, + // icon:chevron-down | FontAwesome https://fontawesome.com/ + collection: { + content: ( + + ), + viewBox: '0 0 512 512', + scale: 0.7, + }, + }, + styles: { + container: { + backgroundColor: '#f6f6f6', + fontFamily: 'monospace', + }, + // collection: {}, + // collectionInner: {}, + // collectionElement: {}, + // dropZone: {}, + property: '#292929', + bracket: { color: '#002b36', fontWeight: 'bold' }, + itemCount: { color: '#0000004d', fontStyle: 'italic' }, + string: '#cb4b16', + number: '#268bd2', + boolean: 'green', + null: { color: '#dc322f', fontVariant: 'small-caps', fontWeight: 'bold' }, + input: ['#292929'], + inputHighlight: '#b3d8ff', + error: { fontSize: '0.8em', color: 'red', fontWeight: 'bold' }, + iconCollection: '#002b36', + iconEdit: '#2aa198', + iconDelete: '#cb4b16', + iconAdd: '#2aa198', + iconCopy: '#268bd2', + iconOk: 'green', + iconCancel: '#cb4b16', + }, +} From a4f557f3ce43b621f9e4ef567ff2bbe6af790443 Mon Sep 17 00:00:00 2001 From: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Wed, 17 Jun 2026 18:10:17 +1200 Subject: [PATCH 04/10] Rewire theme engine to new structure --- src/CollectionNode.tsx | 2 +- src/Icons.tsx | 170 +++++-------------- src/JsonEditor.tsx | 2 +- src/contexts/CollapseProvider.tsx | 3 +- src/contexts/ThemeProvider/ThemeProvider.tsx | 20 ++- src/contexts/ThemeProvider/compileStyles.ts | 24 ++- src/index.ts | 2 - src/utils/keyboard.ts | 4 +- src/utils/misc.ts | 4 + 9 files changed, 82 insertions(+), 149 deletions(-) diff --git a/src/CollectionNode.tsx b/src/CollectionNode.tsx index c4a9fa24..f98a7faa 100644 --- a/src/CollectionNode.tsx +++ b/src/CollectionNode.tsx @@ -584,7 +584,7 @@ const CollectionNodeBase: React.FC = (props) => { style={{ zIndex: 11 + nodeData.level * 2, transition: cssTransitionValue }} onClick={handleCollapse} > - + {shouldShowKey && } {!isEditing && ( diff --git a/src/Icons.tsx b/src/Icons.tsx index d9bfb073..79fa531b 100644 --- a/src/Icons.tsx +++ b/src/Icons.tsx @@ -1,143 +1,55 @@ -import React, { type JSX } from 'react' +import { type JSX, type SVGProps } from 'react' import { useTheme } from './contexts' -import { type NodeData } from './types' - -// All icons from: https://reactsvgicons.com/ - -export interface IconProps { - size: string - style?: React.CSSProperties - className?: string -} - -// Shared wrapper for all icons. `size` maps to width/height, and the most -// common attributes (24×24 viewBox, fill="currentColor") are defaulted here so -// each icon only needs to declare the attributes that differ. Any other SVG -// attribute (stroke, transform, baseProfile, viewBox overrides, …) passes -// through unchanged. -const IconSvg: React.FC<{ size: string } & React.SVGProps> = ({ +import { type NodeData, type ThemeableElement, type ThemeIcons } from './types' + +// The icon:text size ratio. Icons read a little larger than body text, so the +// standard icon renders at 1.4em (~22px against the editor's 16px base). This +// is the one global size policy; per-glyph art corrections ride on top of it +// via `IconDefinition.scale`. +const ICON_TEXT_SIZE_RATIO = 1.4 + +// Shared wrapper for every icon — it IS the `IconDefinition` renderer. +// `size` maps to width/height, and the most common attributes (24×24 viewBox, +// fill="currentColor") are defaulted here so a glyph only declares what +// differs. Any other SVG attribute (stroke, transform, baseProfile, …) passes +// through from the definition's `svgProps`. +const IconSvg = ({ size, viewBox = '0 0 24 24', fill = 'currentColor', children, ...props -}): JSX.Element => ( +}: { size: string } & SVGProps): JSX.Element => ( {children} ) -export const IconAdd: React.FC = (props): JSX.Element => ( - // icon:bx-plus-circle | Boxicons https://boxicons.com/ | Atisa - - - - -) - -export const IconEdit: React.FC = (props): JSX.Element => ( - // icon:bx-edit | Boxicons https://boxicons.com/ | Atisa - - - - -) - -export const IconDelete: React.FC = (props): JSX.Element => ( - // icon:bx-edit | Boxicons https://boxicons.com/ | Atisa icon:delete-forever | - // Material Design Icons https://materialdesignicons.com/ | Austin Andrews - - - -) - -const sharedSVGProps = { - fill: 'none', - stroke: 'currentColor', - strokeLinecap: 'round' as 'round' | 'inherit' | 'butt' | 'square' | undefined, - strokeLinejoin: 'round' as 'round' | 'inherit' | 'bevel' | 'miter' | undefined, - strokeWidth: 2, -} - -export const IconCopy: React.FC = (props): JSX.Element => ( - // icon:clipboard-copy | Lucide https://lucide.dev/ - - - - - -) - -export const IconOk: React.FC = (props): JSX.Element => ( - // icon:check-circle | FeatherIcons https://feathericons.com/ | Cole Bemis - - - - -) - -export const IconCancel: React.FC = (props): JSX.Element => ( - // icon:cancel | Typicons https://www.s-ings.com/typicons/ | Stephen - // Hutchings - - - -) - -export const IconChevron: React.FC = (props): JSX.Element => ( - // icon:chevron-down | FontAwesome https://fontawesome.com/ - - - -) - -interface IconSharedProps { - name: string +// Renders a themed icon by name. The glyph comes from the merged theme `icons` +// (always complete — `defaultTheme` is layer 0), and its paint key is derived +// by the naming invariant (`icon` + PascalCase), so the whole icon set renders +// through one path with no per-name switch and no fallback. +export const Icon = ({ + name, + nodeData, +}: { + name: keyof ThemeIcons nodeData: NodeData -} - -export const Icon: React.FC = ({ name, nodeData }): JSX.Element => { +}): JSX.Element => { const { getStyles, icons } = useTheme() - - const commonProps = { size: '1.4em', className: 'jer-icon' } - - switch (name) { - case 'add': - return icons?.add ?? - case 'edit': - return icons?.edit ?? - case 'delete': - return ( - icons?.delete ?? ( - - ) - ) - case 'copy': - return ( - icons?.copy ?? ( - - ) - ) - case 'ok': - return ( - icons?.ok ?? ( - - ) - ) - case 'cancel': - return ( - icons?.cancel ?? ( - - ) - ) - case 'chevron': - return ( - icons?.chevron ?? - ) - default: - return <> - } + const def = icons[name] + const styleKey = `icon${name[0].toUpperCase()}${name.slice(1)}` as ThemeableElement + return ( + + {def.content} + + ) } diff --git a/src/JsonEditor.tsx b/src/JsonEditor.tsx index 1a360fb7..bd52f926 100644 --- a/src/JsonEditor.tsx +++ b/src/JsonEditor.tsx @@ -757,7 +757,7 @@ export function JsonEditor(props: JsonEditorProps): React.React const innerProps = props as unknown as JsonEditorProps return ( - + React.CSSProperties - icons: IconReplacements + // Always complete — `defaultTheme` defines all seven glyphs and is merge layer 0. + icons: Required } const initialContext: ThemeContext = { getStyles: () => ({}), - icons: {}, + icons: mergeIcons(defaultTheme), } const ThemeProviderContext = createContext(initialContext) -// Stable default so an omitted `icons` prop doesn't churn the context value. -const EMPTY_ICONS: IconReplacements = {} - export const ThemeProvider = ({ theme = defaultTheme, - icons = EMPTY_ICONS, children, }: { theme?: ThemeInput - icons?: IconReplacements children: React.ReactNode }) => { // Memoize so the context value is referentially stable across unrelated @@ -40,6 +41,7 @@ export const ThemeProvider = ({ // Pass a stable `theme` reference (e.g. memoize an inline theme array) to get // the full benefit. const styles = useMemo(() => compileStyles(theme), [theme]) + const icons = useMemo(() => mergeIcons(theme), [theme]) // The two non-inlineable colours feed static rules in style.css, so they're // written to the document root as CSS custom properties whenever the theme diff --git a/src/contexts/ThemeProvider/compileStyles.ts b/src/contexts/ThemeProvider/compileStyles.ts index 6a0ce0fc..16198835 100644 --- a/src/contexts/ThemeProvider/compileStyles.ts +++ b/src/contexts/ThemeProvider/compileStyles.ts @@ -4,11 +4,13 @@ import { type ThemeValueUnit, type ThemeFunction, type ThemeableElement, + type ThemeIcons, type CompiledStyles, type Theme, type NodeData, } from '../../types' import { defaultTheme } from './defaultTheme' +import { toArray } from '../../utils/misc' // Elements whose bare-string shorthand targets a property other than `color`. const DEFAULT_PROP: Partial> = { @@ -20,11 +22,15 @@ const DEFAULT_PROP: Partial> = { inputHighlight: 'backgroundColor', } +// The ordered theme stack a `ThemeInput` resolves to: `defaultTheme` first +// (always layer 0), then each supplied entry coerced to a full `Theme`. Shared +// by both derivations (`compileStyles`, `mergeIcons`) so the "merge over +// default" rule lives in exactly one place. +const resolveThemeStack = (themeInput: ThemeInput): Theme[] => + [defaultTheme, ...toArray(themeInput)].map((t) => ('styles' in t ? t : { styles: t })) + export const compileStyles = (themeInput: ThemeInput): CompiledStyles => { - const themes: Theme[] = [ - defaultTheme, - ...(Array.isArray(themeInput) ? themeInput : [themeInput]), - ].map((t) => ('styles' in t ? t : { styles: t })) + const themes = resolveThemeStack(themeInput) const base: Partial> = {} const fns: Partial> = {} @@ -62,6 +68,16 @@ export const compileStyles = (themeInput: ThemeInput): CompiledStyles => { return compiled } +// Merge each theme's `icons` in array order (defaultTheme first), later wins +// per glyph key — exactly parallel to how `styles` compose. defaultTheme defines +// all seven glyphs, so the result is always complete and the renderer can index +// it without a fallback path. +export const mergeIcons = (themeInput: ThemeInput): Required => { + const merged = {} as ThemeIcons + for (const { icons } of resolveThemeStack(themeInput)) if (icons) Object.assign(merged, icons) + return merged as Required +} + // Resolve a compiled element to concrete CSS: call the closure, return the // object as-is (a stable reference), or `{}` for an element no theme styles — // so the public contract is always a concrete CSSProperties object. diff --git a/src/index.ts b/src/index.ts index 559a9f73..295ad608 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,6 @@ export { JsonEditor } from './JsonEditor' export { JsonViewer } from './JsonViewer' export { defaultTheme } from './contexts/ThemeProvider' -export { IconAdd, IconEdit, IconDelete, IconCopy, IconOk, IconCancel, IconChevron } from './Icons' export { StringDisplay, StringEdit, useKeyboardListener } from './ValueNodes' export { AutogrowTextArea } from './AutogrowTextArea' export { type SelectProps } from './NativeSelect' @@ -59,5 +58,4 @@ export { type TypeOptions, type UpdateFunctionProps, } from './types' -export { type IconProps } from './Icons' export { type LocalisedStrings, type TranslateFunction } from './localisation' diff --git a/src/utils/keyboard.ts b/src/utils/keyboard.ts index 02e0ef1a..c093af64 100644 --- a/src/utils/keyboard.ts +++ b/src/utils/keyboard.ts @@ -11,7 +11,7 @@ import { } from '../types' import { buildNodeData } from './buildNodeData' import { extract } from './extract' -import { isCollection } from './misc' +import { isCollection, toArray } from './misc' // A general keyboard handler. Matches keyboard events against the predefined // keyboard controls (defaults, or user-defined), and maps them to specific @@ -125,7 +125,7 @@ export const getFullKeyboardControlMap = (userControls: KeyboardControls): Keybo } const definition = (() => { - if (isModifierKey) return Array.isArray(value) ? value : [value] + if (isModifierKey) return toArray(value) if (typeof value === 'string') return { key: value } return value })() as KeyEvent & React.ModifierKey[] diff --git a/src/utils/misc.ts b/src/utils/misc.ts index fb93d403..03b05deb 100644 --- a/src/utils/misc.ts +++ b/src/utils/misc.ts @@ -11,6 +11,10 @@ import { export const NOOP = () => {} +// Wrap a value in an array unless it already is one — for the "accepts one or +// many" inputs (theme layers, collapse states, keyboard modifiers). +export const toArray = (value: T | T[]): T[] => (Array.isArray(value) ? value : [value]) + export const isCollection = (value: unknown): value is Record | unknown[] => value !== null && typeof value === 'object' From 7aa1043d138e4c5873243a1c70e3146748861cf1 Mon Sep 17 00:00:00 2001 From: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Wed, 17 Jun 2026 18:18:23 +1200 Subject: [PATCH 05/10] Create tests --- test/icons.test.tsx | 167 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 test/icons.test.tsx diff --git a/test/icons.test.tsx b/test/icons.test.tsx new file mode 100644 index 00000000..ed35b487 --- /dev/null +++ b/test/icons.test.tsx @@ -0,0 +1,167 @@ +import { render } from '@testing-library/react' +import { Icon } from '../src/Icons' +import { ThemeProvider } from '../src/contexts/ThemeProvider/ThemeProvider' +import { mergeIcons } from '../src/contexts/ThemeProvider/compileStyles' +import { type ThemeInput, type ThemeIcons, type IconDefinition, type NodeData } from '../src/types' + +// Minimal NodeData fixture — the icon paint path only reads it to feed theme +// functions, and these themes are all static. +const nodeData: NodeData = { + key: 'k', + path: [], + level: 0, + index: 0, + value: 'x', + size: null, + parentData: null, + fullData: {}, +} + +// Render a single themed icon and hand back its container for DOM queries. +const renderIcon = (name: keyof ThemeIcons, theme?: ThemeInput) => + render( + + + + ).container + +const allNames: Array = [ + 'add', + 'edit', + 'delete', + 'copy', + 'ok', + 'cancel', + 'collection', +] + +// ─── A — mergeIcons: completeness & layering ───────────────────────────────── + +describe('mergeIcons', () => { + it('is always complete — every glyph resolves from the default theme', () => { + const merged = mergeIcons({}) + allNames.forEach((name) => expect(merged[name]).toBeDefined()) + // Built-in detail carried through (the chevron glyph + its size correction). + expect(merged.collection.viewBox).toBe('0 0 512 512') + expect(merged.collection.scale).toBe(0.7) + }) + + it('swaps only the overridden glyph, keeping the rest default', () => { + const custom: IconDefinition = { content: 'X' } + const merged = mergeIcons({ icons: { add: custom }, styles: {} }) + expect(merged.add).toBe(custom) + // Untouched glyphs are still the built-ins. + expect(merged.edit).toBe(mergeIcons({}).edit) + }) + + it('layers an array, later theme winning per glyph key', () => { + const a: IconDefinition = { content: 'A' } + const b: IconDefinition = { content: 'B' } + const merged = mergeIcons([ + { icons: { add: a }, styles: {} }, + { icons: { add: b }, styles: {} }, + ]) + expect(merged.add).toBe(b) + // A key set by neither overlay stays default. + expect(merged.delete).toBe(mergeIcons({}).delete) + }) + + it('ignores a bare ThemeStyles entry (no icons) and keeps the defaults', () => { + const merged = mergeIcons({ string: 'red' }) + expect(merged.add).toBe(mergeIcons({}).add) + }) +}) + +// ─── B — rendering: replacement, sizing, keying, hover class ───────────────── + +describe('Icon — rendering', () => { + it('renders a theme-supplied glyph in place of the built-in', () => { + const custom: IconDefinition = { + content: , + } + const container = renderIcon('add', { icons: { add: custom }, styles: {} }) + expect(container.querySelector('[data-testid="custom-add"]')).toBeInTheDocument() + // The built-in add glyph (which starts "M13 7…") is gone. + expect(container.querySelector('path[d^="M13 7"]')).toBeNull() + }) + + it('sizes the icon at ICON_TEXT_SIZE_RATIO × scale em', () => { + // No scale → the bare 1.4 ratio. + const plain = renderIcon('add', { styles: {} }).querySelector('svg')! + expect(plain).toHaveAttribute('width', '1.4em') + expect(plain).toHaveAttribute('height', '1.4em') + + // scale multiplies the ratio: 1.4 × 0.5 = 0.7em. + const scaled: IconDefinition = { content: , scale: 0.5 } + const svg = renderIcon('add', { icons: { add: scaled }, styles: {} }).querySelector('svg')! + expect(svg).toHaveAttribute('width', '0.7em') + expect(svg).toHaveAttribute('height', '0.7em') + }) + + it('keys `collection` to the iconCollection paint and the chevron glyph', () => { + const svg = renderIcon('collection', { + styles: { iconCollection: 'rgb(1, 2, 3)' }, + }).querySelector('svg')! + expect(svg).toHaveStyle({ color: 'rgb(1, 2, 3)' }) + // The built-in collection glyph carries the 512 viewBox. + expect(svg).toHaveAttribute('viewBox', '0 0 512 512') + }) + + it('gives action icons the jer-icon hover class but not the collapse chevron', () => { + expect(renderIcon('add', { styles: {} }).querySelector('svg')).toHaveClass('jer-icon') + expect(renderIcon('collection', { styles: {} }).querySelector('svg')).not.toHaveClass( + 'jer-icon' + ) + }) +}) + +// ─── C — colour: currentColor adoption vs. fixed fills ─────────────────────── +// +// jsdom doesn't resolve `currentColor` (no layout/cascade), so these assert the +// *wiring* that makes it work: the theme colour reaches the and the +// fill="currentColor" default is in place, while a path's own `fill` is left +// untouched. + +describe('Icon — colour', () => { + it('applies the theme colour to the icon (general colour is themed)', () => { + const svg = renderIcon('add', { styles: { iconAdd: 'rgb(10, 20, 30)' } }).querySelector('svg')! + expect(svg).toHaveAttribute('fill', 'currentColor') + expect(svg).toHaveStyle({ color: 'rgb(10, 20, 30)' }) + // The built-in add paths declare no fill, so they inherit the svg's + // currentColor → the theme colour. + svg.querySelectorAll('path').forEach((p) => expect(p).not.toHaveAttribute('fill')) + }) + + it('flows the theme colour through `stroke` for stroke-based glyphs', () => { + const svg = renderIcon('ok', { styles: { iconOk: 'rgb(5, 6, 7)' } }).querySelector('svg')! + expect(svg).toHaveAttribute('fill', 'none') + expect(svg).toHaveAttribute('stroke', 'currentColor') + expect(svg).toHaveStyle({ color: 'rgb(5, 6, 7)' }) + }) + + it('preserves a path’s explicit fill while repainting its fill-less siblings', () => { + // A "flag"/brand-style glyph: one hard-coded colour that must survive + // theming, one themeable shape that should adopt the theme colour. + const flag: IconDefinition = { + content: ( + <> + + + + ), + } + const container = renderIcon('add', { + icons: { add: flag }, + styles: { iconAdd: 'rgb(0, 128, 0)' }, + }) + const svg = container.querySelector('svg')! + + // The theme colour reaches the svg… + expect(svg).toHaveAttribute('fill', 'currentColor') + expect(svg).toHaveStyle({ color: 'rgb(0, 128, 0)' }) + // …but the explicit fill is untouched (brand colour preserved)… + expect(container.querySelector('[data-testid="fixed"]')).toHaveAttribute('fill', '#ff0000') + // …while the fill-less sibling inherits currentColor → the theme colour. + expect(container.querySelector('[data-testid="themed"]')).not.toHaveAttribute('fill') + }) +}) From b088dccf1593e18a4724d7eee1006e450a8edbad Mon Sep 17 00:00:00 2001 From: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:30:24 +1200 Subject: [PATCH 06/10] Create iconFromSvg method, plus tests --- packages/utils/src/_common/intern.ts | 55 ++++++++++++ packages/utils/src/filters/_intern.ts | 62 +------------- packages/utils/src/filters/index.ts | 3 +- packages/utils/src/icon/iconFromSvg.ts | 95 +++++++++++++++++++++ packages/utils/src/icon/index.ts | 1 + packages/utils/src/index.ts | 4 + packages/utils/test/icon.test.tsx | 112 +++++++++++++++++++++++++ 7 files changed, 272 insertions(+), 60 deletions(-) create mode 100644 packages/utils/src/_common/intern.ts create mode 100644 packages/utils/src/icon/iconFromSvg.ts create mode 100644 packages/utils/src/icon/index.ts create mode 100644 packages/utils/test/icon.test.tsx diff --git a/packages/utils/src/_common/intern.ts b/packages/utils/src/_common/intern.ts new file mode 100644 index 00000000..0f907565 --- /dev/null +++ b/packages/utils/src/_common/intern.ts @@ -0,0 +1,55 @@ +// Soft cap on a single builder's argument cache. Literal arguments keep these +// tiny and bounded; the cap only bites if a consumer feeds pathologically +// dynamic arguments in a loop, and clearing then just rebuilds on next use — +// never a correctness issue, only a lost cache hit. +const MAX_CACHE_ENTRIES = 10_000 + +// Make an argument injectively serialisable. JSON.stringify already does the +// hard part — it quotes and escapes strings (so `['a','b']` can't collide with +// `['a,b']`) and keeps the number 1 distinct from the string "1". We only need +// to special-case RegExp, which JSON would flatten to `{}` (it has no own +// enumerable props); tag it by source + flags so `/x/i` and `/x/g` differ. The +// recursion covers RegExps nested inside array/object args. +const normalise = (arg: unknown): unknown => { + if (arg instanceof RegExp) return { __re: arg.source, flags: arg.flags } + if (Array.isArray(arg)) return arg.map(normalise) + if (arg !== null && typeof arg === 'object') + return Object.fromEntries(Object.entries(arg).map(([k, v]) => [k, normalise(v)])) + return arg +} + +const keyOf = (args: readonly unknown[]): string => JSON.stringify(args.map(normalise)) + +/** + * Wrap a value-argument builder so equal arguments return the SAME instance + * ("interning" / hash-consing). + * + * Why it matters: it lets a builder be written inline on a prop + * (`allowEdit={byKey('name')}`, `icons={{ add: iconFromSvg(code) }}`) without + * minting a fresh value every render. json-edit-react compares such props by + * identity (the node `React.memo` boundary, and the theme / `useMemo(…, [prop])` + * paths upstream), so a new identity each render would defeat fine-grained + * re-rendering tree-wide. A stable identity keeps the memo intact; a genuinely + * different argument still produces a different instance, so real changes still + * propagate. + * + * Each wrapped builder owns its own `Map`, keyed by an injective JSON + * serialisation of the arguments (see `keyOf`). The cache lives as long as the + * builder (module scope) and grows with the number of DISTINCT argument-sets — + * bounded for the usual literal args; `MAX_CACHE_ENTRIES` backstops the rest. + * + * Builders whose arguments are FUNCTIONS can't be string-keyed (distinct + * closures with identical source must stay distinct); intern those on reference + * identity via a `WeakMap` instead. + */ +export const intern = (build: (...args: A) => R): ((...args: A) => R) => { + const cache = new Map() + return (...args: A): R => { + const key = keyOf(args) + if (cache.has(key)) return cache.get(key) as R + const result = build(...args) + if (cache.size >= MAX_CACHE_ENTRIES) cache.clear() + cache.set(key, result) + return result + } +} diff --git a/packages/utils/src/filters/_intern.ts b/packages/utils/src/filters/_intern.ts index 1e24e201..121fbce4 100644 --- a/packages/utils/src/filters/_intern.ts +++ b/packages/utils/src/filters/_intern.ts @@ -1,64 +1,5 @@ import type { FilterPredicate } from './types' -// Soft cap on a single builder's argument cache. Literal arguments keep these -// tiny and bounded; the cap only bites if a consumer feeds pathologically -// dynamic arguments in a loop, and clearing then just rebuilds on next use — -// never a correctness issue, only a lost cache hit. -const MAX_CACHE_ENTRIES = 10_000 - -// Make an argument injectively serialisable. JSON.stringify already does the -// hard part — it quotes and escapes strings (so `['a','b']` can't collide with -// `['a,b']`) and keeps the number 1 distinct from the string "1". We only need -// to special-case RegExp, which JSON would flatten to `{}` (it has no own -// enumerable props); tag it by source + flags so `/x/i` and `/x/g` differ. The -// recursion covers RegExps nested inside array/object args (e.g. a pattern in -// `matchRecord`'s options). -const normalise = (arg: unknown): unknown => { - if (arg instanceof RegExp) return { __re: arg.source, flags: arg.flags } - if (Array.isArray(arg)) return arg.map(normalise) - if (arg !== null && typeof arg === 'object') - return Object.fromEntries(Object.entries(arg).map(([k, v]) => [k, normalise(v)])) - return arg -} - -const keyOf = (args: readonly unknown[]): string => JSON.stringify(args.map(normalise)) - -/** - * Wrap a value-argument builder so equal arguments return the SAME predicate - * instance ("interning" / hash-consing). - * - * Why it matters: it lets a builder be written inline on a filter prop - * (`allowEdit={byKey('name')}`) without minting a fresh function every render. - * json-edit-react compares filter props by identity (the node `React.memo` - * boundary, and `useMemo(…, [allowEdit])` upstream), so a new identity each - * render would defeat fine-grained re-rendering tree-wide. A stable identity - * keeps the memo intact; a genuinely different argument still produces a - * different instance, so real changes still propagate. - * - * Each wrapped builder owns its own `Map`, keyed by an injective JSON - * serialisation of the arguments (see `keyOf`). The cache lives as long as the - * builder (module scope) and grows with the number of DISTINCT argument-sets — - * bounded for the usual literal args; `MAX_CACHE_ENTRIES` backstops the rest. - * - * Builders whose arguments are FUNCTIONS (the `and`/`or`/`not` combinators) - * can't be string-keyed and intern on reference identity via a `WeakMap` - * instead — see those builders. - */ -export const intern = ( - build: (...args: A) => FilterPredicate -): ((...args: A) => FilterPredicate) => { - const cache = new Map() - return (...args: A): FilterPredicate => { - const key = keyOf(args) - const cached = cache.get(key) - if (cached) return cached - const predicate = build(...args) - if (cache.size >= MAX_CACHE_ENTRIES) cache.clear() - cache.set(key, predicate) - return predicate - } -} - // --- Reference-keyed interning, for the combinators ------------------------- // // `and`/`or`/`not` take FUNCTION arguments (other predicates), which can't be @@ -68,6 +9,9 @@ export const intern = ( // stay distinct), and it's leak-free (an entry is GC'd once its key predicate // is unreferenced). Because the kit's own builders already intern, a combinator // over them — `and(byKey('a'), byPath('b'))` — is itself inline-stable. +// +// (The value-keyed `intern`, used by the value-argument builders, lives in the +// shared `_common/intern.ts`.) /** Memoise a unary combinator (`not`) on its single predicate's identity. */ export const internRef = ( diff --git a/packages/utils/src/filters/index.ts b/packages/utils/src/filters/index.ts index c33d7513..f34904bd 100644 --- a/packages/utils/src/filters/index.ts +++ b/packages/utils/src/filters/index.ts @@ -1,6 +1,7 @@ import { extract, matchNode, matchNodeKey, type JsonData } from 'json-edit-react' import type { FilterPredicate, NodeValueType, PathPattern, Range } from './types' -import { intern, internRef, internRefs } from './_intern' +import { intern } from '../_common/intern' +import { internRef, internRefs } from './_intern' import { compilePathMatcher } from './_glob' // Public types. `Range` is intentionally NOT re-exported (see types.ts). diff --git a/packages/utils/src/icon/iconFromSvg.ts b/packages/utils/src/icon/iconFromSvg.ts new file mode 100644 index 00000000..6d333e14 --- /dev/null +++ b/packages/utils/src/icon/iconFromSvg.ts @@ -0,0 +1,95 @@ +import { createElement, isValidElement, type ReactElement, type SVGProps } from 'react' +import type { IconDefinition } from 'json-edit-react' +import { intern } from '../_common/intern' + +// Root matcher. `\b` avoids matching ; the inner capture +// is greedy so it reaches the final ; case-insensitive for . Anything +// before the tag (, , comments) is ignored — we only locate the +// root tag, never the inner markup. +const SVG_TAG = /]*)>([\s\S]*)<\/svg>/i + +// name="value" | name='value', over the root tag's attribute string only. +const ATTR = /([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g + +// Root attributes core owns or that don't belong on the rendered : size +// (core sizes via `scale`, and would be overridden anyway), the xml namespaces +// (React adds them), and id/class/style (theming + identity are core's, and a +// raw `class`/`style` string isn't a valid React prop). `xmlns` is a prefix +// match so it also catches `xmlns:xlink`. +const DROPPED_ATTRS = ['width', 'height', 'id', 'class', 'style'] +const isDroppedAttr = (name: string) => DROPPED_ATTRS.includes(name) || name.startsWith('xmlns') + +// stroke-width → strokeWidth, fill-rule → fillRule, … so a lifted root attribute +// is a valid React SVG prop. The inner markup is NOT converted — it's injected +// raw, where kebab-case is correct. +const toCamel = (name: string) => name.replace(/-([a-z])/gi, (_, c: string) => c.toUpperCase()) + +// String → IconDefinition. Parses ONLY the root tag's attributes; the inner +// markup rides through verbatim via dangerouslySetInnerHTML — the browser parses +// it at mount, in the SVG namespace (the sits inside core's ), so +// nested/odd markup just works. dangerouslySetInnerHTML is benign here: the +// markup is author-supplied, not end-user input. +const parse = (raw: string): IconDefinition => { + const match = raw.trim().match(SVG_TAG) + const attrs = match ? match[1] : '' + const inner = match ? match[2] : raw.trim() + + let viewBox: string | undefined + const svgProps: Record = {} + for (const [, name, dq, sq] of attrs.matchAll(ATTR)) { + const value = dq ?? sq + if (name === 'viewBox') viewBox = value + else if (!isDroppedAttr(name)) svgProps[toCamel(name)] = value + } + + const def: IconDefinition = { + content: createElement('g', { dangerouslySetInnerHTML: { __html: inner } }), + } + if (viewBox !== undefined) def.viewBox = viewBox + if (Object.keys(svgProps).length > 0) def.svgProps = svgProps as SVGProps + return def +} + +// Props read straight off a React element — already camelCase, no +// parsing. `viewBox`/`children` are handled separately; the rest are +// size/namespace that core owns. +const ELEMENT_OWN_PROPS = ['viewBox', 'children', 'width', 'height', 'xmlns', 'xmlnsXlink'] + +// React element → IconDefinition. A element is unwrapped via its props +// (so core doesn't wrap a second around it — the nested- footgun); +// any other element (a , fragment, custom component) becomes the glyph +// content directly. +const fromElement = (el: ReactElement): IconDefinition => { + if (el.type !== 'svg') return { content: el } + const props = (el.props ?? {}) as Record + const svgProps: Record = {} + for (const [name, value] of Object.entries(props)) + if (!ELEMENT_OWN_PROPS.includes(name)) svgProps[name] = value + + const def: IconDefinition = { content: props.children as IconDefinition['content'] } + if (props.viewBox !== undefined) def.viewBox = String(props.viewBox) + if (Object.keys(svgProps).length > 0) def.svgProps = svgProps as SVGProps + return def +} + +const fromString = intern((svg: string): IconDefinition => parse(svg)) + +/** + * Build an `IconDefinition` (for `Theme.icons`) from raw SVG. Accepts: + * - a raw SVG string — a full `` or bare inner markup. **Interned**, + * so an inline `iconFromSvg('')` keeps a stable identity across + * renders. + * - a React `` element — unwrapped via its props/children. A non-`` + * element (a ``, fragment, custom component) becomes the glyph content + * directly. + * - an existing `IconDefinition` — returned unchanged (the single front door + * regardless of source). + * + * The element and object forms are NOT interned (an inline element is a fresh + * object each render): hoist or memoize them for render stability. + */ +export const iconFromSvg = (svg: string | ReactElement | IconDefinition): IconDefinition => { + if (typeof svg === 'string') return fromString(svg) + if (isValidElement(svg)) return fromElement(svg) + return svg +} diff --git a/packages/utils/src/icon/index.ts b/packages/utils/src/icon/index.ts new file mode 100644 index 00000000..5c0fd060 --- /dev/null +++ b/packages/utils/src/icon/index.ts @@ -0,0 +1 @@ +export * from './iconFromSvg' diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 870d025d..5589fbea 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -32,6 +32,10 @@ export * from './undo' export * from './stable-value' export * from './validation' +// Build an IconDefinition (for `Theme.icons`) from raw SVG markup or a React +// element. https://github.com/CarlosNZ/json-edit-react/issues/369 +export * from './icon' + // NOTE: the filter-function toolkit (`./filters`) is deliberately NOT re-exported // here. It ships under its own subpath — `@json-edit-react/utils/filters` — so its // generic builder names (`and`, `or`, `not`, `root`, `collections`, `primitives`, diff --git a/packages/utils/test/icon.test.tsx b/packages/utils/test/icon.test.tsx new file mode 100644 index 00000000..b8c715a2 --- /dev/null +++ b/packages/utils/test/icon.test.tsx @@ -0,0 +1,112 @@ +import { isValidElement, type ReactElement } from 'react' +import { iconFromSvg } from '../src/icon' +import type { IconDefinition } from 'json-edit-react' + +// Pull the dangerouslySetInnerHTML payload off a string-built glyph's content. +const innerHtml = (def: IconDefinition): string | undefined => + (def.content as ReactElement<{ dangerouslySetInnerHTML?: { __html: string } }>).props + .dangerouslySetInnerHTML?.__html + +// ─── A — string parsing (real-world fixtures) ──────────────────────────────── + +describe('iconFromSvg — string parsing', () => { + it('lifts viewBox + stroke svgProps from a full (Lucide-style), dropping size/xmlns', () => { + const def = iconFromSvg( + '' + ) + expect(def.viewBox).toBe('0 0 24 24') + expect(def.svgProps).toEqual({ + fill: 'none', + stroke: 'currentColor', + strokeWidth: '2', + strokeLinecap: 'round', + strokeLinejoin: 'round', + }) + expect(innerHtml(def)).toBe('') + }) + + it('parses a Boxicons-style fill (no extra root attrs → no svgProps)', () => { + const def = iconFromSvg('') + expect(def.viewBox).toBe('0 0 24 24') + expect(def.svgProps).toBeUndefined() + expect(innerHtml(def)).toBe('') + }) + + it('keeps a FontAwesome-style 512 viewBox', () => { + expect(iconFromSvg('').viewBox).toBe( + '0 0 512 512' + ) + }) + + it('ignores a leading /comment and accepts single-quoted attrs', () => { + const def = iconFromSvg( + "" + ) + expect(def.viewBox).toBe('0 0 24 24') + expect(def.svgProps).toEqual({ fill: 'red' }) + expect(innerHtml(def)).toBe("") + }) + + it('strips width/height so core controls the size', () => { + const def = iconFromSvg('') + expect(def.svgProps).toBeUndefined() + }) + + it('passes nested inner markup through verbatim', () => { + const def = iconFromSvg('') + expect(innerHtml(def)).toBe('') + }) + + it('treats bare inner markup (no wrapper) as content with defaults', () => { + const def = iconFromSvg('') + expect(def.viewBox).toBeUndefined() + expect(def.svgProps).toBeUndefined() + expect(innerHtml(def)).toBe('') + }) +}) + +// ─── B — React element input ───────────────────────────────────────────────── + +describe('iconFromSvg — element input', () => { + it('unwraps a React via props/children, dropping size (nested-svg prevention)', () => { + const def = iconFromSvg( + + + + ) + expect(def.viewBox).toBe('0 0 24 24') + expect(def.svgProps).toEqual({ fill: 'none', stroke: 'currentColor', strokeWidth: 2 }) + // content is the children, NOT the — so core won't double-wrap. + const content = def.content as ReactElement + expect(content.type).toBe('path') + }) + + it('routes a non- element to content directly', () => { + const path = + const def = iconFromSvg(path) + expect(def.content).toBe(path) + expect(def.viewBox).toBeUndefined() + expect(def.svgProps).toBeUndefined() + }) +}) + +// ─── C — passthrough & interning ───────────────────────────────────────────── + +describe('iconFromSvg — passthrough & interning', () => { + it('returns an IconDefinition unchanged', () => { + const def: IconDefinition = { content: , viewBox: '0 0 10 10' } + expect(iconFromSvg(def)).toBe(def) + expect(isValidElement(def)).toBe(false) // sanity: a definition isn't an element + }) + + it('interns string inputs — same string returns the same reference', () => { + const s = '' + expect(iconFromSvg(s)).toBe(iconFromSvg(s)) + }) + + it('returns distinct references for different strings', () => { + expect(iconFromSvg('')).not.toBe( + iconFromSvg('') + ) + }) +}) From 675831a98ae00481ef8443ce5d44175990fe75a3 Mon Sep 17 00:00:00 2001 From: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:32:30 +1200 Subject: [PATCH 07/10] Update README.md --- packages/utils/README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/utils/README.md b/packages/utils/README.md index b3324a7b..e27415d1 100644 --- a/packages/utils/README.md +++ b/packages/utils/README.md @@ -22,6 +22,7 @@ pnpm add @json-edit-react/utils - **Undo / redo** — wrap a consumer-owned `data`/`setData` pair with undo/redo (snapshot stacks plus `canUndo` / `canRedo`), zero-dep. _Available now._ - **Reactive validation** — `useValidationState` runs your validator over the whole document and exposes an O(1), identity-stable error index, so styles / filters / conditions reflect validity correctly even for cross-branch effects. Ships `validationStyles` (theme sugar), `ajvAdapter`, and the `useStableValue` primitive it's built on. Zero-dep (you bring your own validator). _Available now._ ([#357](https://github.com/CarlosNZ/json-edit-react/issues/357)) - **Filter-function toolkit** — composable predicate builders (`byKey`, `byPath`, `byLevel`, `byType`, …), `and` / `or` / `not` combinators, and search bridges for the `allow*` props and `searchFilter`. Zero-dep. _Available now._ ([#343](https://github.com/CarlosNZ/json-edit-react/issues/343)) +- **Icon definitions from SVG** — `iconFromSvg` turns raw SVG markup (or a React ``) into the `IconDefinition` a theme's `icons` expects, so a copied icon drops straight into a theme. Zero-dep. _Available now._ ([#369](https://github.com/CarlosNZ/json-edit-react/issues/369)) - **JSON Schema → Filter Functions** — generate `allowEdit` / `allowDelete` / `allowAdd` (etc.) functions from a JSON Schema so the editor UI can't produce invalid data in the first place. _Planned._ ([#285](https://github.com/CarlosNZ/json-edit-react/issues/285)) - **Search helpers** — ready-made `searchFilter` functions for common search patterns. _Planned._ ([#319](https://github.com/CarlosNZ/json-edit-react/issues/319)) @@ -182,6 +183,33 @@ import { and, byKey, byLevel, byType, matchRecord, not, primitives } from '@json See [src/filters/README.md](src/filters/README.md) for the full reference — every builder, the glob path syntax, and the composition / referential-stability rules. +## Icon definitions from SVG + +A theme can supply its own icon glyphs through `Theme.icons`, where each glyph is an `IconDefinition` — `content` (the inner SVG markup) plus an optional `viewBox` / `svgProps` / `scale`. `iconFromSvg` builds that shape for you so a copied icon drops straight in: it strips the outer `` tag, lifts `viewBox` and the presentation attributes (`fill`, `stroke`, `stroke-width`, …) into the right fields, and puts the inner markup in `content`. Core still renders the wrapping ``, so the glyph picks up the theme's icon colour (via `currentColor`) and standard sizing automatically. + +```tsx +import { JsonEditor } from 'json-edit-react' +import { iconFromSvg } from '@json-edit-react/utils' + +// Defined at module scope → built once, stable across renders. +const myTheme = { + icons: { + add: iconFromSvg(''), + }, + styles: { iconAdd: '#2aa198' }, +} + +const MyEditor = () => +``` + +It accepts three forms: + +- **A raw SVG string** — a full ``, or just the inner markup (``s) on their own. +- **A React `` element** — unwrapped via its props/children (natural in `.tsx`). Routing the element through `iconFromSvg` is the right way to use JSX here: putting a full `` directly in an `IconDefinition`'s `content` would nest it inside the one core renders. +- **An existing `IconDefinition`** — returned unchanged, so `iconFromSvg` is a single front door whatever the source. + +**Inline stability.** Pass a **string** for inline use — string inputs are interned, so `icons={{ add: iconFromSvg('') }}` keeps a stable reference across renders. A React **element** or a pre-built **`IconDefinition`** (and likewise a React node placed directly in `theme.icons`) is a fresh object every render and is **not** interned: define it outside the component or wrap it in `useMemo`, exactly as you would any inline `theme` value — otherwise it churns the editor's re-render memoization. A stable `theme` reference is the general rule; the string form of `iconFromSvg` is the one case that's safe to write inline. + ## License MIT From 4c1e71e2e337d366e8cbc19cdbab25c104577f06 Mon Sep 17 00:00:00 2001 From: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:42:35 +1200 Subject: [PATCH 08/10] Make React a peer dep of themes package --- packages/themes/CLAUDE.md | 16 +++++++++++++--- packages/themes/package.json | 10 +++++++++- packages/themes/rollup.config.mjs | 4 +++- pnpm-lock.yaml | 6 ++++++ 4 files changed, 31 insertions(+), 5 deletions(-) diff --git a/packages/themes/CLAUDE.md b/packages/themes/CLAUDE.md index dabd1848..8dd99883 100644 --- a/packages/themes/CLAUDE.md +++ b/packages/themes/CLAUDE.md @@ -4,7 +4,7 @@ Guidance for Claude Code when working in this sub-package. ## What this is -A small published package of theme objects for [`json-edit-react`](https://github.com/CarlosNZ/json-edit-react). Pure static data — each theme is a plain object describing styles and fragments. No runtime logic. +A small published package of theme objects for [`json-edit-react`](https://github.com/CarlosNZ/json-edit-react). Mostly static data — each theme is a plain object describing styles and fragments. A theme may also supply its own icon glyphs via `icons` (an `IconDefinition` map) whose `content` is SVG/JSX markup; that's the one place a theme carries anything beyond plain style data. See "Shipping theme icons" below. ## Public API @@ -18,10 +18,20 @@ Each is typed as `Theme` (imported from `json-edit-react`). ## Conventions -- **No runtime dependencies.** This package imports only the `Theme` type from `json-edit-react` (compile-time, erased at build). Don't add runtime deps. +- **Minimal dependencies.** Imports only the `Theme` type from `json-edit-react` (compile-time, erased at build). The one runtime touchpoint is `react/jsx-runtime`, emitted *only* by a theme that ships `icons` (JSX) — so **`react` is an _optional_ peer dep** (see `peerDependenciesMeta` in package.json), and the rollup `external` keeps `react`/`react/jsx-runtime` unbundled. Don't add any other runtime deps. - **`json-edit-react` is a peer dep** at `workspace:^` so pnpm resolves it to the local workspace during dev and to a real semver range at publish. - **`sideEffects: false`** in package.json so bundlers tree-shake unused themes. -- Theme objects are static. Don't add functions or hooks here — the core `Theme` type accepts function-returning style values, but those belong with the consumer, not in the published theme object. +- Keep themes declarative. A theme's `icons` glyphs (SVG/JSX markup) are fine — they're data, not logic. But don't add **style functions** or hooks: the core `Theme` type accepts function-returning style values, yet those belong with the consumer, not in a published theme object. + +## Shipping theme icons + +A theme may define `icons` (per-glyph `IconDefinition`s) so its look includes its own glyphs, not just icon colours. Because a glyph's `content` is JSX, the entry file must be `.tsx`: + +1. Rename [src/index.ts](src/index.ts) → `src/index.tsx`. +2. Point the build at it — rollup `input` and tsconfig `files` both → `src/index.tsx`. +3. Author the glyphs. `iconFromSvg` (from `@json-edit-react/utils`) turns raw SVG markup into the `IconDefinition` shape, or write the object directly (`content` is the inner markup, plus optional `viewBox`/`svgProps`/`scale`). + +The build is already wired for this: `jsx: react-jsx` in tsconfig, `react` + `react/jsx-runtime` kept external in rollup, and `react` declared as an optional peer dep. A theme with no `icons` stays a plain `.ts` file with no React touchpoint — only flip to `.tsx` when a theme actually ships glyphs. ## Build diff --git a/packages/themes/package.json b/packages/themes/package.json index 7b428414..43b34157 100644 --- a/packages/themes/package.json +++ b/packages/themes/package.json @@ -34,12 +34,20 @@ "preview-publish": "pnpm pack" }, "peerDependencies": { - "json-edit-react": "workspace:^" + "json-edit-react": "workspace:^", + "react": ">=18.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } }, "devDependencies": { "@rollup/plugin-typescript": "^11.1.6", "@rollup/plugin-terser": "^0.4.4", + "@types/react": "19.1.1", "json-edit-react": "workspace:*", + "react": "^19.1.0", "rollup": "^4.10.0", "rollup-plugin-bundle-size": "^1.0.3", "rollup-plugin-dts": "^6.1.0", diff --git a/packages/themes/rollup.config.mjs b/packages/themes/rollup.config.mjs index 7a53b3a6..2e1da5d2 100644 --- a/packages/themes/rollup.config.mjs +++ b/packages/themes/rollup.config.mjs @@ -11,7 +11,9 @@ export default [ { file: 'build/index.cjs.js', format: 'cjs' }, { file: 'build/index.esm.js', format: 'esm' }, ], - external: ['json-edit-react'], + // A theme that ships `icons` emits JSX → `react/jsx-runtime` imports; keep + // React (a peer dep) external so the runtime is never bundled. + external: (id) => id === 'json-edit-react' || id === 'react' || id.startsWith('react/'), plugins: [ typescript({ module: 'ESNext', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1929e7d4..393533f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -212,9 +212,15 @@ importers: '@rollup/plugin-typescript': specifier: ^11.1.6 version: 11.1.6(rollup@4.60.4)(tslib@2.8.1)(typescript@5.9.3) + '@types/react': + specifier: 19.1.1 + version: 19.1.1 json-edit-react: specifier: workspace:* version: link:../.. + react: + specifier: ^19.1.0 + version: 19.2.6 rollup: specifier: ^4.10.0 version: 4.60.4 From 74be50754c42709136e401078373c84ad0602aed Mon Sep 17 00:00:00 2001 From: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Thu, 18 Jun 2026 00:03:06 +1200 Subject: [PATCH 09/10] Docs --- .changeset/icon-from-svg.md | 5 ++ .changeset/theme-owned-icons.md | 5 ++ .changeset/themes-icon-ready.md | 5 ++ README.md | 66 +++++++++++++++------ migration-guide.md | 52 ++++++++++++++++ src/contexts/ThemeProvider/defaultTheme.tsx | 11 ++-- 6 files changed, 121 insertions(+), 23 deletions(-) create mode 100644 .changeset/icon-from-svg.md create mode 100644 .changeset/theme-owned-icons.md create mode 100644 .changeset/themes-icon-ready.md diff --git a/.changeset/icon-from-svg.md b/.changeset/icon-from-svg.md new file mode 100644 index 00000000..d2cc4209 --- /dev/null +++ b/.changeset/icon-from-svg.md @@ -0,0 +1,5 @@ +--- +'@json-edit-react/utils': minor +--- + +Add `iconFromSvg`: build a `Theme.icons` `IconDefinition` from raw SVG markup (a full `` string or bare inner markup), a React `` element (unwrapped via its props/children), or an existing `IconDefinition` (returned unchanged). String inputs are interned, so an inline `iconFromSvg('')` keeps a stable reference across renders. diff --git a/.changeset/theme-owned-icons.md b/.changeset/theme-owned-icons.md new file mode 100644 index 00000000..e7948d35 --- /dev/null +++ b/.changeset/theme-owned-icons.md @@ -0,0 +1,5 @@ +--- +'json-edit-react': major +--- + +Themes now own their icon glyphs. The standalone `icons` prop is removed; supply glyphs via `theme.icons` (keyed `add`/`edit`/`delete`/`copy`/`ok`/`cancel`/`collection`), where each value is an `IconDefinition` (`content` plus optional `viewBox`/`svgProps`/`scale`). User-supplied glyphs are themeable via `currentColor`, just like the built-ins. The expand/collapse key is renamed `chevron` → `collection`. The `IconAdd`…`IconChevron` components, `IconProps`, and `IconReplacements` are no longer exported (the built-in glyphs now live on `defaultTheme.icons`); `IconDefinition` and `ThemeIcons` are added. diff --git a/.changeset/themes-icon-ready.md b/.changeset/themes-icon-ready.md new file mode 100644 index 00000000..9916323a --- /dev/null +++ b/.changeset/themes-icon-ready.md @@ -0,0 +1,5 @@ +--- +'@json-edit-react/themes': minor +--- + +The package build now supports themes that ship their own icon glyphs (`theme.icons`): a glyph's JSX `content` compiles against `react/jsx-runtime`, which is kept external (never bundled). `react` is declared as an optional peer dependency — needed only by a theme that defines icons. diff --git a/README.md b/README.md index b0f1caff..658aeeac 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,6 @@ This is a reference list of *all* possible props, divided into related sections. | Prop | Type | Default | Description | | ----------------------- | --------------------------------------------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `theme` | `ThemeInput` | `defaultTheme` | Either one of the built-in themes (imported separately), or an object specifying some or all theme properties — see [Themes](#themes--styles). | -| `icons` | `{[iconName]: JSX.Element, ... }` | `{ }` | Replace the built-in icons by specifying them here — see [Themes](#themes--styles). | | | `showIconTooltips` | `boolean` | false | Display icon tooltips when hovering. | | | `indent` | `number` | `3` | Specify the amount of indentation for each level of nesting in the displayed data. | | `collapse` | `boolean\|number\|FilterFunction` | `false` | Defines which nodes of the JSON tree will be displayed "opened" in the UI on load — see [Collapse](#collapse). | @@ -891,6 +890,7 @@ However, you can pass in your own theme object, or part thereof. The theme struc ```js { displayName: 'Default', + // icons: { … }, // optional per-glyph IconDefinitions — see "Icons" below styles: { container: { backgroundColor: '#f6f6f6', @@ -950,7 +950,7 @@ So, to summarise, the `theme` prop can take *either*: - an imported theme, e.g `"candyWrapperTheme"` - a theme object: - - can be structured as above with `fragments`, `styles`, `displayName` etc., or just the `styles` part (at the root level) + - can be structured as above with `fragments`, `styles`, `displayName`, `icons` (glyphs — see [Icons](#icons)) etc., or just the `styles` part (at the root level) - a theme name *and* an override object in an array, i.e. `[ ", {...overrides } ]` You can play round with live editing of the themes in the [Demo app](https://carlosnz.github.io/json-edit-react/) by selecting "Edit this theme!" from the "Demo data" selector (though you won't be able to create functions in JSON). @@ -1000,21 +1000,52 @@ styles: { ### Icons -The default icons can be replaced, but you need to provide them as React/HTML elements. Just define any or all of them within the `icons` prop, keyed as: +A theme owns its icon **glyphs** as well as their colour. The glyph (which shape renders) lives on `theme.icons`; the colour lives on `theme.styles` under `iconAdd`…`iconCollection` (above). The two are independent — restyle the default glyph, swap the glyph, or both. -```js - icons={{ - add: - edit: - delete: - copy: - ok: - cancel: - chevron: -}} +`theme.icons` is keyed by icon name (`add`, `edit`, `delete`, `copy`, `ok`, `cancel`, and `collection` — the expand/collapse chevron), and each value is an `IconDefinition`. Supply only the glyphs you want to replace; the rest fall back to the defaults. + +```ts +interface IconDefinition { + content: React.ReactNode // the inner SVG markup — //… (no outer ) + viewBox?: string // defaults to '0 0 24 24' + svgProps?: React.SVGProps // extra attrs, e.g. a stroke icon: { fill: 'none', stroke: 'currentColor', strokeWidth: 2 } + scale?: number // per-glyph size tweak (default 1) +} +``` + +Core renders the wrapping `` itself, so you supply only what goes inside it: + +```tsx +const myTheme = { + icons: { + add: { content: }, + }, + styles: { iconAdd: '#2aa198' }, // colours the glyph +} ``` -The Icon components will need to have their own styles defined, as the theme styles *won't* be added to the custom elements. +**Colour follows `currentColor`.** Core applies the theme's icon colour to the ``, so any glyph path that uses `fill="currentColor"` (or sets no `fill`) adopts it. A path with its own explicit `fill` keeps that colour — so multi-colour glyphs (flags, brand logos) survive theming, as long as every coloured path carries its own `fill`. + +**Sizing.** Icons render a little larger than text by default; `scale` is a per-glyph multiplier on that baseline (e.g. `scale: 1.3` renders 30% bigger). Use it only to even out a glyph whose artwork over- or under-fills its viewBox — size lives in the glyph, never in `styles`. + +**Pasting raw SVG.** The `iconFromSvg` helper in [`@json-edit-react/utils`](#optional-companion-packages) turns a raw SVG string (or a React `` element) into an `IconDefinition`, so you can drop a copied icon straight in: + +```tsx +import { iconFromSvg } from '@json-edit-react/utils' + +const myTheme = { + icons: { add: iconFromSvg('') }, + styles: {}, +} +``` + +A **string** passed to `iconFromSvg` is interned, so it's stable to write inline. A React **element**, a pre-built **`IconDefinition`**, or a raw React node placed directly in `theme.icons` is a fresh object each render — define it outside the component or wrap it in `useMemo` (the same rule as any inline `theme` value) so it doesn't churn the editor's re-rendering. + +To replace an icon for a single editor instance, layer it onto the `theme` array — the same mechanism as style overrides: + +```tsx +theme={[githubDarkTheme, { icons: { add: iconFromSvg('') } }]} +``` ## Localisation @@ -1448,9 +1479,7 @@ A few helper functions, components and types that might be useful in your own im - `StringDisplay`: main component used to display a string value. Useful as a building block in custom components — handles truncation, "show more / show less" expansion, and the standard double-click-to-edit behaviour. - `StringEdit`: component used when editing a string value, can be useful for custom components -- `AutogrowTextArea`: the auto-resizing textarea primitive used by `StringEdit` and the built-in string editor -- `IconAdd`, `IconEdit`, `IconDelete`, `IconCopy`, `IconOk`, `IconCancel`, `IconChevron`: all the built-in [icon](#icons) components -- `matchNode`, `matchNodeKey`: helpers for defining custom [Search](#searchfiltering) functions +- `AutogrowTextArea`: the auto-resizing textarea primitive used by `StringEdit` and the built-in string editor- `matchNode`, `matchNodeKey`: helpers for defining custom [Search](#searchfiltering) functions - `extract`: function to extract a deeply nested object value from a string path. Originally published at [object-property-extractor](https://github.com/CarlosNZ/object-property-extractor) - `assign`: function to set a deep object value from a string path. Originally published at [object-property-assigner](https://github.com/CarlosNZ/object-property-assigner) - `isCollection`: simple utility that returns `true` if input is a "Collection" (i.e. an Object or Array) @@ -1470,7 +1499,8 @@ A few helper functions, components and types that might be useful in your own im - [`CustomNodeDefinition`](#custom-nodes), [`CustomTextDefinitions`](#custom-text), [`CustomTextFunction`](#custom-text), [`JsonEditorHandle`](#imperative-handle-editorref), [`JsonViewerHandle`](#imperative-handle-editorref), [`StartEditOptions`](#imperative-handle-editorref), [`StartEditResult`](#imperative-handle-editorref): input/output types of the respective props - `TranslateFunction`: function that takes a [localisation](#localisation) key and returns a translated string - `LocalisedString`: keys for the [`translations`](#localisation) object -- `IconReplacements`: input type for the `icons` prop +- `IconDefinition`: a themeable icon glyph (`content` plus optional `viewBox`/`svgProps`/`scale`) — see [Icons](#icons) +- `ThemeIcons`: the `theme.icons` map (icon name → `IconDefinition`) - `CollectionNodeProps`: all props passed internally to "collection" nodes (i.e. objects/arrays) - `ValueNodeProps`: all props passed internally to "value" nodes (i.e. *not* objects/arrays) - `CustomComponentProps`: all props passed internally to [Custom nodes](#custom-nodes); basically the same as `CollectionNodeProps` with an extra `componentProps` field for passing props unique to your component` diff --git a/migration-guide.md b/migration-guide.md index b6205ea0..98eccb02 100644 --- a/migration-guide.md +++ b/migration-guide.md @@ -23,6 +23,7 @@ If you only have a few minutes, these are the changes most likely to affect exis | `externalTriggers` prop replaced by an [imperative handle](https://react.dev/reference/react/useImperativeHandle) | Use a `useRef` and call `editorRef.current.collapse/startEdit/confirm/cancel` — see [the `editorRef` handle](#12-externaltriggers-prop-replaced-by-the-editorref-imperative-handle) | | Fine-grained re-rendering: object / array / function props must be referentially stable to benefit | Keep `customNodeDefinitions`, filter functions, `translations`, etc. stable (module scope or `useMemo`); callbacks are stabilised for you — see [stable props](#13-keep-object-and-function-props-referentially-stable) | | Misc public-export changes — new `AutogrowTextArea`; `toPathString` is now `/`-encoded; `ThemeStyles` is `Partial` | Mostly additive; act only if you parse `toPathString` output or typed against a total `ThemeStyles` — see [Misc changes to public exports](#14-misc-changes-to-public-exports) | +| `icons` prop removed; icon glyphs move into the theme | Move the config to `theme.icons` as `IconDefinition`s (or wrap with `iconFromSvg`); rename `chevron` → `collection` — see [`icons` prop removed](#15-icons-prop-removed-themes-own-their-glyphs) | --- @@ -714,6 +715,57 @@ The closing bracket of an expanded object/array now aligns with the key (the sta --- +## 15. `icons` prop removed (themes own their glyphs) + +The standalone `icons` prop is gone. Icon **glyphs** now live on the theme, under `theme.icons`, alongside their colours (`theme.styles.iconAdd`…). Each glyph is an `IconDefinition` (`{ content, viewBox?, svgProps?, scale? }`) rather than a bare `JSX.Element`, and the chevron key is renamed `chevron` → `collection`. See [Icons](README.md#icons) for the full shape, the `currentColor` theming rule, and `scale`. + +**Why:** icons are part of a theme's look, so a theme can now ship its own glyphs (not just their colours), and a per-instance override composes through the same `theme` layering as style overrides — one mechanism instead of two. + +### Migration + +Move the config from the `icons` prop into a theme layer, and rename `chevron` → `collection`. `content` is the **inner** SVG markup — core renders the wrapping `` — so drop the outer `` and keep its `viewBox` as a sibling field: + +```diff +- , +- chevron: , +- }} +- /> ++ , viewBox: '0 0 24 24' }, ++ collection: { content: , viewBox: '0 0 512 512' }, ++ }, ++ styles: {}, ++ }} ++ /> +``` + +Or skip the unwrapping — hand the original `` markup (a string, or a React `` element) to the `iconFromSvg` helper in [`@json-edit-react/utils`](README.md#icons), which strips the outer `` and lifts its `viewBox` / attributes for you: + +```tsx +import { iconFromSvg } from '@json-edit-react/utils' + +theme={{ icons: { add: iconFromSvg('') }, styles: {} }} +``` + +Keep the theme reference stable (module scope or `useMemo`), as with any `theme` value. + +### Removed icon exports + +Gone from `json-edit-react`: + +- The built-in icon **components** `IconAdd`, `IconEdit`, `IconDelete`, `IconCopy`, `IconOk`, `IconCancel`, `IconChevron` and their props type `IconProps`. The built-in glyphs now live on `defaultTheme.icons` (e.g. `defaultTheme.icons.add`) if you need to reference one. +- The `IconReplacements` type (the old `icons`-prop shape) — replaced by `IconDefinition` and `ThemeIcons`. + +--- + ## Need help? If you hit something this guide doesn't cover, please [open an issue](https://github.com/CarlosNZ/json-edit-react/issues) — happy to help triage and add to this doc. diff --git a/src/contexts/ThemeProvider/defaultTheme.tsx b/src/contexts/ThemeProvider/defaultTheme.tsx index 7d31ee87..b8482a6d 100644 --- a/src/contexts/ThemeProvider/defaultTheme.tsx +++ b/src/contexts/ThemeProvider/defaultTheme.tsx @@ -15,11 +15,12 @@ const strokeIconProps: SVGProps = { export const defaultTheme: Theme = { displayName: 'Default', // The seven built-in glyphs. Core renders the wrapping ; each definition - // supplies only the inner markup plus the attributes core can't infer. `scale` - // is a per-glyph size correction (multiplied onto core's ICON_TEXT_SIZE_RATIO) - // that compensates for source art under/over-filling its viewBox — the set is - // drawn by different icon families (Boxicons, Lucide, Feather, Typicons, - // FontAwesome), so at an identical em box they'd otherwise look uneven. + // supplies only the inner markup plus the attributes core can't infer. + // `scale` is a per-glyph size correction (multiplied onto core's + // ICON_TEXT_SIZE_RATIO) that compensates for source art under/over-filling + // its viewBox — the set is drawn by different icon families (Boxicons, + // Lucide, Feather, Typicons, FontAwesome), so at an identical em box they'd + // otherwise look uneven. icons: { // icon:bx-plus-circle | Boxicons https://boxicons.com/ | Atisa add: { From 622ffb95c80f2e34fcd8020504d28084751408e0 Mon Sep 17 00:00:00 2001 From: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Thu, 18 Jun 2026 00:46:00 +1200 Subject: [PATCH 10/10] Render icons correctly in the demo --- .changeset/theme-owned-icons.md | 2 +- README.md | 4 ++- demo/src/demoData/dataDefinitions.tsx | 33 +++++++++++++++++++++++++ src/Icons.tsx | 35 ++++++++++++++++----------- src/index.ts | 1 + 5 files changed, 59 insertions(+), 16 deletions(-) diff --git a/.changeset/theme-owned-icons.md b/.changeset/theme-owned-icons.md index e7948d35..5ded0fd8 100644 --- a/.changeset/theme-owned-icons.md +++ b/.changeset/theme-owned-icons.md @@ -2,4 +2,4 @@ 'json-edit-react': major --- -Themes now own their icon glyphs. The standalone `icons` prop is removed; supply glyphs via `theme.icons` (keyed `add`/`edit`/`delete`/`copy`/`ok`/`cancel`/`collection`), where each value is an `IconDefinition` (`content` plus optional `viewBox`/`svgProps`/`scale`). User-supplied glyphs are themeable via `currentColor`, just like the built-ins. The expand/collapse key is renamed `chevron` → `collection`. The `IconAdd`…`IconChevron` components, `IconProps`, and `IconReplacements` are no longer exported (the built-in glyphs now live on `defaultTheme.icons`); `IconDefinition` and `ThemeIcons` are added. +Themes now own their icon glyphs. The standalone `icons` prop is removed; supply glyphs via `theme.icons` (keyed `add`/`edit`/`delete`/`copy`/`ok`/`cancel`/`collection`), where each value is an `IconDefinition` (`content` plus optional `viewBox`/`svgProps`/`scale`). User-supplied glyphs are themeable via `currentColor`, just like the built-ins. The expand/collapse key is renamed `chevron` → `collection`. The `IconAdd`…`IconChevron` components, `IconProps`, and `IconReplacements` are no longer exported (the built-in glyphs now live on `defaultTheme.icons`); `IconDefinition`, `ThemeIcons`, and `IconSvg` (the glyph renderer — pass an `IconDefinition`'s parts) are added. diff --git a/README.md b/README.md index 658aeeac..e07e8067 100644 --- a/README.md +++ b/README.md @@ -1479,7 +1479,9 @@ A few helper functions, components and types that might be useful in your own im - `StringDisplay`: main component used to display a string value. Useful as a building block in custom components — handles truncation, "show more / show less" expansion, and the standard double-click-to-edit behaviour. - `StringEdit`: component used when editing a string value, can be useful for custom components -- `AutogrowTextArea`: the auto-resizing textarea primitive used by `StringEdit` and the built-in string editor- `matchNode`, `matchNodeKey`: helpers for defining custom [Search](#searchfiltering) functions +- `AutogrowTextArea`: the auto-resizing textarea primitive used by `StringEdit` and the built-in string editor +- `IconSvg`: renders an `IconDefinition`'s parts (`scale`, `viewBox`, inner markup as `children`, plus its `svgProps`) as an `` — the same renderer the editor uses for theme [icons](#icons); handy for previewing a glyph outside the editor +- `matchNode`, `matchNodeKey`: helpers for defining custom [Search](#searchfiltering) functions - `extract`: function to extract a deeply nested object value from a string path. Originally published at [object-property-extractor](https://github.com/CarlosNZ/object-property-extractor) - `assign`: function to set a deep object value from a string path. Originally published at [object-property-assigner](https://github.com/CarlosNZ/object-property-assigner) - `isCollection`: simple utility that returns `true` if input is a "Collection" (i.e. an Object or Array) diff --git a/demo/src/demoData/dataDefinitions.tsx b/demo/src/demoData/dataDefinitions.tsx index 69a18574..90360386 100644 --- a/demo/src/demoData/dataDefinitions.tsx +++ b/demo/src/demoData/dataDefinitions.tsx @@ -18,6 +18,8 @@ import { import { ReactDatePicker } from '@json-edit-react/components/widgets' import { CustomNodeDefinition, + IconDefinition, + IconSvg, JsonData, FilterFunction, CustomTextDefinitions, @@ -633,6 +635,37 @@ export const demoDataDefinitions: Record = { searchFilter: 'key', searchPlaceholder: 'Search Theme keys', data: {}, + // Render each theme icon glyph (an `IconDefinition`) as the actual icon via + // core's `IconSvg`, rather than the raw React-element internals. Display-only. + customNodeDefinitions: [ + { + condition: ({ value }) => + !!value && + typeof value === 'object' && + React.isValidElement((value as { content?: unknown }).content), + renderCollectionAsValue: true, + showEditTools: false, + component: ({ value, nodeData, getStyles }) => { + const { content, viewBox, svgProps, scale } = value as IconDefinition + // Derive the paint key (icon + PascalCase) the same way core does, so + // the preview adopts the theme's icon colour via currentColor. + const key = String(nodeData.key) + const paintKey = `icon${key[0].toUpperCase()}${key.slice(1)}` as Parameters< + typeof getStyles + >[0] + return ( + + {content} + + ) + }, + }, + ], customTextEditorAvailable: true, }, customNodes: { diff --git a/src/Icons.tsx b/src/Icons.tsx index 79fa531b..d4f9c90f 100644 --- a/src/Icons.tsx +++ b/src/Icons.tsx @@ -8,22 +8,29 @@ import { type NodeData, type ThemeableElement, type ThemeIcons } from './types' // via `IconDefinition.scale`. const ICON_TEXT_SIZE_RATIO = 1.4 -// Shared wrapper for every icon — it IS the `IconDefinition` renderer. -// `size` maps to width/height, and the most common attributes (24×24 viewBox, -// fill="currentColor") are defaulted here so a glyph only declares what -// differs. Any other SVG attribute (stroke, transform, baseProfile, …) passes -// through from the definition's `svgProps`. -const IconSvg = ({ - size, +// The renderer for an `IconDefinition`: pass its parts — `scale`, `viewBox`, +// the inner markup as `children`, and any extra `` attributes (a stroke +// icon's `{ fill: 'none', stroke: 'currentColor' }`, a transform, …) by +// spreading its `svgProps`. `scale` is multiplied onto the icon:text size +// baseline (`ICON_TEXT_SIZE_RATIO`) to set the rendered em size; the common +// attributes (24×24 viewBox, fill="currentColor") are defaulted so a glyph only +// declares what differs. Colour flows in via `currentColor`. +export const IconSvg = ({ + scale = 1, viewBox = '0 0 24 24', fill = 'currentColor', children, ...props -}: { size: string } & SVGProps): JSX.Element => ( - - {children} - -) + // `scale` is our size multiplier — omit SVG's own (rarely-used) `scale` + // attribute so spreading a definition's `svgProps` can't shadow it. +}: { scale?: number } & Omit, 'scale'>): JSX.Element => { + const size = `${ICON_TEXT_SIZE_RATIO * scale}em` + return ( + + {children} + + ) +} // Renders a themed icon by name. The glyph comes from the merged theme `icons` // (always complete — `defaultTheme` is layer 0), and its paint key is derived @@ -41,9 +48,9 @@ export const Icon = ({ const styleKey = `icon${name[0].toUpperCase()}${name.slice(1)}` as ThemeableElement return (