save( editorState.toJSON() ) }
+ * />
+ * ```
+ *
+ * With `valueFormat="lexical"`:
+ *
+ * - `defaultValue` is parsed as Lexical `EditorState` JSON.
+ * - `onChange`'s first argument is a synthetic `LegacyEditorState` with
+ * `.toJSON()` (returns Lexical-shaped JSON) and `.read( fn )`.
+ * - `ref.current.legacy` is available with `getEditorState`, `focus`, and
+ * `update`.
+ *
+ * Every deprecated surface fires a one-time dev-only `console.warn`. The
+ * shim will be **removed in 2.0** — schedule a one-time data migration.
+ *
+ * ### ✨ New — Converter utilities
+ *
+ * Exported from the package root for one-time data migrations:
+ *
+ * ```js
+ * import {
+ * lexicalToMarkup,
+ * markupToLexical,
+ * lexicalJSONToTipTapDoc,
+ * tipTapDocToLexicalJSON,
+ * } from '@bsf/force-ui';
+ *
+ * const markup = lexicalToMarkup( storedLexicalJsonString );
+ * // → "Employee name: @[Catherine](3)"
+ *
+ * const lexicalJSON = markupToLexical( markup );
+ * ```
+ *
+ * ### ✨ New — `multiline` prop
+ *
+ * Defaults to `true`. Set to `false` to swallow the Enter key instead of
+ * inserting a newline (single-line input behavior).
+ *
+ * ### 🐛 Fix — Empty-label guard in mention filter (restored in 1.8.0)
+ *
+ * The 1.7 Lexical lookup excluded object options whose `[by]` field was
+ * empty, `null`, `undefined`, or missing — those options never appeared in
+ * the dropdown. This guard was missing in the initial TipTap rewrite (so
+ * blank rows showed up when the query was empty) and has been restored.
+ *
+ * ### 🐛 Fix — Placeholder alignment
+ *
+ * The placeholder previously used `absolute inset-0 flex items-center`,
+ * which made it vertically center over the entire editor area. When the
+ * editor grew (auto-height, taller wrappers, etc.) the placeholder
+ * drifted to the middle while the caret stayed on the first line. The
+ * placeholder is now pinned to the top and mirrors the empty ``'s
+ * vertical positioning, so its text aligns with the caret regardless of
+ * the editor's overall height.
+ *
+ * ### 🗑️ Removed — Rich-text metadata on text nodes
+ *
+ * The 1.7 Lexical text nodes tracked `format`, `style`, `detail`, and
+ * `mode` fields, but the editor surface never let users apply rich
+ * formatting. The new markup form preserves the plain text only. If you
+ * relied on this metadata, file an issue before upgrading.
+ *
+ * ---
+ *
+ * ## Migration paths
+ *
+ * ### Path A — Drop-in compat (zero code change, deprecated)
+ *
+ * Add `valueFormat="lexical"` to every `` and keep your
+ * existing `defaultValue` JSON and `onChange( s => s.toJSON() )`
+ * callbacks. This is the shortest path; logs a one-time deprecation
+ * warning in development. Removed in 2.0.
+ *
+ * ```jsx
+ * save( editorState.toJSON() ) }
+ * options={ options }
+ * />
+ * ```
+ *
+ * ### Path B — One-time data migration (recommended)
+ *
+ * Convert stored Lexical JSON to markup once (e.g., in a DB migration or
+ * a data-load step) and remove `valueFormat="lexical"` entirely.
+ *
+ * ```js
+ * import { lexicalToMarkup, markupToLexical } from '@bsf/force-ui';
+ *
+ * // Read side: Lexical JSON in the DB → markup in the UI
+ * const markup = lexicalToMarkup( storedLexicalJsonString );
+ *
+ * // Write side (if you must keep Lexical in the DB during transition):
+ * const lexicalJSON = markupToLexical( "Employee name: @[Catherine](3)" );
+ * ```
+ *
+ * Then update call sites:
+ *
+ * ```diff
+ * - defaultValue={ storedLexicalJsonString }
+ * - onChange={ ( editorState ) => save( editorState.toJSON() ) }
+ * + defaultValue={ migratedMarkupString }
+ * + onChange={ ( markup ) => save( markup ) }
+ * ```
+ *
+ * ---
+ *
+ * ## Ref usage
+ *
+ * `ref.current` is the TipTap `Editor` in both modes. It is `null` until
+ * TipTap has initialized — read it from event handlers or `useEffect`,
+ * not during render.
+ *
+ * ```jsx
+ * import { useRef, useEffect } from 'react';
+ * import { EditorInput } from '@bsf/force-ui';
+ *
+ * function MyForm() {
+ * const editorRef = useRef( null );
+ *
+ * useEffect( () => {
+ * if ( editorRef.current ) {
+ * editorRef.current.commands.focus();
+ * }
+ * }, [] );
+ *
+ * return ;
+ * }
+ * ```
+ *
+ * With `valueFormat="lexical"`, the same ref also exposes a `legacy`
+ * namespace mirroring the Lexical methods most commonly used at call
+ * sites:
+ *
+ * ```jsx
+ * editorRef.current.legacy.getEditorState().toJSON();
+ * editorRef.current.legacy.focus();
+ * editorRef.current.legacy.update( () => { } );
+ * ```
+ *
+ * The `legacy` namespace is deprecated and removed in 2.0. Anything
+ * outside of those three methods is a hard break — migrate to TipTap
+ * commands.
+ *
+ * ---
+ *
+ * ## Removal timeline
+ *
+ * | Version | Status |
+ * | ------- | ---------------------------------------------------------------------------------------------- |
+ * | 1.8.x | Shims live. Dev-only warnings on every legacy surface. |
+ * | 2.0 | `valueFormat`, `LegacyEditorState`, `ref.current.legacy`, and converter helpers all removed. |
+ *
+ * Plan your data migration before bumping to 2.0.
+ */
+const meta: Meta = {
+ title: 'Atoms/EditorInput/Migration & Changelog',
+ tags: [ 'autodocs' ],
+ parameters: {
+ previewTabs: { canvas: { hidden: true } },
+ viewMode: 'docs',
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Overview: Story = {
+ render: () => <>>,
+ parameters: {
+ docs: {
+ story: { inline: false, height: '0' },
+ },
+ },
+};
+Overview.storyName = 'Overview';
diff --git a/src/components/editor-input/editor-input-style.ts b/src/components/editor-input/editor-input-style.ts
index ec5cd187..936b6477 100644
--- a/src/components/editor-input/editor-input-style.ts
+++ b/src/components/editor-input/editor-input-style.ts
@@ -8,13 +8,13 @@ export const editorDisabledClassNames =
'bg-field-secondary-background outline-field-border-disabled hover:outline-field-border-disabled [&_p]:text-badge-color-disabled cursor-not-allowed';
export const editorInputClassNames = {
- sm: 'px-3 py-1.5 rounded [&_.editor-content>p]:text-xs [&_.editor-content>p]:font-normal [&_.pointer-events-none]:text-xs [&_.pointer-events-none]:font-normal [&_.editor-content>p]:content-center [&_.editor-content>p]:min-h-5',
- md: 'px-3.5 py-2 rounded-md [&_.editor-content>p]:text-sm [&_.editor-content>p]:font-normal [&_.pointer-events-none]:text-sm [&_.pointer-events-none]:font-normal [&_.editor-content>p]:content-center [&_.editor-content>p]:min-h-6',
- lg: 'px-4 py-2.5 rounded-md [&_.editor-content>p]:text-base [&_.editor-content>p]:font-normal [&_.pointer-events-none]:text-base [&_.pointer-events-none]:font-normal [&_.editor-content>p]:content-center [&_.editor-content>p]:min-h-7',
+ sm: 'px-3 py-1.5 rounded [&_.editor-content>p]:text-xs [&_.editor-content>p]:font-normal [&_.pointer-events-none]:text-xs [&_.pointer-events-none]:font-normal [&_.editor-content>p]:content-center [&_.editor-content>p]:min-h-5 [&_.editor-placeholder]:min-h-5',
+ md: 'px-3.5 py-2 rounded-md [&_.editor-content>p]:text-sm [&_.editor-content>p]:font-normal [&_.pointer-events-none]:text-sm [&_.pointer-events-none]:font-normal [&_.editor-content>p]:content-center [&_.editor-content>p]:min-h-6 [&_.editor-placeholder]:min-h-6',
+ lg: 'px-4 py-2.5 rounded-md [&_.editor-content>p]:text-base [&_.editor-content>p]:font-normal [&_.pointer-events-none]:text-base [&_.pointer-events-none]:font-normal [&_.editor-content>p]:content-center [&_.editor-content>p]:min-h-7 [&_.editor-placeholder]:min-h-7',
};
export const comboboxDropdownCommonClassNames =
- 'absolute inset-x-0 top-full m-0 w-full h-auto overflow-y-auto overflow-x-hidden z-10 bg-background-primary border border-solid border-border-subtle shadow-lg list-none';
+ 'm-0 w-full h-auto overflow-y-auto overflow-x-hidden bg-background-primary border border-solid border-border-subtle shadow-lg list-none';
export const comboboxDropdownClassNames = {
sm: 'p-1.5 rounded-md max-h-[10.75rem]',
diff --git a/src/components/editor-input/editor-input.stories.tsx b/src/components/editor-input/editor-input.stories.tsx
index 9fc8e7df..88d55035 100644
--- a/src/components/editor-input/editor-input.stories.tsx
+++ b/src/components/editor-input/editor-input.stories.tsx
@@ -1,5 +1,7 @@
import EditorInput from './editor-input';
import type { Meta, StoryFn } from '@storybook/react-vite';
+import { useEffect, useRef } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
const meta: Meta = {
title: 'Atoms/EditorInput',
@@ -49,26 +51,195 @@ Default.args = {
autoSpaceAfterMention: false,
autoFocus: false,
options,
- onChange: ( editorState ) => editorState.toJSON(),
+ onChange: ( markup ) => console.log( markup ), // eslint-disable-line no-console
};
export const Small: Story = Template.bind( {} );
Small.args = {
size: 'sm',
options,
- onChange: ( editorState ) => editorState.toJSON(),
};
export const Medium: Story = Template.bind( {} );
Medium.args = {
size: 'md',
options,
- onChange: ( editorState ) => editorState.toJSON(),
};
export const Large: Story = Template.bind( {} );
Large.args = {
size: 'lg',
options,
- onChange: ( editorState ) => editorState.toJSON(),
+};
+
+export const WithDefaultValue: Story = Template.bind( {} );
+WithDefaultValue.args = {
+ size: 'md',
+ options,
+ defaultValue: 'Hello @[Red](Red), welcome to @[Blue](Blue)!',
+};
+WithDefaultValue.storyName = 'With Default Value';
+
+// ---------------------------------------------------------------------------
+// v1.7 Lexical backward-compatibility story
+// ---------------------------------------------------------------------------
+
+const legacyLexicalDefaultValue = JSON.stringify( {
+ root: {
+ type: 'root',
+ direction: 'ltr',
+ format: '',
+ indent: 0,
+ version: 1,
+ children: [
+ {
+ type: 'paragraph',
+ direction: 'ltr',
+ format: '',
+ indent: 0,
+ version: 1,
+ textFormat: 0,
+ textStyle: '',
+ children: [
+ {
+ type: 'text',
+ text: 'Employee name: ',
+ detail: 0,
+ format: 0,
+ mode: 'normal',
+ style: '',
+ version: 1,
+ },
+ {
+ type: 'mention',
+ data: { id: 3, label: 'Catherine' },
+ version: 1,
+ },
+ ],
+ },
+ ],
+ },
+} );
+
+export const WithLexicalCompat: Story = Template.bind( {} );
+WithLexicalCompat.args = {
+ size: 'md',
+ options: [
+ { id: 1, label: 'Anton' },
+ { id: 2, label: 'Boris' },
+ { id: 3, label: 'Catherine' },
+ ],
+ by: 'label',
+ valueFormat: 'lexical',
+ defaultValue: legacyLexicalDefaultValue,
+ // Old v1.7 call sites used editorState.toJSON()
+ onChange: ( editorState ) => {
+ // eslint-disable-next-line no-console
+ console.log( ( editorState as { toJSON: () => unknown } ).toJSON() );
+ },
+};
+WithLexicalCompat.storyName = 'v1.7 Lexical Compat';
+WithLexicalCompat.parameters = {
+ docs: {
+ description: {
+ story: 'Demonstrates the `valueFormat="lexical"` backward-compatibility shim. Existing v1.7 consumers can opt in with one prop and keep their stored Lexical `EditorState` JSON `defaultValue` and `onChange( s => s.toJSON() )` call sites. Deprecated — removed in @bsf/force-ui 2.0.',
+ },
+ },
+};
+
+// ---------------------------------------------------------------------------
+// Shadow DOM story
+// ---------------------------------------------------------------------------
+
+/**
+ * Renders children inside an attached shadow root using a dedicated React root.
+ *
+ * Why a separate React root (not createPortal):
+ * createPortal keeps the React tree in the light DOM — React's event
+ * delegation attaches to the light-DOM root container. Events fired inside
+ * the shadow root are retargeted at the shadow boundary, so event.target
+ * becomes the host element and React cannot match them to the correct fiber.
+ * Result: the editor never receives input/composition events → typing does nothing.
+ *
+ * createRoot() inside the shadow root places React's event delegation inside
+ * the shadow boundary so events are handled before retargeting occurs.
+ *
+ * Style injection:
+ * Clones every