diff --git a/src/components/Pega_Extensions_JsonEditor/Docs.mdx b/src/components/Pega_Extensions_JsonEditor/Docs.mdx
new file mode 100644
index 00000000..7741103b
--- /dev/null
+++ b/src/components/Pega_Extensions_JsonEditor/Docs.mdx
@@ -0,0 +1,16 @@
+import { Meta, Story } from '@storybook/addon-docs/blocks';
+import * as Stories from './demo.stories';
+
+
+
+# JSON editor
+
+`Pega_Extensions_JsonEditor` provides a multiline JSON field with syntax highlighting, validation, formatting, minification, copying, and clearing actions.
+
+## Example
+
+
+
+## Usage
+
+Use this component for editable JSON object or array values. The component updates the bound field through `getPConnect().getActionsApi()` and supports display-only and labels-left display modes.
diff --git a/src/components/Pega_Extensions_JsonEditor/config.json b/src/components/Pega_Extensions_JsonEditor/config.json
new file mode 100644
index 00000000..f60af8fd
--- /dev/null
+++ b/src/components/Pega_Extensions_JsonEditor/config.json
@@ -0,0 +1,175 @@
+{
+ "name": "Pega_Extensions_JsonEditor",
+ "label": "JSON editor",
+ "description": "A multiline editor for JSON values with validation and formatting.",
+ "organization": "Pega",
+ "version": "5.0.4",
+ "library": "Extensions",
+ "allowedApplications": [],
+ "componentKey": "Pega_Extensions_JsonEditor",
+ "type": "Field",
+ "subtype": "Text-Paragraph",
+ "subtypeLabel": "Paragraph",
+ "icon": "images/pz-text-area-active.svg",
+ "properties": [
+ {
+ "name": "labelOption",
+ "label": "Label value",
+ "format": "SELECT",
+ "defaultValue": "custom",
+ "source": [
+ {
+ "key": "default",
+ "value": "Default"
+ },
+ {
+ "key": "custom",
+ "value": "Custom"
+ }
+ ]
+ },
+ {
+ "name": "label",
+ "label": "Custom label value",
+ "format": "VALUEINPUT",
+ "required": true,
+ "ruleType": "Paragraph"
+ },
+ {
+ "name": "readOnly",
+ "label": "Edit mode",
+ "format": "READONLY"
+ },
+ {
+ "label": "Input settings",
+ "format": "GROUP",
+ "visibility": "(!readOnly = true)",
+ "properties": [
+ {
+ "name": "placeholder",
+ "label": "Placeholder",
+ "format": "TEXT",
+ "defaultValue": "{\n \"title\": \"Constellation\",\n \"count\": 3,\n \"enabled\": true,\n \"notes\": null,\n \"tags\": [\"json\", \"editor\"],\n \"settings\": {\n \"theme\": \"light\"\n }\n}"
+ },
+ {
+ "name": "helperText",
+ "label": "Helper text",
+ "format": "TEXT",
+ "defaultValue": "Supports objects, arrays, strings, numbers, booleans, and null."
+ }
+ ]
+ },
+ {
+ "label": "Conditions",
+ "format": "GROUP",
+ "properties": [
+ {
+ "name": "required",
+ "label": "Required",
+ "format": "REQUIRED",
+ "visibility": "(!readOnly = true)"
+ },
+ {
+ "name": "disabled",
+ "label": "Disabled",
+ "format": "DISABLED",
+ "visibility": "(!readOnly = true)"
+ },
+ {
+ "name": "visibility",
+ "label": "Visibility",
+ "format": "VISIBILITY"
+ }
+ ]
+ },
+ {
+ "name": "indent",
+ "label": "Indent spaces",
+ "format": "TEXT"
+ },
+ {
+ "label": "Editor actions",
+ "format": "GROUP",
+ "collapsible": true,
+ "properties": [
+ {
+ "name": "showValidate",
+ "label": "Show Validate action",
+ "format": "BOOLEAN"
+ },
+ {
+ "name": "showFormat",
+ "label": "Show Format action",
+ "format": "BOOLEAN"
+ },
+ {
+ "name": "showMinify",
+ "label": "Show Minify action",
+ "format": "BOOLEAN"
+ },
+ {
+ "name": "showCopy",
+ "label": "Show Copy action",
+ "format": "BOOLEAN"
+ },
+ {
+ "name": "showClear",
+ "label": "Show Clear action",
+ "format": "BOOLEAN"
+ },
+ {
+ "name": "showCharacterCount",
+ "label": "Show character count",
+ "format": "BOOLEAN"
+ },
+ {
+ "name": "showLineCount",
+ "label": "Show line count",
+ "format": "BOOLEAN"
+ }
+ ]
+ },
+ {
+ "label": "Advanced",
+ "format": "GROUP",
+ "collapsible": true,
+ "properties": [
+ {
+ "name": "testId",
+ "label": "Test ID",
+ "format": "TEXT",
+ "ignorePattern": "[^-_\\p{N}\\p{L}]",
+ "includeAnnotations": false
+ }
+ ]
+ },
+ {
+ "format": "LABEL",
+ "label": "About",
+ "name": "PegaAboutLabel",
+ "variant": "h3"
+ },
+ {
+ "format": "LABEL",
+ "label": "Pega_Extensions 5.0.4",
+ "name": "PegaAboutData",
+ "variant": "primary"
+ }
+ ],
+ "defaultConfig": {
+ "label": "@L $this.label",
+ "indent": "2",
+ "showValidate": true,
+ "showFormat": true,
+ "showMinify": true,
+ "showCopy": true,
+ "showClear": true,
+ "showCharacterCount": true,
+ "showLineCount": true,
+ "detailFVLItem": true,
+ "isFormWidth": false,
+ "isContainerWidth": false
+ },
+ "infinityVersion": "26.1.1",
+ "packageCosmosVersion": "9.25.0"
+}
diff --git a/src/components/Pega_Extensions_JsonEditor/demo.stories.tsx b/src/components/Pega_Extensions_JsonEditor/demo.stories.tsx
new file mode 100644
index 00000000..3bff141b
--- /dev/null
+++ b/src/components/Pega_Extensions_JsonEditor/demo.stories.tsx
@@ -0,0 +1,59 @@
+import { useState } from 'react';
+import type { Meta, StoryFn } from '@storybook/react-webpack5';
+
+import { configProps, stateProps } from './mock';
+import { PegaExtensionsJsonEditor } from './index';
+
+const meta: Meta = {
+ title: 'Fields/JSON editor',
+ component: PegaExtensionsJsonEditor,
+ excludeStories: /.*Data$/,
+ argTypes: {
+ getPConnect: {
+ table: {
+ disable: true,
+ },
+ },
+ displayMode: {
+ options: ['', 'DISPLAY_ONLY', 'LABELS_LEFT'],
+ control: {
+ type: 'select',
+ },
+ },
+ },
+};
+
+export default meta;
+
+export const BaseJsonEditor: StoryFn = (args) => {
+ const [value, setValue] = useState(configProps.value);
+
+ const props = {
+ value,
+ getPConnect: () =>
+ ({
+ getStateProps: () => stateProps,
+ getActionsApi: () => ({
+ updateFieldValue: (_propName: string, nextValue: string) => setValue(nextValue),
+ }),
+ getLocalizedValue: (text: string) => text,
+ }) as unknown as typeof PConnect,
+ };
+
+ return ;
+};
+
+BaseJsonEditor.args = {
+ label: configProps.label,
+ value: configProps.value,
+ helperText: configProps.helperText,
+ placeholder: configProps.placeholder,
+ testId: configProps.testId,
+ readOnly: configProps.readOnly,
+ disabled: configProps.disabled,
+ required: configProps.required,
+ status: configProps.status,
+ hideLabel: configProps.hideLabel,
+ displayMode: configProps.displayMode,
+ validatemessage: configProps.validatemessage,
+};
diff --git a/src/components/Pega_Extensions_JsonEditor/demo.test.tsx b/src/components/Pega_Extensions_JsonEditor/demo.test.tsx
new file mode 100644
index 00000000..4d974b67
--- /dev/null
+++ b/src/components/Pega_Extensions_JsonEditor/demo.test.tsx
@@ -0,0 +1,118 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { composeStories } from '@storybook/react-webpack5';
+import '@testing-library/jest-dom';
+
+import * as DemoStories from './demo.stories';
+import { configProps } from './mock';
+
+const { BaseJsonEditor } = composeStories(DemoStories);
+
+test('renders JsonEditor', async () => {
+ render();
+
+ expect(await screen.findByRole('textbox', { name: 'JSON value' })).toHaveValue(configProps.value);
+ expect(screen.getByRole('button', { name: 'Format JSON' })).toBeVisible();
+ expect(screen.getByRole('button', { name: 'Validate JSON' })).toContainElement(
+ screen.getByTestId('JsonEditor-12345678:action:validate').querySelector('[data-icon-name="check"]'),
+ );
+ expect(screen.getByTestId('JsonEditor-12345678:action:format')).toContainElement(
+ screen.getByTestId('JsonEditor-12345678:action:format').querySelector('[data-icon-name="align-left"]'),
+ );
+ expect(screen.getByTestId('JsonEditor-12345678:action:minify')).toContainElement(
+ screen.getByTestId('JsonEditor-12345678:action:minify').querySelector('[data-icon-name="code"]'),
+ );
+ expect(screen.getByTestId('JsonEditor-12345678:action:copy')).toContainElement(
+ screen.getByTestId('JsonEditor-12345678:action:copy').querySelector('[data-icon-name="copy"]'),
+ );
+ expect(screen.getByTestId('JsonEditor-12345678:action:clear')).toContainElement(
+ screen.getByTestId('JsonEditor-12345678:action:clear').querySelector('[data-icon-name="trash"]'),
+ );
+});
+
+test('shows a placeholder covering all JSON value types', async () => {
+ render();
+
+ expect(await screen.findByRole('textbox', { name: 'JSON value' })).toHaveAttribute(
+ 'placeholder',
+ configProps.placeholder,
+ );
+});
+
+test('formats valid JSON', async () => {
+ render();
+
+ const editor = await screen.findByRole('textbox', { name: 'JSON value' });
+ fireEvent.change(editor, { target: { value: '{"name":"Pega"}' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Format JSON' }));
+
+ expect(editor).toHaveValue('{\n "name": "Pega"\n}');
+});
+
+test('honors authoring visibility settings for actions and counters', async () => {
+ render();
+
+ expect(screen.queryByRole('button', { name: 'Validate JSON' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Copy JSON' })).not.toBeInTheDocument();
+ expect(screen.queryByText(/characters|lines/)).not.toBeInTheDocument();
+});
+
+test('renders syntax colors and line numbers', async () => {
+ render();
+
+ const editor = await screen.findByRole('textbox', { name: 'JSON value' });
+ fireEvent.change(editor, {
+ target: { value: '{\n "name": "Pega",\n "enabled": true\n}' },
+ });
+
+ expect(screen.getByTestId('JsonEditor-12345678:line-numbers').querySelectorAll('span')).toHaveLength(4);
+ expect(screen.getByTestId('JsonEditor-12345678:highlight').querySelectorAll('.json-key')).toHaveLength(2);
+ expect(screen.getByTestId('JsonEditor-12345678:highlight').querySelector('.json-literal')).toHaveTextContent('true');
+});
+
+test('reports invalid JSON and disables JSON transforms', async () => {
+ render();
+
+ const editor = await screen.findByRole('textbox', { name: 'JSON value' });
+ fireEvent.change(editor, { target: { value: '{invalid' } });
+
+ expect(screen.getByTestId('JsonEditor-12345678:status')).toHaveTextContent('Invalid JSON');
+ expect(screen.getByRole('button', { name: 'Format JSON' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Minify JSON' })).toBeDisabled();
+});
+
+test('reports the invalid JSON line and column', async () => {
+ render();
+
+ const editor = await screen.findByRole('textbox', { name: 'JSON value' });
+ fireEvent.change(editor, { target: { value: '{\n "name": "Pega",\n}' } });
+
+ expect(screen.getByTestId('JsonEditor-12345678:error')).toHaveTextContent('Line 3');
+ expect(screen.getByTestId('JsonEditor-12345678:error')).toHaveTextContent('column');
+});
+
+test('minifies and clears JSON', async () => {
+ render();
+
+ const editor = await screen.findByRole('textbox', { name: 'JSON value' });
+ fireEvent.change(editor, { target: { value: '{\n "name": "Pega"\n}' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Minify JSON' }));
+ expect(editor).toHaveValue('{"name":"Pega"}');
+
+ fireEvent.click(screen.getByRole('button', { name: 'Clear JSON' }));
+ expect(editor).toHaveValue('');
+ expect(screen.queryByTestId('JsonEditor-12345678:status')).not.toBeInTheDocument();
+});
+
+test('copies JSON and reports copy success', async () => {
+ const writeText = jest.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: { writeText },
+ });
+
+ render();
+ fireEvent.click(screen.getByRole('button', { name: 'Copy JSON' }));
+
+ expect(await screen.findByRole('button', { name: 'Copied' })).toBeVisible();
+ expect(writeText).toHaveBeenCalledWith(configProps.value);
+});
diff --git a/src/components/Pega_Extensions_JsonEditor/index.tsx b/src/components/Pega_Extensions_JsonEditor/index.tsx
new file mode 100644
index 00000000..618f748a
--- /dev/null
+++ b/src/components/Pega_Extensions_JsonEditor/index.tsx
@@ -0,0 +1,350 @@
+import { useEffect, useMemo, useRef, useState } from 'react';
+import type { ChangeEvent, KeyboardEvent, UIEvent } from 'react';
+import { FieldValueList, Icon, registerIcon, Text, TextArea, withConfiguration } from '@pega/cosmos-react-core';
+import * as alignLeftIcon from '@pega/cosmos-react-core/lib/components/Icon/icons/align-left.icon';
+import * as checkIcon from '@pega/cosmos-react-core/lib/components/Icon/icons/check.icon';
+import * as clipboardCheckIcon from '@pega/cosmos-react-core/lib/components/Icon/icons/clipboard-check.icon';
+import * as codeIcon from '@pega/cosmos-react-core/lib/components/Icon/icons/code.icon';
+import * as copyIcon from '@pega/cosmos-react-core/lib/components/Icon/icons/copy.icon';
+import * as informationIcon from '@pega/cosmos-react-core/lib/components/Icon/icons/information.icon';
+import * as trashIcon from '@pega/cosmos-react-core/lib/components/Icon/icons/trash.icon';
+
+import '../shared/create-nonce';
+
+import StyledJsonEditorWrapper, {
+ StyledJsonEditorCopyError,
+ StyledJsonEditorCode,
+ StyledJsonEditorButton,
+ StyledJsonEditorError,
+ StyledJsonEditorFooter,
+ StyledJsonEditorHighlight,
+ StyledJsonEditorLineNumbers,
+ StyledJsonEditorMeta,
+ StyledJsonEditorSurface,
+ StyledJsonEditorStatus,
+ StyledJsonEditorToolbar,
+} from './styles';
+import {
+ copyText,
+ getLineCount,
+ getValidationLabel,
+ getValidationStatus,
+ highlightJson,
+ type CopyState,
+ validateJson,
+} from './utils';
+
+registerIcon(alignLeftIcon, checkIcon, clipboardCheckIcon, codeIcon, copyIcon, informationIcon, trashIcon);
+
+export interface PegaExtensionsJsonEditorProps {
+ getPConnect: () => typeof PConnect;
+ label: string;
+ value?: string;
+ placeholder?: string;
+ disabled?: boolean;
+ displayMode?: '' | 'DISPLAY_ONLY' | 'LABELS_LEFT';
+ readOnly?: boolean;
+ required?: boolean;
+ hideLabel?: boolean;
+ testId?: string;
+ helperText?: string;
+ validatemessage?: string;
+ status?: 'error' | 'success' | 'warning' | 'pending';
+ indent?: number | string;
+ showValidate?: boolean;
+ showFormat?: boolean;
+ showMinify?: boolean;
+ showCopy?: boolean;
+ showClear?: boolean;
+ showCharacterCount?: boolean;
+ showLineCount?: boolean;
+}
+
+interface StateProps {
+ value: string;
+}
+
+export function PegaExtensionsJsonEditor(props: PegaExtensionsJsonEditorProps) {
+ const {
+ getPConnect,
+ value = '',
+ placeholder,
+ disabled: disabledProp = false,
+ displayMode,
+ readOnly: readOnlyProp = false,
+ required: requiredProp = false,
+ label,
+ hideLabel = false,
+ testId,
+ helperText,
+ validatemessage,
+ status,
+ indent = 2,
+ showValidate = true,
+ showFormat = true,
+ showMinify = true,
+ showCopy = true,
+ showClear = true,
+ showCharacterCount = true,
+ showLineCount = true,
+ } = props;
+
+ const pConn = getPConnect();
+ const localize = (text: string) => pConn.getLocalizedValue?.(text) || text;
+ const actions = pConn.getActionsApi();
+ const stateProps = pConn.getStateProps() as StateProps;
+ const propName = stateProps.value;
+ let readOnly = readOnlyProp;
+ let required = requiredProp;
+ let disabled = disabledProp;
+ [readOnly, required, disabled] = [readOnly, required, disabled].map(
+ (prop) => prop === true || (typeof prop === 'string' && prop === 'true'),
+ );
+ const [draft, setDraft] = useState(value);
+ const [validation, setValidation] = useState(() => validateJson(value, indent, required));
+ const [copyState, setCopyState] = useState('idle');
+ const [scrollOffset, setScrollOffset] = useState({ top: 0, left: 0 });
+ const copyStateTimeoutRef = useRef>();
+ const lineCount = useMemo(() => getLineCount(draft), [draft]);
+ const highlightedDraft = useMemo(() => highlightJson(draft), [draft]);
+ const lineNumbers = useMemo(
+ () => Array.from({ length: Math.max(1, lineCount) }, (_, index) => index + 1),
+ [lineCount],
+ );
+
+ const updateValue = (nextValue: string) => {
+ setDraft(nextValue);
+ setValidation(validateJson(nextValue, indent, required));
+ setCopyState('idle');
+ actions.updateFieldValue(propName, nextValue);
+ };
+
+ useEffect(() => {
+ setDraft(value);
+ setValidation(validateJson(value, indent, required));
+ setCopyState('idle');
+ }, [indent, required, value]);
+
+ useEffect(() => {
+ return () => {
+ if (copyStateTimeoutRef.current) {
+ clearTimeout(copyStateTimeoutRef.current);
+ }
+ };
+ }, []);
+
+ const handleOnChange = (event: ChangeEvent) => {
+ updateValue(event.target.value);
+ };
+
+ const handleFormat = () => {
+ if (validation.isValid && !validation.isEmpty) {
+ updateValue(validation.formattedValue);
+ }
+ };
+
+ const handleValidate = () => {
+ setValidation(validateJson(draft, indent, required));
+ };
+
+ const handleMinify = () => {
+ if (validation.isValid && !validation.isEmpty) {
+ updateValue(validation.minifiedValue);
+ }
+ };
+
+ const handleCopy = async () => {
+ try {
+ await copyText(draft);
+ setCopyState('copied');
+ if (copyStateTimeoutRef.current) {
+ clearTimeout(copyStateTimeoutRef.current);
+ }
+ copyStateTimeoutRef.current = setTimeout(() => setCopyState('idle'), 2000);
+ } catch {
+ setCopyState('failed');
+ }
+ };
+
+ const handleClear = () => {
+ updateValue('');
+ };
+
+ const handleEditorKeyDown = (event: KeyboardEvent) => {
+ if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
+ event.preventDefault();
+ handleValidate();
+ }
+ };
+
+ const handleEditorScroll = (event: UIEvent) => {
+ setScrollOffset({
+ top: event.currentTarget.scrollTop,
+ left: event.currentTarget.scrollLeft,
+ });
+ };
+
+ const errorMessage = validation.message ? localize(validation.message) : validatemessage;
+ const errorLocation =
+ validation.errorLine && validation.errorColumn
+ ? `${localize('Line')} ${validation.errorLine}, ${localize('column')} ${validation.errorColumn}`
+ : undefined;
+ const fieldStatus = !validation.isValid ? 'error' : status;
+ const editorTestId = testId ? `${testId}:editor` : undefined;
+ const statusTestId = testId ? `${testId}:status` : undefined;
+ const highlightTestId = testId ? `${testId}:highlight` : undefined;
+ const lineNumbersTestId = testId ? `${testId}:line-numbers` : undefined;
+ const actionTestId = (action: string) => (testId ? `${testId}:action:${action}` : undefined);
+ const hasToolbarActions = showValidate || showFormat || showMinify || showCopy || showClear;
+
+ if (displayMode === 'LABELS_LEFT' || displayMode === 'DISPLAY_ONLY') {
+ let displayValue = draft || ––;
+ if (validation.isValid && !validation.isEmpty) {
+ displayValue = validation.formattedValue;
+ }
+
+ return displayMode === 'DISPLAY_ONLY' ? (
+
+ {displayValue}
+
+ ) : (
+
+
+
+ );
+ }
+
+ return (
+
+ {hasToolbarActions && (
+
+ {showValidate && (
+
+
+
+ )}
+ {showFormat && (
+
+
+
+ )}
+ {showMinify && (
+
+
+
+ )}
+ {showCopy && (
+
+
+
+ )}
+ {showClear && (
+
+
+
+ )}
+
+ )}
+
+
+
+ {lineNumbers.map((lineNumber) => (
+ {lineNumber}
+ ))}
+
+
+ {highlightedDraft.map(({ className, key, text }) =>
+ className ? (
+
+ {text}
+
+ ) : (
+ text
+ ),
+ )}
+
+
+
+
+
+ {(!validation.isEmpty || !validation.isValid) && (
+
+
+ {localize(getValidationLabel(validation))}
+
+ )}
+
+ {showCharacterCount && `${draft.length} characters`}
+ {showCharacterCount && showLineCount && ' ยท '}
+ {showLineCount && `${lineCount} lines`}
+
+ {!validation.isValid && errorLocation && (
+
+ {errorLocation}
+
+ )}
+ {copyState === 'failed' && {localize('Copy failed.')}}
+
+
+ );
+}
+
+export default withConfiguration(PegaExtensionsJsonEditor);
diff --git a/src/components/Pega_Extensions_JsonEditor/mock.ts b/src/components/Pega_Extensions_JsonEditor/mock.ts
new file mode 100644
index 00000000..e7c3faa2
--- /dev/null
+++ b/src/components/Pega_Extensions_JsonEditor/mock.ts
@@ -0,0 +1,48 @@
+export const configProps = {
+ value: `{
+ "caseId": "C-10042",
+ "status": "In progress",
+ "priority": 2,
+ "isEscalated": false,
+ "owner": null,
+ "customer": {
+ "name": "Avery Morgan",
+ "email": "avery.morgan@example.com"
+ },
+ "tags": ["billing", "priority"],
+ "milestones": [
+ {
+ "name": "Review",
+ "complete": true
+ },
+ {
+ "name": "Resolution",
+ "complete": false
+ }
+ ]
+}`,
+ label: 'JSON value',
+ placeholder: `{
+ "title": "Constellation",
+ "count": 3,
+ "enabled": true,
+ "notes": null,
+ "tags": ["json", "editor"],
+ "settings": {
+ "theme": "light"
+ }
+}`,
+ helperText: 'Supports objects, arrays, strings, numbers, booleans, and null.',
+ testId: 'JsonEditor-12345678',
+ displayMode: '' as const,
+ hideLabel: false,
+ readOnly: false,
+ required: false,
+ disabled: false,
+ status: undefined,
+ validatemessage: '',
+};
+
+export const stateProps = {
+ value: '.JsonValue',
+};
diff --git a/src/components/Pega_Extensions_JsonEditor/styles.ts b/src/components/Pega_Extensions_JsonEditor/styles.ts
new file mode 100644
index 00000000..b48d7d7e
--- /dev/null
+++ b/src/components/Pega_Extensions_JsonEditor/styles.ts
@@ -0,0 +1,235 @@
+import styled, { css } from 'styled-components';
+import { defaultThemeProp } from '@pega/cosmos-react-core';
+
+const StyledJsonEditorWrapper = styled.div(() => {
+ return css`
+ width: 100%;
+
+ textarea {
+ min-height: 15rem;
+ resize: vertical;
+ white-space: pre;
+ }
+ `;
+});
+
+export const StyledJsonEditorSurface = styled.div(({ theme }) => {
+ return css`
+ position: relative;
+ width: 100%;
+
+ textarea {
+ position: relative;
+ z-index: 2;
+ color: transparent;
+ background-color: transparent;
+ caret-color: ${theme.base.palette.interactive};
+ font-family: monospace;
+ line-height: 1.5;
+ padding-inline-start: calc(${theme.base.spacing} * 4);
+
+ &::placeholder {
+ color: ${theme.base.palette['foreground-color']};
+ opacity: ${theme.base.transparency['transparent-3']};
+ }
+ }
+ `;
+});
+
+StyledJsonEditorSurface.defaultProps = defaultThemeProp;
+
+export const StyledJsonEditorHighlight = styled.div<{ $labelHidden: boolean }>(({ theme, $labelHidden }) => {
+ return css`
+ position: absolute;
+ z-index: 1;
+ inset-inline: 0;
+ inset-block-end: 0;
+ inset-block-start: ${$labelHidden ? '0' : `calc(${theme.base.spacing} * 3)`};
+ overflow: hidden;
+ pointer-events: none;
+ color: ${theme.base.palette['foreground-color']};
+ font-family: monospace;
+ line-height: 1.5;
+ white-space: pre;
+
+ .json-string {
+ color: ${theme.base.palette.pending};
+ }
+
+ .json-number {
+ color: ${theme.base.palette.success};
+ }
+
+ .json-literal {
+ color: ${theme.base.palette.warn};
+ }
+
+ .json-punctuation {
+ color: ${theme.base.palette.info};
+ }
+ `;
+});
+
+StyledJsonEditorHighlight.defaultProps = defaultThemeProp;
+
+export const StyledJsonEditorLineNumbers = styled.div<{ $scrollTop: number }>(({ theme, $scrollTop }) => {
+ return css`
+ position: absolute;
+ inset-block-start: 0;
+ inset-inline-start: 0;
+ width: calc(${theme.base.spacing} * 3.5);
+ padding: ${theme.components['text-area'].padding};
+ box-sizing: border-box;
+ border-inline-end: 0.0625rem solid ${theme.base.palette['border-line']};
+ color: ${theme.base.palette['foreground-color']};
+ opacity: ${theme.base.transparency['transparent-2']};
+ text-align: end;
+ transform: translateY(-${$scrollTop}px);
+
+ span {
+ display: block;
+ line-height: 1.5;
+ }
+ `;
+});
+
+StyledJsonEditorLineNumbers.defaultProps = defaultThemeProp;
+
+export const StyledJsonEditorCode = styled.code<{
+ $scrollTop: number;
+ $scrollLeft: number;
+}>(({ theme, $scrollTop, $scrollLeft }) => {
+ return css`
+ position: absolute;
+ inset-block-start: 0;
+ inset-inline-start: 0;
+ min-width: 100%;
+ padding: ${theme.components['text-area'].padding};
+ padding-inline-start: calc(${theme.base.spacing} * 4);
+ box-sizing: border-box;
+ transform: translate(-${$scrollLeft}px, -${$scrollTop}px);
+ `;
+});
+
+StyledJsonEditorCode.defaultProps = defaultThemeProp;
+
+export const StyledJsonEditorToolbar = styled.div(({ theme }) => {
+ return css`
+ display: flex;
+ align-items: center;
+ gap: ${theme.base.spacing};
+ margin-block-end: ${theme.base.spacing};
+ `;
+});
+
+StyledJsonEditorToolbar.defaultProps = defaultThemeProp;
+
+export const StyledJsonEditorButton = styled.button(({ theme }) => {
+ return css`
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: calc(${theme.base.spacing} * 3);
+ min-height: calc(${theme.base.spacing} * 3);
+ padding: 0;
+ border: 0;
+ border-radius: ${theme.base['border-radius']};
+ color: ${theme.components.button.color};
+ background: transparent;
+ cursor: pointer;
+
+ &::before {
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ background: currentColor;
+ content: '';
+ opacity: 0;
+ pointer-events: none;
+ }
+
+ &:hover::before {
+ opacity: 0.1;
+ }
+
+ &:active::before {
+ opacity: 0.2;
+ }
+
+ &:focus-visible {
+ outline: none;
+ box-shadow: ${theme.components.button['focus-shadow']};
+ }
+
+ &:disabled {
+ cursor: not-allowed;
+ opacity: ${theme.base['disabled-opacity']};
+ pointer-events: none;
+ }
+
+ > svg {
+ display: block;
+ }
+ `;
+});
+
+StyledJsonEditorButton.defaultProps = defaultThemeProp;
+
+export const StyledJsonEditorFooter = styled.div(({ theme }) => {
+ return css`
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: ${theme.base.spacing};
+ margin-block-start: ${theme.base.spacing};
+ min-height: 1.5rem;
+ `;
+});
+
+StyledJsonEditorFooter.defaultProps = defaultThemeProp;
+
+export const StyledJsonEditorStatus = styled.span(({ theme }) => {
+ return css`
+ display: inline-flex;
+ align-items: center;
+ gap: calc(${theme.base.spacing} / 2);
+ color: ${theme.base.palette['foreground-color']};
+ font-size: ${theme.base['font-size']};
+
+ &[data-status='invalid'] {
+ color: ${theme.components['form-field'].error['status-color']};
+ }
+
+ svg {
+ width: 1em;
+ height: 1em;
+ }
+ `;
+});
+
+StyledJsonEditorStatus.defaultProps = defaultThemeProp;
+
+export const StyledJsonEditorMeta = styled.span(({ theme }) => {
+ return css`
+ color: ${theme.base.palette['foreground-color']};
+ opacity: ${theme.base.transparency['transparent-2']};
+ font-size: ${theme.base['font-size']};
+ `;
+});
+
+StyledJsonEditorMeta.defaultProps = defaultThemeProp;
+
+const StyledJsonEditorInlineError = styled.span(({ theme }) => {
+ return css`
+ color: ${theme.components['form-field'].error['status-color']};
+ font-size: ${theme.base['font-size']};
+ `;
+});
+
+StyledJsonEditorInlineError.defaultProps = defaultThemeProp;
+
+export const StyledJsonEditorCopyError = StyledJsonEditorInlineError;
+export const StyledJsonEditorError = StyledJsonEditorInlineError;
+
+export default StyledJsonEditorWrapper;
diff --git a/src/components/Pega_Extensions_JsonEditor/utils.ts b/src/components/Pega_Extensions_JsonEditor/utils.ts
new file mode 100644
index 00000000..873c672b
--- /dev/null
+++ b/src/components/Pega_Extensions_JsonEditor/utils.ts
@@ -0,0 +1,145 @@
+export interface JsonValidation {
+ isEmpty: boolean;
+ isValid: boolean;
+ message?: string;
+ errorLine?: number;
+ errorColumn?: number;
+ formattedValue: string;
+ minifiedValue: string;
+}
+
+export interface JsonHighlightPart {
+ text: string;
+ className?: string;
+ key: string;
+}
+
+export type CopyState = 'idle' | 'copied' | 'failed';
+
+const JSON_TOKEN_PATTERN = /("(?:\\.|[^"\\])*")|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|\b(?:true|false|null)\b|[{}[\],:]/g;
+
+export const normalizeIndent = (indent: number | string): number => {
+ const parsedIndent = typeof indent === 'number' ? indent : Number(indent);
+
+ if (!Number.isFinite(parsedIndent)) {
+ return 2;
+ }
+
+ return Math.min(10, Math.max(0, Math.floor(parsedIndent)));
+};
+
+const getErrorLocation = (value: string, error: unknown) => {
+ const message = error instanceof Error ? error.message : '';
+ const positionMatch = message.match(/position\s+(\d+)/i);
+
+ if (!positionMatch) {
+ return {};
+ }
+
+ const position = Number(positionMatch[1]);
+ const precedingText = value.slice(0, position);
+ const lastLineBreak = precedingText.lastIndexOf('\n');
+
+ return {
+ errorLine: precedingText.split('\n').length,
+ errorColumn: position - lastLineBreak,
+ };
+};
+
+export const highlightJson = (value: string): JsonHighlightPart[] => {
+ const highlighted: JsonHighlightPart[] = [];
+ let lastIndex = 0;
+
+ for (const match of value.matchAll(JSON_TOKEN_PATTERN)) {
+ const token = match[0];
+ const start = match.index ?? 0;
+ const end = start + token.length;
+
+ if (start > lastIndex) {
+ highlighted.push({ text: value.slice(lastIndex, start), key: `text-${lastIndex}-${start}` });
+ }
+
+ let className = 'json-punctuation';
+ if (token.startsWith('"')) {
+ className = /^\s*:/.test(value.slice(end)) ? 'json-key' : 'json-string';
+ } else if (/^-?\d/.test(token)) {
+ className = 'json-number';
+ } else if (/^(true|false|null)$/.test(token)) {
+ className = 'json-literal';
+ }
+
+ highlighted.push({ text: token, className, key: `${start}-${end}` });
+ lastIndex = end;
+ }
+
+ if (lastIndex < value.length) {
+ highlighted.push({ text: value.slice(lastIndex), key: `text-${lastIndex}-${value.length}` });
+ }
+
+ return highlighted;
+};
+
+export const validateJson = (value: string, indent: number | string, required: boolean): JsonValidation => {
+ if (!value.trim()) {
+ return {
+ isEmpty: true,
+ isValid: !required,
+ message: required ? 'JSON is required.' : undefined,
+ formattedValue: '',
+ minifiedValue: '',
+ };
+ }
+
+ try {
+ const parsedValue: unknown = JSON.parse(value);
+ return {
+ isEmpty: false,
+ isValid: true,
+ formattedValue: JSON.stringify(parsedValue, null, normalizeIndent(indent)),
+ minifiedValue: JSON.stringify(parsedValue),
+ };
+ } catch (error) {
+ const { errorLine, errorColumn } = getErrorLocation(value, error);
+ return {
+ isEmpty: false,
+ isValid: false,
+ message: error instanceof SyntaxError ? error.message : 'Invalid JSON.',
+ errorLine,
+ errorColumn,
+ formattedValue: '',
+ minifiedValue: '',
+ };
+ }
+};
+
+export const copyText = async (value: string): Promise => {
+ if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
+ await navigator.clipboard.writeText(value);
+ return;
+ }
+
+ const textArea = document.createElement('textarea');
+ textArea.value = value;
+ textArea.setAttribute('readonly', '');
+ textArea.style.position = 'fixed';
+ textArea.style.opacity = '0';
+ document.body.appendChild(textArea);
+ try {
+ textArea.select();
+ if (!document.execCommand('copy')) {
+ throw new Error('Clipboard access is unavailable.');
+ }
+ } finally {
+ document.body.removeChild(textArea);
+ }
+};
+
+export const getValidationLabel = (jsonValidation: JsonValidation): string => {
+ return jsonValidation.isValid ? 'Valid JSON' : 'Invalid JSON';
+};
+
+export const getValidationStatus = (jsonValidation: JsonValidation): 'invalid' | 'valid' => {
+ return jsonValidation.isValid ? 'valid' : 'invalid';
+};
+
+export const getLineCount = (value: string): number => (value ? value.split('\n').length : 0);