@itsammarb/mention-editor 1.0.5 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -54,14 +54,19 @@ Hello <@a1b2c3d4-0000-0000-0000-000000000001>, welcome.
54
54
  | `value` | `string` | *(required)* | Wire-format content (controlled — the editor always reflects this string). |
55
55
  | `fields` | `{ id: string; label: string }[]` | *(required)* | Fields offered in the `@` suggestion menu; filter/localize before passing in — the component doesn't fetch or translate these itself. |
56
56
  | `onChange` | `(value: string) => void` | `undefined` | Called with the new wire-format string on every edit. Omit for a read-only display (combine with `disabled`). |
57
+ | `onFocus` / `onBlur` | `(event: React.FocusEvent) => void` | `undefined` | Fired when the editable surface gains/loses focus — e.g. to trigger validation-on-blur in a form library. |
58
+ | `onKeyDown` | `(event: React.KeyboardEvent) => void` | `undefined` | Called after the suggestion menu's own key handling (arrows/Tab/Enter/Escape) — e.g. to add a Cmd+Enter-to-submit shortcut. Check `event.defaultPrevented` if you need to know whether the menu already acted on the key. |
57
59
  | `dir` | `'ltr' \| 'rtl'` | `undefined` (browser infers) | Applied directly as the `dir` HTML attribute on the editable surface — required for correct caret movement and bidi layout with RTL content such as Arabic. |
58
60
  | `disabled` | `boolean` | `false` | Makes the editor read-only (`Editable`'s `readOnly`) and dims it via `opacity-60`. |
59
61
  | `isError` | `boolean` | `false` | Swaps the border to the invalid-state color (`border-red-500`). Purely visual — doesn't block typing. |
60
62
  | `placeholder` | `string` | `undefined` (no placeholder) | Shown only when the document is fully empty (a single empty paragraph) — see [Behavior notes](#behavior-notes). |
61
63
  | `rows` | `number` | `undefined` (intrinsic `min-h-11`, ~44px) | Sets `min-height` to `rows * 1.5em`, textarea-`rows`-equivalent. The editor still grows taller than this if content wraps to more lines. |
64
+ | `maxLength` | `number` | `undefined` (no limit) | Caps the *visible* length — see [Length limits](#length-limits). |
62
65
  | `className` | `string` | `undefined` | Extra class name(s) appended to the root container, after the built-in ones — use this for layout (width, margin) or to override the built-in border/background. |
63
66
  | `colors` | `MentionEditorColors` | `undefined` (built-in defaults) | Per-instance color overrides — see [Theme colors](#theme-colors). Omitted keys keep their default. |
64
67
 
68
+ `MentionEditor` also forwards a `ref` — see [Imperative handle](#imperative-handle).
69
+
65
70
  ## Exported utilities
66
71
 
67
72
  Everything importable from `@itsammarb/mention-editor`:
@@ -72,6 +77,8 @@ Everything importable from `@itsammarb/mention-editor`:
72
77
  | `MentionEditorProps` | type | Prop types for `MentionEditor`. |
73
78
  | `MentionFieldOption` | type | Shape of an entry in `fields`: `{ id: string; label: string }`. |
74
79
  | `MentionEditorColors` | type | Shape of the `colors` prop — see [Theme colors](#theme-colors). |
80
+ | `MentionEditorHandle` | type | Shape of the imperative handle exposed via `ref` — see [Imperative handle](#imperative-handle). |
81
+ | `getPlainTextLength(nodes: Descendant[]): number` | function | The same visible-length count `maxLength` enforces and the ref's `getPlainTextLength()` returns — each mention counts as `"@" + label.length`, not its wire-format id. |
75
82
  | `serialize(nodes: Descendant[]): string` | function | Converts a Slate `Descendant[]` tree to the wire-format string. Useful if you need to inspect/generate content outside the component (e.g. server-side). |
76
83
  | `deserialize(value: string, fields?: MentionFieldOption[]): Descendant[]` | function | Inverse of `serialize`. `fields` resolves each mention's label; an id not found in `fields` still renders, falling back to the raw id as its label. |
77
84
  | `serializeToDiscordMarkup` | function | Deprecated alias of `serialize`, kept for backwards compatibility with the pre-rename API. Prefer `serialize`. |
@@ -137,6 +144,37 @@ A `colors` value applies in both light and dark mode (the fallback is what diffe
137
144
 
138
145
  If your own app happens to use Tailwind and you want to reuse its utility classes against this component's internal DOM (beyond what `className` on the root reaches), you can optionally point your app's Tailwind config/`@source` at `node_modules/@itsammarb/mention-editor/dist` — but this is purely an opt-in extra, not required for the component to work or look right.
139
146
 
147
+ ## Imperative handle
148
+
149
+ `MentionEditor` forwards a `ref` exposing:
150
+
151
+ ```tsx
152
+ import { useRef } from 'react';
153
+ import { MentionEditor, MentionEditorHandle } from '@itsammarb/mention-editor';
154
+
155
+ const editorRef = useRef<MentionEditorHandle>(null);
156
+
157
+ <MentionEditor ref={editorRef} value={value} fields={fields} onChange={setValue} />;
158
+
159
+ editorRef.current?.focus();
160
+ editorRef.current?.blur();
161
+ editorRef.current?.getPlainTextLength(); // same count maxLength enforces against
162
+ ```
163
+
164
+ There's no `value` getter on the handle since the component is fully controlled — read the current content from the `value` you already pass in (or `onChange`'s latest callback argument), not from the ref.
165
+
166
+ ## Length limits
167
+
168
+ `maxLength` caps the *visible* length: each mention counts as `"@" + label.length`, not its (often much longer, GUID-based) wire-format id — so a 4-character mention label costs 5 toward the limit regardless of how long the underlying field id is. Typing or pasting past the limit is blocked (a paste that doesn't fully fit is truncated to what's left, not rejected outright); picking a mention from the suggestion menu that would push the total over the limit is skipped entirely rather than partially inserted.
169
+
170
+ Content that's already over `maxLength` — e.g. because it was set that way via the controlled `value` prop, before a `maxLength` was added — is left alone and can still be edited or trimmed back down; only *growing* past the limit is blocked, so you're never left unable to delete your way back under it.
171
+
172
+ `getPlainTextLength()` (exported standalone, and available on the [imperative handle](#imperative-handle)) returns the same count, e.g. for rendering a "120/280" counter next to the editor.
173
+
174
+ ## Accessibility
175
+
176
+ The suggestion menu follows the ARIA listbox pattern: the menu has `role="listbox"`, each row has `role="option"` and `aria-selected`, and the editable surface carries `aria-haspopup="listbox"` plus (only while the menu is open) `aria-expanded`, `aria-controls`, and `aria-activedescendant` pointing at the keyboard-highlighted row. A search with zero matches keeps the menu open showing a "No matches" row instead of silently vanishing, so a screen reader user isn't left wondering whether the `@` did anything.
177
+
140
178
  ## Behavior notes
141
179
 
142
180
  Things that are fixed (not currently exposed as props), so you know what to expect rather than hunt for a setting:
@@ -6,14 +6,30 @@ export interface MentionEditorProps {
6
6
  /** Already localized/filtered list of fields the consumer wants offered. */
7
7
  fields: MentionFieldOption[];
8
8
  onChange?: (value: string) => void;
9
+ onFocus?: (event: React.FocusEvent) => void;
10
+ onBlur?: (event: React.FocusEvent) => void;
11
+ /** Called after the suggestion menu's own key handling (arrows/Tab/Enter/Escape). */
12
+ onKeyDown?: (event: React.KeyboardEvent) => void;
9
13
  dir?: 'ltr' | 'rtl';
10
14
  disabled?: boolean;
11
15
  isError?: boolean;
12
16
  placeholder?: string;
13
17
  /** Approximate visible height, textarea-`rows`-equivalent. */
14
18
  rows?: number;
19
+ /**
20
+ * Caps the *visible* length (each mention counts as `"@" + label.length`,
21
+ * not its often-longer wire-format id) -- typing or pasting past this limit
22
+ * is blocked, but existing content already over it can still be edited/trimmed.
23
+ */
24
+ maxLength?: number;
15
25
  className?: string;
16
26
  /** Per-instance color overrides; omitted keys keep the built-in default. */
17
27
  colors?: MentionEditorColors;
18
28
  }
19
- export declare function MentionEditor(props: MentionEditorProps): React.JSX.Element;
29
+ export interface MentionEditorHandle {
30
+ focus: () => void;
31
+ blur: () => void;
32
+ /** The same visible-length count `maxLength` is enforced against. */
33
+ getPlainTextLength: () => number;
34
+ }
35
+ export declare const MentionEditor: React.ForwardRefExoticComponent<MentionEditorProps & React.RefAttributes<MentionEditorHandle>>;
@@ -1,8 +1,41 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.MentionEditor = MentionEditor;
36
+ exports.MentionEditor = void 0;
4
37
  const jsx_runtime_1 = require("react/jsx-runtime");
5
- const react_1 = require("react");
38
+ const react_1 = __importStar(require("react"));
6
39
  const slate_1 = require("slate");
7
40
  const slate_react_1 = require("slate-react");
8
41
  const slate_history_1 = require("slate-history");
@@ -19,24 +52,38 @@ const EDITOR_COLOR_KEYS = [
19
52
  'mentionColor',
20
53
  'mentionBg',
21
54
  ];
22
- function MentionEditor(props) {
23
- const { value, fields, onChange, dir, disabled = false, isError = false, placeholder, rows, className, colors, } = props;
55
+ exports.MentionEditor = react_1.default.forwardRef(function MentionEditor(props, ref) {
56
+ const { value, fields, onChange, onFocus, onBlur, onKeyDown, dir, disabled = false, isError = false, placeholder, rows, maxLength, className, colors, } = props;
57
+ // Read via a ref (not captured in the `withMaxLength` closure directly) so
58
+ // changing `maxLength` doesn't require recreating the editor instance.
59
+ const maxLengthRef = (0, react_1.useRef)(maxLength);
60
+ maxLengthRef.current = maxLength;
24
61
  // `generation` forces a full remount of the Slate editor instance whenever the
25
62
  // `value` prop changes for a reason *other* than our own onChange echoing back
26
63
  // down (e.g. the consumer swaps to editing a different record). See the
27
64
  // render-time reset block below for the echo-vs-external distinction.
28
65
  const [generation, setGeneration] = (0, react_1.useState)(0);
29
- const editor = (0, react_1.useMemo)(() => withMentions((0, slate_history_1.withHistory)((0, slate_react_1.withReact)((0, slate_1.createEditor)()))), [generation]);
66
+ const editor = (0, react_1.useMemo)(() => withMaxLength(withMentions((0, slate_history_1.withHistory)((0, slate_react_1.withReact)((0, slate_1.createEditor)()))), () => maxLengthRef.current), [generation]);
30
67
  const lastEmittedRef = (0, react_1.useRef)(value);
31
68
  const [committedValue, setCommittedValue] = (0, react_1.useState)(value);
32
69
  const [slateValue, setSlateValue] = (0, react_1.useState)(() => value ? (0, serialize_1.deserialize)(value, fields) : types_1.INITIAL_VALUE);
70
+ // Tracks the visible length of the current value, exposed via the ref's
71
+ // getPlainTextLength().
72
+ const plainLengthRef = (0, react_1.useRef)((0, serialize_1.getPlainTextLength)(slateValue));
33
73
  const [target, setTarget] = (0, react_1.useState)(null);
34
74
  const [index, setIndex] = (0, react_1.useState)(0);
35
75
  const [search, setSearch] = (0, react_1.useState)('');
76
+ (0, react_1.useImperativeHandle)(ref, () => ({
77
+ focus: () => slate_react_1.ReactEditor.focus(editor),
78
+ blur: () => slate_react_1.ReactEditor.blur(editor),
79
+ getPlainTextLength: () => plainLengthRef.current,
80
+ }), [editor]);
36
81
  if (value !== committedValue) {
37
82
  setCommittedValue(value);
38
83
  if (value !== lastEmittedRef.current) {
39
- setSlateValue(value ? (0, serialize_1.deserialize)(value, fields) : types_1.INITIAL_VALUE);
84
+ const nextSlateValue = value ? (0, serialize_1.deserialize)(value, fields) : types_1.INITIAL_VALUE;
85
+ setSlateValue(nextSlateValue);
86
+ plainLengthRef.current = (0, serialize_1.getPlainTextLength)(nextSlateValue);
40
87
  setGeneration((g) => g + 1);
41
88
  setTarget(null);
42
89
  setSearch('');
@@ -87,6 +134,7 @@ function MentionEditor(props) {
87
134
  }, [target, editor, slateValue]);
88
135
  // --- CORE LOGIC: Trigger Detection ---
89
136
  const handleChange = (0, react_1.useCallback)((newValue) => {
137
+ plainLengthRef.current = (0, serialize_1.getPlainTextLength)(newValue);
90
138
  setSlateValue(newValue);
91
139
  const serialized = (0, serialize_1.serialize)(newValue);
92
140
  if (serialized !== lastEmittedRef.current) {
@@ -107,6 +155,16 @@ function MentionEditor(props) {
107
155
  // --- CORE LOGIC: Insertion ---
108
156
  const selectField = (0, react_1.useCallback)((selectedField) => {
109
157
  if (target) {
158
+ if (maxLength != null) {
159
+ // The target range is exactly "@" + the search text typed so far,
160
+ // so its plain length is always 1 + search.length -- compute what
161
+ // the total would be after swapping it for the mention, and skip
162
+ // the insert entirely rather than let it land over the limit.
163
+ const currentLength = (0, serialize_1.getPlainTextLength)(editor.children);
164
+ const projectedLength = currentLength - (1 + search.length) + (1 + selectedField.label.length);
165
+ if (projectedLength > maxLength)
166
+ return;
167
+ }
110
168
  slate_1.Transforms.select(editor, target);
111
169
  slate_1.Transforms.delete(editor);
112
170
  slate_1.Transforms.insertNodes(editor, {
@@ -118,7 +176,7 @@ function MentionEditor(props) {
118
176
  slate_1.Transforms.move(editor, { distance: 1, unit: 'offset' });
119
177
  setTarget(null);
120
178
  }
121
- }, [editor, target]);
179
+ }, [editor, target, maxLength, search]);
122
180
  // --- KEYBOARD NAVIGATION ---
123
181
  const handleKeyDown = (0, react_1.useCallback)((event) => {
124
182
  if (target) {
@@ -137,8 +195,10 @@ function MentionEditor(props) {
137
195
  }
138
196
  case 'Tab':
139
197
  case 'Enter':
140
- event.preventDefault();
141
- selectField(filteredFields[index]);
198
+ if (filteredFields.length > 0) {
199
+ event.preventDefault();
200
+ selectField(filteredFields[index]);
201
+ }
142
202
  break;
143
203
  case 'Escape':
144
204
  event.preventDefault();
@@ -146,7 +206,9 @@ function MentionEditor(props) {
146
206
  break;
147
207
  }
148
208
  }
149
- }, [target, index, filteredFields, selectField]);
209
+ onKeyDown === null || onKeyDown === void 0 ? void 0 : onKeyDown(event);
210
+ }, [target, index, filteredFields, selectField, onKeyDown]);
211
+ const menuId = (0, react_1.useId)();
150
212
  const rootClassName = [
151
213
  'mention-editor relative rounded-md border',
152
214
  'bg-(--mention-editor-bg,var(--color-white)) dark:bg-(--mention-editor-bg,var(--color-neutral-900))',
@@ -162,8 +224,8 @@ function MentionEditor(props) {
162
224
  // Set on the root: cascades naturally to Editable/mention/placeholder since
163
225
  // they're real DOM descendants (unlike the portaled menu -- see MentionMenu).
164
226
  const rootStyle = (0, colors_1.colorsToCssVars)(colors, EDITOR_COLOR_KEYS);
165
- return ((0, jsx_runtime_1.jsx)(slate_react_1.Slate, { editor: editor, value: slateValue, onChange: handleChange, children: (0, jsx_runtime_1.jsxs)("div", { className: rootClassName, style: rootStyle, children: [(0, jsx_runtime_1.jsx)(slate_react_1.Editable, { className: "mention-editor__editable min-h-11 px-3 py-2 text-base leading-6 outline-none text-(--mention-editor-text-color,var(--color-gray-900)) dark:text-(--mention-editor-text-color,var(--color-gray-100))", style: editableStyle, dir: dir, readOnly: disabled, onKeyDown: handleKeyDown, placeholder: placeholder, renderElement: (elementProps) => (0, jsx_runtime_1.jsx)(Element, { ...elementProps }), renderPlaceholder: renderPlaceholder }), target && ((0, jsx_runtime_1.jsx)(MentionMenu_1.MentionMenu, { fields: filteredFields, selectedIndex: index, onSelect: selectField, onHover: setIndex, targetRect: menuTargetRect, colors: colors }))] }) }, generation));
166
- }
227
+ return ((0, jsx_runtime_1.jsx)(slate_react_1.Slate, { editor: editor, value: slateValue, onChange: handleChange, children: (0, jsx_runtime_1.jsxs)("div", { className: rootClassName, style: rootStyle, children: [(0, jsx_runtime_1.jsx)(slate_react_1.Editable, { className: "mention-editor__editable min-h-11 px-3 py-2 text-base leading-6 outline-none text-(--mention-editor-text-color,var(--color-gray-900)) dark:text-(--mention-editor-text-color,var(--color-gray-100))", style: editableStyle, dir: dir, readOnly: disabled, onKeyDown: handleKeyDown, onFocus: onFocus, onBlur: onBlur, placeholder: placeholder, renderElement: (elementProps) => (0, jsx_runtime_1.jsx)(Element, { ...elementProps }), renderPlaceholder: renderPlaceholder, "aria-haspopup": "listbox", "aria-expanded": !!target, "aria-controls": target ? menuId : undefined, "aria-activedescendant": target && filteredFields[index] ? (0, MentionMenu_1.getMenuOptionId)(menuId, filteredFields[index].id) : undefined }), target && ((0, jsx_runtime_1.jsx)(MentionMenu_1.MentionMenu, { id: menuId, fields: filteredFields, selectedIndex: index, onSelect: selectField, onHover: setIndex, targetRect: menuTargetRect, colors: colors }))] }) }, generation));
228
+ });
167
229
  // slate-react's default placeholder renders at a fixed dim-black opacity,
168
230
  // which reads poorly in dark mode. Overriding it lets the placeholder use
169
231
  // real (theme-aware) Tailwind colors at full opacity instead.
@@ -247,6 +309,51 @@ const withMentions = (editor) => {
247
309
  };
248
310
  return editor;
249
311
  };
312
+ /**
313
+ * Enforces `maxLength` by letting an insertion happen normally and then
314
+ * trimming any resulting overflow off the end via a real `Transforms.delete`.
315
+ *
316
+ * The seemingly more obvious approach -- checking first and skipping the
317
+ * insertion entirely when it wouldn't fit -- doesn't work in this Slate
318
+ * version: typed input is reconciled by diffing the DOM *after* the native
319
+ * browser edit has already happened, not by intercepting and
320
+ * `preventDefault`-ing it beforehand. So refusing to call the real
321
+ * `insertText` still leaves the just-typed character sitting in the actual
322
+ * DOM, with no Slate operation left to reconcile it away. Inserting for real
323
+ * and then deleting the excess is itself a genuine operation, which Slate
324
+ * *does* correctly reconcile the DOM against.
325
+ *
326
+ * Covers typing (`insertText`), paste/drop (`insertFragment`), and new
327
+ * paragraphs (`insertBreak`); mention insertion goes through
328
+ * `Transforms.insertNodes` directly and is pre-checked separately in
329
+ * `selectField` instead, since skipping a discrete, deliberate action
330
+ * (picking a menu item) outright is the better UX there.
331
+ */
332
+ const withMaxLength = (editor, getMaxLength) => {
333
+ const { insertText, insertFragment, insertBreak } = editor;
334
+ const trimToMaxLength = () => {
335
+ const maxLength = getMaxLength();
336
+ if (maxLength == null)
337
+ return;
338
+ const overBy = (0, serialize_1.getPlainTextLength)(editor.children) - maxLength;
339
+ if (overBy > 0) {
340
+ slate_1.Transforms.delete(editor, { unit: 'character', reverse: true, distance: overBy });
341
+ }
342
+ };
343
+ editor.insertText = (text, options) => {
344
+ insertText(text, options);
345
+ trimToMaxLength();
346
+ };
347
+ editor.insertFragment = (fragment, options) => {
348
+ insertFragment(fragment, options);
349
+ trimToMaxLength();
350
+ };
351
+ editor.insertBreak = () => {
352
+ insertBreak();
353
+ trimToMaxLength();
354
+ };
355
+ return editor;
356
+ };
250
357
  // Renders Slate nodes into React components
251
358
  const Element = ({ attributes, children, element }) => {
252
359
  var _a;
@@ -1,6 +1,8 @@
1
1
  import React from 'react';
2
2
  import { MentionFieldOption, MentionEditorColors } from './types';
3
3
  interface MentionMenuProps {
4
+ /** Element id; referenced by the editable's `aria-controls`. */
5
+ id: string;
4
6
  fields: MentionFieldOption[];
5
7
  selectedIndex: number;
6
8
  onSelect: (field: MentionFieldOption) => void;
@@ -8,5 +10,7 @@ interface MentionMenuProps {
8
10
  targetRect: DOMRect | null;
9
11
  colors?: MentionEditorColors;
10
12
  }
13
+ /** Shared with `MentionEditor` so its `aria-activedescendant` points at the right row. */
14
+ export declare const getMenuOptionId: (menuId: string, fieldId: string) => string;
11
15
  export declare const MentionMenu: React.FC<MentionMenuProps>;
12
16
  export {};
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.MentionMenu = void 0;
3
+ exports.MentionMenu = exports.getMenuOptionId = void 0;
4
4
  const jsx_runtime_1 = require("react/jsx-runtime");
5
5
  const react_dom_1 = require("react-dom");
6
6
  const colors_1 = require("./colors");
@@ -10,8 +10,11 @@ const MENU_COLOR_KEYS = [
10
10
  'menuTextColor',
11
11
  'menuHighlightBg',
12
12
  ];
13
- const MentionMenu = ({ fields, selectedIndex, onSelect, onHover, targetRect, colors, }) => {
14
- if (!targetRect || fields.length === 0)
13
+ /** Shared with `MentionEditor` so its `aria-activedescendant` points at the right row. */
14
+ const getMenuOptionId = (menuId, fieldId) => `${menuId}-option-${fieldId}`;
15
+ exports.getMenuOptionId = getMenuOptionId;
16
+ const MentionMenu = ({ id, fields, selectedIndex, onSelect, onHover, targetRect, colors, }) => {
17
+ if (!targetRect)
15
18
  return null;
16
19
  // Rendered into document.body via a portal and positioned with `fixed` using
17
20
  // raw viewport coordinates from getBoundingClientRect(). This is what makes
@@ -24,12 +27,12 @@ const MentionMenu = ({ fields, selectedIndex, onSelect, onHover, targetRect, col
24
27
  // `colors` has to be applied here too, not just on `.mention-editor` -- this
25
28
  // element is a portal child of `document.body`, a sibling of `.mention-editor`
26
29
  // in the real DOM, so it wouldn't inherit color overrides set there.
27
- return (0, react_dom_1.createPortal)((0, jsx_runtime_1.jsx)("div", { className: "mention-editor__menu z-9999 w-70 max-h-75 overflow-y-auto rounded-md border py-1 shadow-lg border-(--mention-editor-menu-border-color,var(--color-gray-300)) dark:border-(--mention-editor-menu-border-color,var(--color-neutral-700)) bg-(--mention-editor-menu-bg,var(--color-white)) dark:bg-(--mention-editor-menu-bg,var(--color-neutral-800))", style: {
30
+ return (0, react_dom_1.createPortal)((0, jsx_runtime_1.jsx)("div", { id: id, role: "listbox", className: "mention-editor__menu z-9999 w-70 max-h-75 overflow-y-auto rounded-md border py-1 shadow-lg border-(--mention-editor-menu-border-color,var(--color-gray-300)) dark:border-(--mention-editor-menu-border-color,var(--color-neutral-700)) bg-(--mention-editor-menu-bg,var(--color-white)) dark:bg-(--mention-editor-menu-bg,var(--color-neutral-800))", style: {
28
31
  position: 'fixed',
29
32
  top: targetRect.bottom + 8,
30
33
  left: targetRect.left,
31
34
  ...(0, colors_1.colorsToCssVars)(colors, MENU_COLOR_KEYS),
32
- }, children: fields.map((field, i) => ((0, jsx_runtime_1.jsx)("div", { className: 'mention-editor__menu-item cursor-pointer px-3 py-2' +
35
+ }, children: fields.length === 0 ? ((0, jsx_runtime_1.jsx)("div", { className: "mention-editor__menu-item cursor-default px-3 py-2 text-(--mention-editor-menu-text-color,var(--color-gray-900)) dark:text-(--mention-editor-menu-text-color,var(--color-gray-100)) opacity-60", children: "No matches" })) : (fields.map((field, i) => ((0, jsx_runtime_1.jsx)("div", { id: (0, exports.getMenuOptionId)(id, field.id), role: "option", "aria-selected": i === selectedIndex, className: 'mention-editor__menu-item cursor-pointer px-3 py-2' +
33
36
  ' text-(--mention-editor-menu-text-color,var(--color-gray-900))' +
34
37
  ' dark:text-(--mention-editor-menu-text-color,var(--color-gray-100))' +
35
38
  (i === selectedIndex
@@ -38,6 +41,6 @@ const MentionMenu = ({ fields, selectedIndex, onSelect, onHover, targetRect, col
38
41
  : ''), onClick: (e) => {
39
42
  e.preventDefault();
40
43
  onSelect(field);
41
- }, onMouseDown: (e) => e.preventDefault(), onMouseEnter: () => onHover(i), children: field.label }, field.id))) }), document.body);
44
+ }, onMouseDown: (e) => e.preventDefault(), onMouseEnter: () => onHover(i), children: field.label }, field.id)))) }), document.body);
42
45
  };
43
46
  exports.MentionMenu = MentionMenu;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { MentionEditor } from './MentionEditor';
2
- export type { MentionEditorProps } from './MentionEditor';
3
- export { serialize, deserialize, serializeToDiscordMarkup } from './serialize';
2
+ export type { MentionEditorProps, MentionEditorHandle } from './MentionEditor';
3
+ export { serialize, deserialize, serializeToDiscordMarkup, getPlainTextLength } from './serialize';
4
4
  export type { MentionFieldOption, MentionEditorColors } from './types';
5
5
  export { INITIAL_VALUE, MENTION_OPEN, MENTION_CLOSE } from './types';
package/dist/index.js CHANGED
@@ -1,12 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.MENTION_CLOSE = exports.MENTION_OPEN = exports.INITIAL_VALUE = exports.serializeToDiscordMarkup = exports.deserialize = exports.serialize = exports.MentionEditor = void 0;
3
+ exports.MENTION_CLOSE = exports.MENTION_OPEN = exports.INITIAL_VALUE = exports.getPlainTextLength = exports.serializeToDiscordMarkup = exports.deserialize = exports.serialize = exports.MentionEditor = void 0;
4
4
  var MentionEditor_1 = require("./MentionEditor");
5
5
  Object.defineProperty(exports, "MentionEditor", { enumerable: true, get: function () { return MentionEditor_1.MentionEditor; } });
6
6
  var serialize_1 = require("./serialize");
7
7
  Object.defineProperty(exports, "serialize", { enumerable: true, get: function () { return serialize_1.serialize; } });
8
8
  Object.defineProperty(exports, "deserialize", { enumerable: true, get: function () { return serialize_1.deserialize; } });
9
9
  Object.defineProperty(exports, "serializeToDiscordMarkup", { enumerable: true, get: function () { return serialize_1.serializeToDiscordMarkup; } });
10
+ Object.defineProperty(exports, "getPlainTextLength", { enumerable: true, get: function () { return serialize_1.getPlainTextLength; } });
10
11
  var types_1 = require("./types");
11
12
  Object.defineProperty(exports, "INITIAL_VALUE", { enumerable: true, get: function () { return types_1.INITIAL_VALUE; } });
12
13
  Object.defineProperty(exports, "MENTION_OPEN", { enumerable: true, get: function () { return types_1.MENTION_OPEN; } });
@@ -11,6 +11,15 @@ import { MentionFieldOption } from './types';
11
11
  export declare const serialize: (nodes: Descendant[]) => string;
12
12
  /** @deprecated use {@link serialize} */
13
13
  export declare const serializeToDiscordMarkup: (nodes: Descendant[]) => string;
14
+ /**
15
+ * Counts the *visible* length of the content -- each mention counts as
16
+ * `"@" + label.length`, not the length of its (often much longer, id-based)
17
+ * wire-format token. This is what `maxLength` enforces against, and what
18
+ * `MentionEditor`'s ref exposes via `getPlainTextLength()`: it matches what a
19
+ * user actually sees on screen, rather than penalizing them for a field with
20
+ * a long internal id.
21
+ */
22
+ export declare const getPlainTextLength: (nodes: Descendant[]) => number;
14
23
  /**
15
24
  * Parses a wire format string back into a Slate `Descendant[]` value, resolving
16
25
  * each `<@id>` token against `fields` to recover its display label. Tokens whose
package/dist/serialize.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.deserialize = exports.serializeToDiscordMarkup = exports.serialize = void 0;
3
+ exports.deserialize = exports.getPlainTextLength = exports.serializeToDiscordMarkup = exports.serialize = void 0;
4
4
  const slate_1 = require("slate");
5
5
  const types_1 = require("./types");
6
6
  const isMentionElement = (node) => !slate_1.Text.isText(node) && node.type === 'mention';
@@ -30,6 +30,31 @@ const serializeInline = (node) => {
30
30
  };
31
31
  /** @deprecated use {@link serialize} */
32
32
  exports.serializeToDiscordMarkup = exports.serialize;
33
+ /**
34
+ * Counts the *visible* length of the content -- each mention counts as
35
+ * `"@" + label.length`, not the length of its (often much longer, id-based)
36
+ * wire-format token. This is what `maxLength` enforces against, and what
37
+ * `MentionEditor`'s ref exposes via `getPlainTextLength()`: it matches what a
38
+ * user actually sees on screen, rather than penalizing them for a field with
39
+ * a long internal id.
40
+ */
41
+ const getPlainTextLength = (nodes) => {
42
+ return nodes.reduce((total, node, i) => {
43
+ const paragraphBreak = i > 0 ? 1 : 0; // mirrors the '\n' serialize() joins paragraphs with
44
+ return total + paragraphBreak + paragraphPlainTextLength(node);
45
+ }, 0);
46
+ };
47
+ exports.getPlainTextLength = getPlainTextLength;
48
+ const paragraphPlainTextLength = (node) => {
49
+ if (slate_1.Text.isText(node) || !('children' in node))
50
+ return 0;
51
+ return node.children.reduce((total, child) => total + inlinePlainTextLength(child), 0);
52
+ };
53
+ const inlinePlainTextLength = (node) => {
54
+ if (isMentionElement(node))
55
+ return 1 + node.field.label.length; // "@" + label
56
+ return node.text.length;
57
+ };
33
58
  /**
34
59
  * Parses a wire format string back into a Slate `Descendant[]` value, resolving
35
60
  * each `<@id>` token against `fields` to recover its display label. Tokens whose
package/llms.txt CHANGED
@@ -39,27 +39,33 @@ function Example() {
39
39
  | `value` | `string` | yes | Wire-format content; component is fully controlled. |
40
40
  | `fields` | `{ id: string; label: string }[]` | yes | Candidates for the `@` menu; already filtered/localized by the caller — no built-in fetching. |
41
41
  | `onChange` | `(value: string) => void` | no | Fires on every edit with the new wire string. |
42
+ | `onFocus` / `onBlur` | `(event: React.FocusEvent) => void` | no | Fires on the editable surface gaining/losing focus. |
43
+ | `onKeyDown` | `(event: React.KeyboardEvent) => void` | no | Called after the suggestion menu's own key handling. |
42
44
  | `dir` | `'ltr' \| 'rtl'` | no | Sets the real `dir` attribute on the editable surface; needed for correct RTL caret/bidi behavior. |
43
45
  | `disabled` | `boolean` | no, default `false` | Read-only + dimmed. |
44
46
  | `isError` | `boolean` | no, default `false` | Red border only; doesn't block input. |
45
47
  | `placeholder` | `string` | no | Shows only when the doc is exactly one empty paragraph. |
46
48
  | `rows` | `number` | no | `min-height = rows * 1.5em`, textarea-rows-equivalent; grows taller if content wraps further. |
49
+ | `maxLength` | `number` | no | Caps the *visible* length (mention = `"@" + label.length`, not its id); blocks growth past the limit but never blocks shrinking, even if content is already over. |
47
50
  | `className` | `string` | no | Appended to the root container's class list. |
48
51
  | `colors` | `MentionEditorColors` | no | Per-instance color overrides; see Theme colors below. Omitted keys keep the default. |
49
52
 
53
+ `MentionEditor` forwards a `ref`: `{ focus(): void; blur(): void; getPlainTextLength(): number }` (type: `MentionEditorHandle`). No value getter on it -- the component is controlled, read `value`/`onChange` instead.
54
+
50
55
  ## Other exports
51
56
 
52
- `MentionEditorProps` (type), `MentionFieldOption` (type), `MentionEditorColors` (type), `INITIAL_VALUE` (empty-doc Slate value), `MENTION_OPEN` / `MENTION_CLOSE` (the `'<@'` / `'>'` delimiter constants), `serializeToDiscordMarkup` (deprecated alias of `serialize`).
57
+ `MentionEditorProps` (type), `MentionFieldOption` (type), `MentionEditorColors` (type), `MentionEditorHandle` (type), `getPlainTextLength(nodes: Descendant[]): number` (function; the same count `maxLength`/the ref use), `INITIAL_VALUE` (empty-doc Slate value), `MENTION_OPEN` / `MENTION_CLOSE` (the `'<@'` / `'>'` delimiter constants), `serializeToDiscordMarkup` (deprecated alias of `serialize`).
53
58
 
54
59
  ## Behavior that is fixed, not configurable via props
55
60
 
56
61
  - Trigger is always `@`; only fires at line-start or after whitespace (never mid-word, e.g. `user@`).
57
- - Suggestion menu shows top 10 case-insensitive substring matches on `label`.
62
+ - Suggestion menu shows top 10 case-insensitive substring matches on `label`; zero matches keeps the menu open with a "No matches" row rather than closing it.
58
63
  - Menu keys: `↑`/`↓` navigate, `Tab`/`Enter` select, `Escape` dismiss.
59
64
  - Undo/redo (`Ctrl/Cmd+Z`) works via `slate-history`.
60
65
  - A mention is atomic: one Backspace immediately after it deletes the whole node, never a partial one.
61
66
  - Mentions get `unicode-bidi: isolate` so an embedded Latin/digit label can't flip surrounding RTL paragraph reordering.
62
67
  - Built-in `dark:` styling follows OS `prefers-color-scheme`, not a manually toggled `.dark` class (e.g. `next-themes`) — override the `.mention-editor*` classes directly if you need class-driven dark mode.
68
+ - The suggestion menu has ARIA listbox/option roles, and the editable carries `aria-haspopup`/`aria-expanded`/`aria-controls`/`aria-activedescendant`.
63
69
 
64
70
  ## Styling hook classes (all normal, single-class specificity — a later rule wins)
65
71
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itsammarb/mention-editor",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "description": "A Discord-style @mention rich-text editor built on Slate.js",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",