@itsammarb/mention-editor 1.0.4 → 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,12 +54,18 @@ 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. |
66
+ | `colors` | `MentionEditorColors` | `undefined` (built-in defaults) | Per-instance color overrides — see [Theme colors](#theme-colors). Omitted keys keep their default. |
67
+
68
+ `MentionEditor` also forwards a `ref` — see [Imperative handle](#imperative-handle).
63
69
 
64
70
  ## Exported utilities
65
71
 
@@ -70,6 +76,9 @@ Everything importable from `@itsammarb/mention-editor`:
70
76
  | `MentionEditor` | component | The editor itself; see [Props](#props). |
71
77
  | `MentionEditorProps` | type | Prop types for `MentionEditor`. |
72
78
  | `MentionFieldOption` | type | Shape of an entry in `fields`: `{ id: string; label: string }`. |
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. |
73
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). |
74
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. |
75
84
  | `serializeToDiscordMarkup` | function | Deprecated alias of `serialize`, kept for backwards compatibility with the pre-rename API. Prefer `serialize`. |
@@ -95,40 +104,77 @@ Override any of these by targeting the class directly in your own CSS, or via `c
95
104
 
96
105
  ### Theme colors
97
106
 
98
- Every color is also a CSS custom property, so you can retheme the whole component with a handful of variable declarations instead of overriding individual classes. Set them anywhere that's an ancestor of the whole page — `:root`, `html`, `body`, or a global stylesheet rule — **not** scoped to `.mention-editor` itself: the suggestion menu is rendered through a React portal directly into `document.body`, so it sits *outside* `.mention-editor` in the real DOM and wouldn't inherit a variable scoped there.
107
+ Pass a `colors` prop to override any of the built-in colors, per instance no external CSS needed:
99
108
 
100
- ```css
101
- /* anywhere in your global CSS, loaded in any order relative to this package's styles.css */
102
- :root {
103
- --mention-editor-mention-color: #16a34a; /* green mention text/underline */
104
- --mention-editor-mention-bg: #dcfce7; /* optional pill-style highlight behind a mention */
105
- --mention-editor-border-color: #db2777;
106
- --mention-editor-menu-highlight-bg: #fde68a; /* selected/hovered suggestion row */
107
- }
109
+ ```tsx
110
+ <MentionEditor
111
+ value={value}
112
+ fields={fields}
113
+ onChange={setValue}
114
+ colors={{
115
+ mentionColor: '#16a34a', // green mention text/underline
116
+ mentionBg: '#dcfce7', // optional pill-style highlight behind a mention
117
+ borderColor: '#db2777',
118
+ menuHighlightBg: '#fde68a', // selected/hovered suggestion row
119
+ }}
120
+ />
108
121
  ```
109
122
 
110
- | Variable | Controls | Light default | Dark default |
123
+ Omitted keys keep their built-in default, and different instances on the same page can have entirely different `colors` without affecting each other — including each instance's own suggestion menu, even though the menu renders through a React portal into `document.body` (a sibling of the editor in the real DOM, not a descendant): `MentionEditor` forwards `colors` to it directly rather than relying on CSS inheritance.
124
+
125
+ | `colors` key | Controls | Light default | Dark default |
111
126
  | --- | --- | --- | --- |
112
- | `--mention-editor-bg` | Editor container background | white | neutral-900 |
113
- | `--mention-editor-text-color` | Editor body text color | gray-900 | gray-100 |
114
- | `--mention-editor-border-color` | Container border (normal state) | gray-300 | neutral-700 |
115
- | `--mention-editor-border-color-error` | Container border when `isError` | red-500 | red-500 (same in both) |
116
- | `--mention-editor-placeholder-color` | Placeholder text color | gray-400 | neutral-500 |
117
- | `--mention-editor-mention-color` | A mention's text/underline color | blue-600 | blue-400 |
118
- | `--mention-editor-mention-bg` | Background behind a mention (e.g. a pill highlight) | transparent | transparent |
119
- | `--mention-editor-menu-bg` | Suggestion menu background | white | neutral-800 |
120
- | `--mention-editor-menu-border-color` | Suggestion menu border | gray-300 | neutral-700 |
121
- | `--mention-editor-menu-text-color` | Suggestion menu row text | gray-900 | gray-100 |
122
- | `--mention-editor-menu-highlight-bg` | Selected/hovered suggestion row background | indigo-50 | neutral-700 |
127
+ | `bg` | Editor container background | white | neutral-900 |
128
+ | `textColor` | Editor body text color | gray-900 | gray-100 |
129
+ | `borderColor` | Container border (normal state) | gray-300 | neutral-700 |
130
+ | `borderColorError` | Container border when `isError` | red-500 | red-500 (same in both) |
131
+ | `placeholderColor` | Placeholder text color | gray-400 | neutral-500 |
132
+ | `mentionColor` | A mention's text/underline color | blue-600 | blue-400 |
133
+ | `mentionBg` | Background behind a mention (e.g. a pill highlight) | transparent | transparent |
134
+ | `menuBg` | Suggestion menu background | white | neutral-800 |
135
+ | `menuBorderColor` | Suggestion menu border | gray-300 | neutral-700 |
136
+ | `menuTextColor` | Suggestion menu row text | gray-900 | gray-100 |
137
+ | `menuHighlightBg` | Selected/hovered suggestion row background | indigo-50 | neutral-700 |
123
138
 
124
- Setting a variable applies it in both light and dark mode (the fallback is what differs by scheme, not the override) — if you want genuinely different override colors per scheme, redeclare the variable yourself inside your own `@media (prefers-color-scheme: dark)` block.
139
+ A `colors` value applies in both light and dark mode (the fallback is what differs by scheme, not your override) — if you want genuinely different colors per scheme, read `window.matchMedia('(prefers-color-scheme: dark)')` (or your app's own dark-mode state) and pass a different `colors` object accordingly.
125
140
 
126
- These defaults aren't asserted anywhere as a real `:root` rule in this package's stylesheet they're inline `var(--x, fallback)` fallbacks on each utility class instead. That's deliberate: a real default declaration and a consumer override would both be equal-specificity `:root` rules, so whichever stylesheet happened to load *last* would silently win regardless of intent. Reading the variable with an inline fallback means there's only ever one real assignment (yours, if you set one), so load order can't matter.
141
+ **Dark mode**: the built-in dark defaults (used when `colors` doesn't set a given key) respond to the OS/browser's `prefers-color-scheme: dark`, *not* a manually-toggled `.dark` class (e.g. from `next-themes` or a similar library). If your app drives dark mode via a class rather than OS preference, either pass `colors` explicitly (bypassing the scheme-based default entirely), or drive it from your own dark-mode state as described above.
127
142
 
128
- **Dark mode**: the built-in dark defaults respond to the OS/browser's `prefers-color-scheme: dark`, *not* a manually-toggled `.dark` class (e.g. from `next-themes` or a similar library). If your app drives dark mode via a class rather than OS preference, set the variables above directly (they'll then apply regardless of scheme), or scope your own overrides under your app's dark-mode class selector.
143
+ **Advanced — global CSS override**: under the hood, `colors` works by setting CSS custom properties (`--mention-editor-*`) inline. You can also set these directly in your own global CSS (`:root { --mention-editor-mention-color: ...; }`) as a page-wide fallback for instances that don't pass `colors` at all an inline `colors` value always takes precedence over a global CSS one. Global CSS declarations must be scoped to `:root`/`html`/`body` (not `.mention-editor`) for the same portal reason as above.
129
144
 
130
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.
131
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
+
132
178
  ## Behavior notes
133
179
 
134
180
  Things that are fixed (not currently exposed as props), so you know what to expect rather than hunt for a setting:
@@ -1,17 +1,35 @@
1
1
  import React from 'react';
2
- import { MentionFieldOption } from './types';
2
+ import { MentionFieldOption, MentionEditorColors } from './types';
3
3
  export interface MentionEditorProps {
4
4
  /** Wire-format string, e.g. `"Hello <@a1b2c3d4-...>, pay rent."` */
5
5
  value: string;
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;
26
+ /** Per-instance color overrides; omitted keys keep the built-in default. */
27
+ colors?: MentionEditorColors;
16
28
  }
17
- 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,32 +1,89 @@
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");
9
42
  const types_1 = require("./types");
10
43
  const serialize_1 = require("./serialize");
44
+ const colors_1 = require("./colors");
11
45
  const MentionMenu_1 = require("./MentionMenu");
12
- function MentionEditor(props) {
13
- const { value, fields, onChange, dir, disabled = false, isError = false, placeholder, rows, className, } = props;
46
+ const EDITOR_COLOR_KEYS = [
47
+ 'bg',
48
+ 'textColor',
49
+ 'borderColor',
50
+ 'borderColorError',
51
+ 'placeholderColor',
52
+ 'mentionColor',
53
+ 'mentionBg',
54
+ ];
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;
14
61
  // `generation` forces a full remount of the Slate editor instance whenever the
15
62
  // `value` prop changes for a reason *other* than our own onChange echoing back
16
63
  // down (e.g. the consumer swaps to editing a different record). See the
17
64
  // render-time reset block below for the echo-vs-external distinction.
18
65
  const [generation, setGeneration] = (0, react_1.useState)(0);
19
- 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]);
20
67
  const lastEmittedRef = (0, react_1.useRef)(value);
21
68
  const [committedValue, setCommittedValue] = (0, react_1.useState)(value);
22
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));
23
73
  const [target, setTarget] = (0, react_1.useState)(null);
24
74
  const [index, setIndex] = (0, react_1.useState)(0);
25
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]);
26
81
  if (value !== committedValue) {
27
82
  setCommittedValue(value);
28
83
  if (value !== lastEmittedRef.current) {
29
- 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);
30
87
  setGeneration((g) => g + 1);
31
88
  setTarget(null);
32
89
  setSearch('');
@@ -77,6 +134,7 @@ function MentionEditor(props) {
77
134
  }, [target, editor, slateValue]);
78
135
  // --- CORE LOGIC: Trigger Detection ---
79
136
  const handleChange = (0, react_1.useCallback)((newValue) => {
137
+ plainLengthRef.current = (0, serialize_1.getPlainTextLength)(newValue);
80
138
  setSlateValue(newValue);
81
139
  const serialized = (0, serialize_1.serialize)(newValue);
82
140
  if (serialized !== lastEmittedRef.current) {
@@ -97,6 +155,16 @@ function MentionEditor(props) {
97
155
  // --- CORE LOGIC: Insertion ---
98
156
  const selectField = (0, react_1.useCallback)((selectedField) => {
99
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
+ }
100
168
  slate_1.Transforms.select(editor, target);
101
169
  slate_1.Transforms.delete(editor);
102
170
  slate_1.Transforms.insertNodes(editor, {
@@ -108,7 +176,7 @@ function MentionEditor(props) {
108
176
  slate_1.Transforms.move(editor, { distance: 1, unit: 'offset' });
109
177
  setTarget(null);
110
178
  }
111
- }, [editor, target]);
179
+ }, [editor, target, maxLength, search]);
112
180
  // --- KEYBOARD NAVIGATION ---
113
181
  const handleKeyDown = (0, react_1.useCallback)((event) => {
114
182
  if (target) {
@@ -127,8 +195,10 @@ function MentionEditor(props) {
127
195
  }
128
196
  case 'Tab':
129
197
  case 'Enter':
130
- event.preventDefault();
131
- selectField(filteredFields[index]);
198
+ if (filteredFields.length > 0) {
199
+ event.preventDefault();
200
+ selectField(filteredFields[index]);
201
+ }
132
202
  break;
133
203
  case 'Escape':
134
204
  event.preventDefault();
@@ -136,7 +206,9 @@ function MentionEditor(props) {
136
206
  break;
137
207
  }
138
208
  }
139
- }, [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)();
140
212
  const rootClassName = [
141
213
  'mention-editor relative rounded-md border',
142
214
  'bg-(--mention-editor-bg,var(--color-white)) dark:bg-(--mention-editor-bg,var(--color-neutral-900))',
@@ -149,8 +221,11 @@ function MentionEditor(props) {
149
221
  .filter(Boolean)
150
222
  .join(' ');
151
223
  const editableStyle = rows ? { minHeight: `${rows * 1.5}em` } : undefined;
152
- 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, 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 }))] }) }, generation));
153
- }
224
+ // Set on the root: cascades naturally to Editable/mention/placeholder since
225
+ // they're real DOM descendants (unlike the portaled menu -- see MentionMenu).
226
+ const rootStyle = (0, colors_1.colorsToCssVars)(colors, EDITOR_COLOR_KEYS);
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
+ });
154
229
  // slate-react's default placeholder renders at a fixed dim-black opacity,
155
230
  // which reads poorly in dark mode. Overriding it lets the placeholder use
156
231
  // real (theme-aware) Tailwind colors at full opacity instead.
@@ -234,6 +309,51 @@ const withMentions = (editor) => {
234
309
  };
235
310
  return editor;
236
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
+ };
237
357
  // Renders Slate nodes into React components
238
358
  const Element = ({ attributes, children, element }) => {
239
359
  var _a;
@@ -1,11 +1,16 @@
1
1
  import React from 'react';
2
- import { MentionFieldOption } from './types';
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;
7
9
  onHover: (index: number) => void;
8
10
  targetRect: DOMRect | null;
11
+ colors?: MentionEditorColors;
9
12
  }
13
+ /** Shared with `MentionEditor` so its `aria-activedescendant` points at the right row. */
14
+ export declare const getMenuOptionId: (menuId: string, fieldId: string) => string;
10
15
  export declare const MentionMenu: React.FC<MentionMenuProps>;
11
16
  export {};
@@ -1,10 +1,20 @@
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
- const MentionMenu = ({ fields, selectedIndex, onSelect, onHover, targetRect, }) => {
7
- if (!targetRect || fields.length === 0)
6
+ const colors_1 = require("./colors");
7
+ const MENU_COLOR_KEYS = [
8
+ 'menuBg',
9
+ 'menuBorderColor',
10
+ 'menuTextColor',
11
+ 'menuHighlightBg',
12
+ ];
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)
8
18
  return null;
9
19
  // Rendered into document.body via a portal and positioned with `fixed` using
10
20
  // raw viewport coordinates from getBoundingClientRect(). This is what makes
@@ -13,11 +23,16 @@ const MentionMenu = ({ fields, selectedIndex, onSelect, onHover, targetRect, })
13
23
  // another modal: portaling to <body> means there's no transformed/positioned
14
24
  // ancestor between the menu and the viewport to throw off `fixed` coordinates.
15
25
  // The caller (MentionEditor) keeps `targetRect` fresh across scroll/resize.
16
- 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: {
26
+ //
27
+ // `colors` has to be applied here too, not just on `.mention-editor` -- this
28
+ // element is a portal child of `document.body`, a sibling of `.mention-editor`
29
+ // in the real DOM, so it wouldn't inherit color overrides set there.
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: {
17
31
  position: 'fixed',
18
32
  top: targetRect.bottom + 8,
19
33
  left: targetRect.left,
20
- }, children: fields.map((field, i) => ((0, jsx_runtime_1.jsx)("div", { className: 'mention-editor__menu-item cursor-pointer px-3 py-2' +
34
+ ...(0, colors_1.colorsToCssVars)(colors, MENU_COLOR_KEYS),
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' +
21
36
  ' text-(--mention-editor-menu-text-color,var(--color-gray-900))' +
22
37
  ' dark:text-(--mention-editor-menu-text-color,var(--color-gray-100))' +
23
38
  (i === selectedIndex
@@ -26,6 +41,6 @@ const MentionMenu = ({ fields, selectedIndex, onSelect, onHover, targetRect, })
26
41
  : ''), onClick: (e) => {
27
42
  e.preventDefault();
28
43
  onSelect(field);
29
- }, 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);
30
45
  };
31
46
  exports.MentionMenu = MentionMenu;
@@ -0,0 +1,9 @@
1
+ import type { CSSProperties } from 'react';
2
+ import { MentionEditorColors } from './types';
3
+ /**
4
+ * Builds an inline `style` object setting only the CSS custom properties for
5
+ * the given keys that are actually present in `colors` -- omitted keys are
6
+ * left unset entirely, so the built-in fallback in the compiled CSS still
7
+ * applies for them.
8
+ */
9
+ export declare const colorsToCssVars: (colors: MentionEditorColors | undefined, keys: (keyof MentionEditorColors)[]) => CSSProperties;
package/dist/colors.js ADDED
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.colorsToCssVars = void 0;
4
+ // Maps each `colors` prop key to the CSS custom property the compiled
5
+ // Tailwind classes read via `var(--x, <built-in fallback>)`. Setting the
6
+ // property inline (rather than requiring a consumer to write global CSS)
7
+ // wins over the fallback automatically -- and, being inline, wins regardless
8
+ // of stylesheet load order, since there's no competing external rule to race.
9
+ const COLOR_VARS = {
10
+ bg: '--mention-editor-bg',
11
+ textColor: '--mention-editor-text-color',
12
+ borderColor: '--mention-editor-border-color',
13
+ borderColorError: '--mention-editor-border-color-error',
14
+ placeholderColor: '--mention-editor-placeholder-color',
15
+ mentionColor: '--mention-editor-mention-color',
16
+ mentionBg: '--mention-editor-mention-bg',
17
+ menuBg: '--mention-editor-menu-bg',
18
+ menuBorderColor: '--mention-editor-menu-border-color',
19
+ menuTextColor: '--mention-editor-menu-text-color',
20
+ menuHighlightBg: '--mention-editor-menu-highlight-bg',
21
+ };
22
+ /**
23
+ * Builds an inline `style` object setting only the CSS custom properties for
24
+ * the given keys that are actually present in `colors` -- omitted keys are
25
+ * left unset entirely, so the built-in fallback in the compiled CSS still
26
+ * applies for them.
27
+ */
28
+ const colorsToCssVars = (colors, keys) => {
29
+ if (!colors)
30
+ return {};
31
+ const style = {};
32
+ for (const key of keys) {
33
+ const value = colors[key];
34
+ if (value)
35
+ style[COLOR_VARS[key]] = value;
36
+ }
37
+ return style;
38
+ };
39
+ exports.colorsToCssVars = colorsToCssVars;
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';
4
- export type { MentionFieldOption } from './types';
2
+ export type { MentionEditorProps, MentionEditorHandle } from './MentionEditor';
3
+ export { serialize, deserialize, serializeToDiscordMarkup, getPlainTextLength } from './serialize';
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/dist/types.d.ts CHANGED
@@ -27,3 +27,16 @@ declare module 'slate' {
27
27
  export declare const INITIAL_VALUE: Descendant[];
28
28
  export declare const MENTION_OPEN = "<@";
29
29
  export declare const MENTION_CLOSE = ">";
30
+ export interface MentionEditorColors {
31
+ bg?: string;
32
+ textColor?: string;
33
+ borderColor?: string;
34
+ borderColorError?: string;
35
+ placeholderColor?: string;
36
+ mentionColor?: string;
37
+ mentionBg?: string;
38
+ menuBg?: string;
39
+ menuBorderColor?: string;
40
+ menuTextColor?: string;
41
+ menuHighlightBg?: string;
42
+ }
package/llms.txt CHANGED
@@ -39,38 +39,43 @@ 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. |
51
+ | `colors` | `MentionEditorColors` | no | Per-instance color overrides; see Theme colors below. Omitted keys keep the default. |
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.
48
54
 
49
55
  ## Other exports
50
56
 
51
- `MentionEditorProps` (type), `MentionFieldOption` (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`).
52
58
 
53
59
  ## Behavior that is fixed, not configurable via props
54
60
 
55
61
  - Trigger is always `@`; only fires at line-start or after whitespace (never mid-word, e.g. `user@`).
56
- - 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.
57
63
  - Menu keys: `↑`/`↓` navigate, `Tab`/`Enter` select, `Escape` dismiss.
58
64
  - Undo/redo (`Ctrl/Cmd+Z`) works via `slate-history`.
59
65
  - A mention is atomic: one Backspace immediately after it deletes the whole node, never a partial one.
60
66
  - Mentions get `unicode-bidi: isolate` so an embedded Latin/digit label can't flip surrounding RTL paragraph reordering.
61
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`.
62
69
 
63
70
  ## Styling hook classes (all normal, single-class specificity — a later rule wins)
64
71
 
65
72
  `.mention-editor` (root), `.mention-editor__editable`, `.mention-editor__paragraph`, `.mention-editor__mention`, `.mention-editor__menu`, `.mention-editor__menu-item`.
66
73
 
67
- ## Theme colors (CSS custom properties)
68
-
69
- Set these at `:root`/`html`/`body` scope in your own global CSS (not scoped to `.mention-editor` — the suggestion menu portals to `document.body`, outside it) to retheme without touching classes:
74
+ ## Theme colors
70
75
 
71
- `--mention-editor-bg`, `--mention-editor-text-color`, `--mention-editor-border-color`, `--mention-editor-border-color-error`, `--mention-editor-placeholder-color`, `--mention-editor-mention-color`, `--mention-editor-mention-bg`, `--mention-editor-menu-bg`, `--mention-editor-menu-border-color`, `--mention-editor-menu-text-color`, `--mention-editor-menu-highlight-bg`.
76
+ Use the `colors` prop, not CSS, to retheme -- e.g. `colors={{ mentionColor: '#16a34a', mentionBg: '#dcfce7', borderColor: '#db2777', menuHighlightBg: '#fde68a' }}`. Keys: `bg`, `textColor`, `borderColor`, `borderColorError`, `placeholderColor`, `mentionColor`, `mentionBg`, `menuBg`, `menuBorderColor`, `menuTextColor`, `menuHighlightBg`. Omitted keys keep the built-in default (which itself differs light vs. dark, following OS `prefers-color-scheme`). Each editor instance's `colors` is independent, and is correctly forwarded to that instance's own suggestion menu even though the menu renders through a React portal into `document.body`.
72
77
 
73
- Each has a light/dark default baked in as a `var(--x, fallback)` on the relevant utility class (no competing `:root` declaration in this package's own CSS), so setting one always wins regardless of stylesheet load order, and applies in both light and dark mode unless you scope your own override inside a `prefers-color-scheme` media query.
78
+ Advanced: `colors` works by setting `--mention-editor-*` CSS custom properties inline; you can also set these directly in your own global CSS at `:root`/`html`/`body` scope (not `.mention-editor` -- the portaled menu sits outside it) as a page-wide fallback. An inline `colors` value always wins over a global CSS one.
74
79
 
75
80
  ## Full docs
76
81
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itsammarb/mention-editor",
3
- "version": "1.0.4",
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",