@itsammarb/mention-editor 1.0.2 → 1.0.4
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 +87 -16
- package/dist/MentionEditor.js +8 -5
- package/dist/MentionMenu.js +8 -3
- package/dist/styles.css +1 -1
- package/llms.txt +77 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
# @
|
|
1
|
+
# @itsammarb/mention-editor
|
|
2
2
|
|
|
3
3
|
A Discord-style `@mention` rich-text editor built on [Slate.js](https://www.slatejs.org/). Mentions are atomic (void + inline) nodes: backspacing next to one deletes it whole, and the suggestion menu tracks the real caret position via `ReactEditor.toDOMRange`.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
|
-
npm install @
|
|
8
|
+
npm install @itsammarb/mention-editor slate slate-react slate-history
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
Peer dependencies: React 18 or 19, `slate` and `slate-react` `^0.94.0`. No Tailwind setup required on your end — see [Styling](#styling).
|
|
@@ -13,8 +13,8 @@ Peer dependencies: React 18 or 19, `slate` and `slate-react` `^0.94.0`. No Tailw
|
|
|
13
13
|
## Usage
|
|
14
14
|
|
|
15
15
|
```tsx
|
|
16
|
-
import { MentionEditor } from '@
|
|
17
|
-
import '@
|
|
16
|
+
import { MentionEditor } from '@itsammarb/mention-editor';
|
|
17
|
+
import '@itsammarb/mention-editor/styles.css';
|
|
18
18
|
|
|
19
19
|
const fields = [
|
|
20
20
|
{ id: 'a1b2c3d4-0000-0000-0000-000000000001', label: 'Landlord Name' },
|
|
@@ -49,22 +49,93 @@ Hello <@a1b2c3d4-0000-0000-0000-000000000001>, welcome.
|
|
|
49
49
|
|
|
50
50
|
## Props
|
|
51
51
|
|
|
52
|
-
| Prop | Type | Description |
|
|
52
|
+
| Prop | Type | Default | Description |
|
|
53
|
+
| --- | --- | --- | --- |
|
|
54
|
+
| `value` | `string` | *(required)* | Wire-format content (controlled — the editor always reflects this string). |
|
|
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
|
+
| `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
|
+
| `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
|
+
| `disabled` | `boolean` | `false` | Makes the editor read-only (`Editable`'s `readOnly`) and dims it via `opacity-60`. |
|
|
59
|
+
| `isError` | `boolean` | `false` | Swaps the border to the invalid-state color (`border-red-500`). Purely visual — doesn't block typing. |
|
|
60
|
+
| `placeholder` | `string` | `undefined` (no placeholder) | Shown only when the document is fully empty (a single empty paragraph) — see [Behavior notes](#behavior-notes). |
|
|
61
|
+
| `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. |
|
|
62
|
+
| `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
|
+
|
|
64
|
+
## Exported utilities
|
|
65
|
+
|
|
66
|
+
Everything importable from `@itsammarb/mention-editor`:
|
|
67
|
+
|
|
68
|
+
| Export | Kind | Description |
|
|
53
69
|
| --- | --- | --- |
|
|
54
|
-
| `
|
|
55
|
-
| `
|
|
56
|
-
| `
|
|
57
|
-
| `
|
|
58
|
-
| `
|
|
59
|
-
| `
|
|
60
|
-
| `
|
|
61
|
-
| `
|
|
62
|
-
| `className` | `string` | Extra class name(s) on the root container. |
|
|
70
|
+
| `MentionEditor` | component | The editor itself; see [Props](#props). |
|
|
71
|
+
| `MentionEditorProps` | type | Prop types for `MentionEditor`. |
|
|
72
|
+
| `MentionFieldOption` | type | Shape of an entry in `fields`: `{ id: string; label: string }`. |
|
|
73
|
+
| `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
|
+
| `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
|
+
| `serializeToDiscordMarkup` | function | Deprecated alias of `serialize`, kept for backwards compatibility with the pre-rename API. Prefer `serialize`. |
|
|
76
|
+
| `INITIAL_VALUE` | constant | The empty-document Slate value (`[{ type: 'paragraph', children: [{ text: '' }] }]`) `MentionEditor` uses internally when `value` is `''`. Useful if you're building your own Slate tooling around this package's types. |
|
|
77
|
+
| `MENTION_OPEN` / `MENTION_CLOSE` | constants | The literal delimiters (`'<@'` / `'>'`) used by the wire format — not configurable, but exported so you can detect/strip mention tokens from raw wire strings yourself without duplicating the regex. |
|
|
63
78
|
|
|
64
79
|
## Styling
|
|
65
80
|
|
|
66
81
|
The default look is authored with Tailwind utility classes internally, but compiled at *this package's* build time into a self-contained `dist/styles.css` — import it once (as shown above) and it works with zero Tailwind setup on your end, whether or not your app uses Tailwind at all. The compiled CSS deliberately excludes Tailwind's Preflight (base element reset), so it can't leak out and restyle your app's own `<h1>`, `<button>`, etc.
|
|
67
82
|
|
|
68
|
-
|
|
83
|
+
Every rendered part carries a stable class name, at normal specificity (plain single-class selectors, so a later rule you write always wins):
|
|
84
|
+
|
|
85
|
+
| Class | Element | Notes |
|
|
86
|
+
| --- | --- | --- |
|
|
87
|
+
| `.mention-editor` | root `<div>` | Border, rounding, background; gets `className` appended and `opacity-60` when `disabled`. |
|
|
88
|
+
| `.mention-editor__editable` | the `Editable` surface | Padding, text size/color, `min-height`. |
|
|
89
|
+
| `.mention-editor__paragraph` | each `<p>` block | Margin between paragraphs. |
|
|
90
|
+
| `.mention-editor__mention` | a mention `<span>` | Color, underline, `unicode-bidi: isolate` (see [Behavior notes](#behavior-notes)). |
|
|
91
|
+
| `.mention-editor__menu` | suggestion menu `<div>` | Portaled to `document.body`; border, shadow, sizing. |
|
|
92
|
+
| `.mention-editor__menu-item` | each menu row `<div>` | Also gets a highlight class when it's the keyboard-selected row. |
|
|
93
|
+
|
|
94
|
+
Override any of these by targeting the class directly in your own CSS, or via `className` on the root for layout/border/background changes.
|
|
95
|
+
|
|
96
|
+
### Theme colors
|
|
97
|
+
|
|
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.
|
|
99
|
+
|
|
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
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
| Variable | Controls | Light default | Dark default |
|
|
111
|
+
| --- | --- | --- | --- |
|
|
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 |
|
|
123
|
+
|
|
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.
|
|
125
|
+
|
|
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.
|
|
127
|
+
|
|
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.
|
|
129
|
+
|
|
130
|
+
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
|
+
|
|
132
|
+
## Behavior notes
|
|
133
|
+
|
|
134
|
+
Things that are fixed (not currently exposed as props), so you know what to expect rather than hunt for a setting:
|
|
69
135
|
|
|
70
|
-
|
|
136
|
+
- The trigger character is always `@`, and it only fires at the start of a line or after whitespace — typing `user@` mid-word never opens the menu.
|
|
137
|
+
- The suggestion menu shows at most the first 10 matches of `fields`, filtered by a case-insensitive substring match against `label`. There's no built-in async/debounced search — `fields` is expected to already be the candidate list.
|
|
138
|
+
- Keyboard handling in the menu is fixed: `↑`/`↓` to move, `Tab` or `Enter` to select, `Escape` to dismiss.
|
|
139
|
+
- Undo/redo (`Ctrl/Cmd+Z`, `Ctrl/Cmd+Shift+Z`) works out of the box via `slate-history`.
|
|
140
|
+
- A mention is atomic: backspacing immediately after one deletes it whole in a single keystroke, never leaving a partial node.
|
|
141
|
+
- `placeholder` only renders when the document is *exactly* one empty paragraph — it disappears the moment any character (or a mention) is present, and won't reappear just because the visible text looks empty (e.g. a paragraph containing only a mention still counts as non-empty).
|
package/dist/MentionEditor.js
CHANGED
|
@@ -138,20 +138,23 @@ function MentionEditor(props) {
|
|
|
138
138
|
}
|
|
139
139
|
}, [target, index, filteredFields, selectField]);
|
|
140
140
|
const rootClassName = [
|
|
141
|
-
'mention-editor relative rounded-md border
|
|
142
|
-
|
|
141
|
+
'mention-editor relative rounded-md border',
|
|
142
|
+
'bg-(--mention-editor-bg,var(--color-white)) dark:bg-(--mention-editor-bg,var(--color-neutral-900))',
|
|
143
|
+
isError
|
|
144
|
+
? 'border-(--mention-editor-border-color-error,var(--color-red-500))'
|
|
145
|
+
: 'border-(--mention-editor-border-color,var(--color-gray-300)) dark:border-(--mention-editor-border-color,var(--color-neutral-700))',
|
|
143
146
|
disabled && 'opacity-60',
|
|
144
147
|
className,
|
|
145
148
|
]
|
|
146
149
|
.filter(Boolean)
|
|
147
150
|
.join(' ');
|
|
148
151
|
const editableStyle = rows ? { minHeight: `${rows * 1.5}em` } : undefined;
|
|
149
|
-
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 text-gray-900
|
|
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));
|
|
150
153
|
}
|
|
151
154
|
// slate-react's default placeholder renders at a fixed dim-black opacity,
|
|
152
155
|
// which reads poorly in dark mode. Overriding it lets the placeholder use
|
|
153
156
|
// real (theme-aware) Tailwind colors at full opacity instead.
|
|
154
|
-
const renderPlaceholder = ({ attributes, children }) => ((0, jsx_runtime_1.jsx)("span", { ...attributes, style: { ...attributes.style, opacity: 1 }, className: "text-gray-400 dark:text-neutral-500", children: children }));
|
|
157
|
+
const renderPlaceholder = ({ attributes, children }) => ((0, jsx_runtime_1.jsx)("span", { ...attributes, style: { ...attributes.style, opacity: 1 }, className: "text-(--mention-editor-placeholder-color,var(--color-gray-400)) dark:text-(--mention-editor-placeholder-color,var(--color-neutral-500))", children: children }));
|
|
155
158
|
// --- TRIGGER DETECTION ---
|
|
156
159
|
const MAX_MENTION_SEARCH_LENGTH = 50;
|
|
157
160
|
/**
|
|
@@ -236,7 +239,7 @@ const Element = ({ attributes, children, element }) => {
|
|
|
236
239
|
var _a;
|
|
237
240
|
switch (element.type) {
|
|
238
241
|
case 'mention':
|
|
239
|
-
return ((0, jsx_runtime_1.jsxs)("span", { ...attributes, contentEditable: false, className: "mention-editor__mention cursor-default
|
|
242
|
+
return ((0, jsx_runtime_1.jsxs)("span", { ...attributes, contentEditable: false, className: "mention-editor__mention cursor-default underline decoration-1 underline-offset-2 [unicode-bidi:isolate] text-(--mention-editor-mention-color,var(--color-blue-600)) dark:text-(--mention-editor-mention-color,var(--color-blue-400)) bg-(--mention-editor-mention-bg,transparent)", children: ["@", (_a = element.field) === null || _a === void 0 ? void 0 : _a.label, children] }));
|
|
240
243
|
default:
|
|
241
244
|
return ((0, jsx_runtime_1.jsx)("p", { ...attributes, className: "mention-editor__paragraph m-0 [&+&]:mt-1", children: children }));
|
|
242
245
|
}
|
package/dist/MentionMenu.js
CHANGED
|
@@ -13,12 +13,17 @@ const MentionMenu = ({ fields, selectedIndex, onSelect, onHover, targetRect, })
|
|
|
13
13
|
// another modal: portaling to <body> means there's no transformed/positioned
|
|
14
14
|
// ancestor between the menu and the viewport to throw off `fixed` coordinates.
|
|
15
15
|
// 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-
|
|
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: {
|
|
17
17
|
position: 'fixed',
|
|
18
18
|
top: targetRect.bottom + 8,
|
|
19
19
|
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
|
|
21
|
-
|
|
20
|
+
}, children: fields.map((field, i) => ((0, jsx_runtime_1.jsx)("div", { className: 'mention-editor__menu-item cursor-pointer px-3 py-2' +
|
|
21
|
+
' text-(--mention-editor-menu-text-color,var(--color-gray-900))' +
|
|
22
|
+
' dark:text-(--mention-editor-menu-text-color,var(--color-gray-100))' +
|
|
23
|
+
(i === selectedIndex
|
|
24
|
+
? ' bg-(--mention-editor-menu-highlight-bg,var(--color-indigo-50))' +
|
|
25
|
+
' dark:bg-(--mention-editor-menu-highlight-bg,var(--color-neutral-700))'
|
|
26
|
+
: ''), onClick: (e) => {
|
|
22
27
|
e.preventDefault();
|
|
23
28
|
onSelect(field);
|
|
24
29
|
}, onMouseDown: (e) => e.preventDefault(), onMouseEnter: () => onHover(i), children: field.label }, field.id))) }), document.body);
|
package/dist/styles.css
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/*! tailwindcss v4.3.2 | MIT License | https://tailwindcss.com */
|
|
2
|
-
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-leading:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}:root,:host{--color-red-500:oklch(63.7% .237 25.331);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-600:oklch(54.6% .245 262.881);--color-indigo-50:oklch(96.2% .018 272.314);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-900:oklch(21% .034 264.665);--color-neutral-500:oklch(55.6% 0 0);--color-neutral-700:oklch(37.1% 0 0);--color-neutral-800:oklch(26.9% 0 0);--color-neutral-900:oklch(20.5% 0 0);--color-white:#fff;--spacing:.25rem;--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--radius-md:.375rem}.visible{visibility:visible}.fixed{position:fixed}.relative{position:relative}.z
|
|
2
|
+
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-leading:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}:root,:host{--color-red-500:oklch(63.7% .237 25.331);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-600:oklch(54.6% .245 262.881);--color-indigo-50:oklch(96.2% .018 272.314);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-900:oklch(21% .034 264.665);--color-neutral-500:oklch(55.6% 0 0);--color-neutral-700:oklch(37.1% 0 0);--color-neutral-800:oklch(26.9% 0 0);--color-neutral-900:oklch(20.5% 0 0);--color-white:#fff;--spacing:.25rem;--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--radius-md:.375rem}.visible{visibility:visible}.fixed{position:fixed}.relative{position:relative}.isolate{isolation:isolate}.z-9999{z-index:9999}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.m-0{margin:0}.block{display:block}.contents{display:contents}.inline{display:inline}.max-h-75{max-height:calc(var(--spacing) * 75)}.min-h-11{min-height:calc(var(--spacing) * 11)}.w-70{width:calc(var(--spacing) * 70)}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.resize{resize:both}.overflow-y-auto{overflow-y:auto}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-\(--mention-editor-border-color\,var\(--color-gray-300\)\){border-color:var(--mention-editor-border-color,var(--color-gray-300))}.border-\(--mention-editor-border-color-error\,var\(--color-red-500\)\){border-color:var(--mention-editor-border-color-error,var(--color-red-500))}.border-\(--mention-editor-menu-border-color\,var\(--color-gray-300\)\){border-color:var(--mention-editor-menu-border-color,var(--color-gray-300))}.border-red-500{border-color:var(--color-red-500)}.bg-\(--mention-editor-bg\,var\(--color-white\)\){background-color:var(--mention-editor-bg,var(--color-white))}.bg-\(--mention-editor-mention-bg\,transparent\){background-color:var(--mention-editor-mention-bg,transparent)}.bg-\(--mention-editor-menu-bg\,var\(--color-white\)\){background-color:var(--mention-editor-menu-bg,var(--color-white))}.bg-\(--mention-editor-menu-highlight-bg\,var\(--color-indigo-50\)\){background-color:var(--mention-editor-menu-highlight-bg,var(--color-indigo-50))}.px-3{padding-inline:calc(var(--spacing) * 3)}.py-1{padding-block:var(--spacing)}.py-2{padding-block:calc(var(--spacing) * 2)}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.leading-6{--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6)}.text-\(--mention-editor-mention-color\,var\(--color-blue-600\)\){color:var(--mention-editor-mention-color,var(--color-blue-600))}.text-\(--mention-editor-menu-text-color\,var\(--color-gray-900\)\){color:var(--mention-editor-menu-text-color,var(--color-gray-900))}.text-\(--mention-editor-placeholder-color\,var\(--color-gray-400\)\){color:var(--mention-editor-placeholder-color,var(--color-gray-400))}.text-\(--mention-editor-text-color\,var\(--color-gray-900\)\){color:var(--mention-editor-text-color,var(--color-gray-900))}.underline{text-decoration-line:underline}.decoration-1{text-decoration-thickness:1px}.underline-offset-2{text-underline-offset:2px}.opacity-60{opacity:.6}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.outline-none{--tw-outline-style:none;outline-style:none}.\[unicode-bidi\:isolate\]{unicode-bidi:isolate}@media (prefers-color-scheme:dark){.dark\:border-\(--mention-editor-border-color\,var\(--color-neutral-700\)\){border-color:var(--mention-editor-border-color,var(--color-neutral-700))}.dark\:border-\(--mention-editor-menu-border-color\,var\(--color-neutral-700\)\){border-color:var(--mention-editor-menu-border-color,var(--color-neutral-700))}.dark\:bg-\(--mention-editor-bg\,var\(--color-neutral-900\)\){background-color:var(--mention-editor-bg,var(--color-neutral-900))}.dark\:bg-\(--mention-editor-menu-bg\,var\(--color-neutral-800\)\){background-color:var(--mention-editor-menu-bg,var(--color-neutral-800))}.dark\:bg-\(--mention-editor-menu-highlight-bg\,var\(--color-neutral-700\)\){background-color:var(--mention-editor-menu-highlight-bg,var(--color-neutral-700))}.dark\:text-\(--mention-editor-mention-color\,var\(--color-blue-400\)\){color:var(--mention-editor-mention-color,var(--color-blue-400))}.dark\:text-\(--mention-editor-menu-text-color\,var\(--color-gray-100\)\){color:var(--mention-editor-menu-text-color,var(--color-gray-100))}.dark\:text-\(--mention-editor-placeholder-color\,var\(--color-neutral-500\)\){color:var(--mention-editor-placeholder-color,var(--color-neutral-500))}.dark\:text-\(--mention-editor-text-color\,var\(--color-gray-100\)\){color:var(--mention-editor-text-color,var(--color-gray-100))}}.\[\&\+\&\]\:mt-1+.\[\&\+\&\]\:mt-1{margin-top:var(--spacing)}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}
|
package/llms.txt
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# @itsammarb/mention-editor
|
|
2
|
+
|
|
3
|
+
> A Discord-style `@mention` rich-text editor for React, built on Slate.js. Renders once, in a real contenteditable surface (no mirrored textarea/div overlay), so it doesn't suffer the cursor-drift bugs that affect that approach with mixed RTL/LTR text.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @itsammarb/mention-editor slate slate-react slate-history
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Peer deps: React `^18.0.0 || ^19.0.0`, `slate` and `slate-react` `^0.94.0`. No Tailwind config needed on the consumer side — styling ships pre-compiled.
|
|
12
|
+
|
|
13
|
+
## Minimal usage
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
import { MentionEditor } from '@itsammarb/mention-editor';
|
|
17
|
+
import '@itsammarb/mention-editor/styles.css'; // required once, anywhere in the app
|
|
18
|
+
|
|
19
|
+
const fields = [{ id: 'a1b2c3d4-0000-0000-0000-000000000001', label: 'Landlord Name' }];
|
|
20
|
+
|
|
21
|
+
function Example() {
|
|
22
|
+
const [value, setValue] = useState('Hello <@a1b2c3d4-0000-0000-0000-000000000001>, welcome.');
|
|
23
|
+
return <MentionEditor value={value} fields={fields} onChange={setValue} />;
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Wire format (the `value`/`onChange` string, NOT a Slate document)
|
|
28
|
+
|
|
29
|
+
- A mention is written as `<@` + the field's `id` + `>`, e.g. `<@a1b2c3d4-...>`.
|
|
30
|
+
- `\n` in the string is a paragraph break.
|
|
31
|
+
- Any id shape works (GUID, numeric, nanoid, ...) — the closing `>` disambiguates, no escaping/config needed.
|
|
32
|
+
- `serialize(nodes: Descendant[]): string` and `deserialize(value: string, fields?: MentionFieldOption[]): Descendant[]` (both exported) convert between this string and Slate's internal tree, if you need to do that outside the component.
|
|
33
|
+
- An id not present in the `fields` passed to `deserialize` still renders — falls back to showing the raw id as the label instead of dropping the mention.
|
|
34
|
+
|
|
35
|
+
## Props (all on `MentionEditor`)
|
|
36
|
+
|
|
37
|
+
| Prop | Type | Required | Notes |
|
|
38
|
+
| --- | --- | --- | --- |
|
|
39
|
+
| `value` | `string` | yes | Wire-format content; component is fully controlled. |
|
|
40
|
+
| `fields` | `{ id: string; label: string }[]` | yes | Candidates for the `@` menu; already filtered/localized by the caller — no built-in fetching. |
|
|
41
|
+
| `onChange` | `(value: string) => void` | no | Fires on every edit with the new wire string. |
|
|
42
|
+
| `dir` | `'ltr' \| 'rtl'` | no | Sets the real `dir` attribute on the editable surface; needed for correct RTL caret/bidi behavior. |
|
|
43
|
+
| `disabled` | `boolean` | no, default `false` | Read-only + dimmed. |
|
|
44
|
+
| `isError` | `boolean` | no, default `false` | Red border only; doesn't block input. |
|
|
45
|
+
| `placeholder` | `string` | no | Shows only when the doc is exactly one empty paragraph. |
|
|
46
|
+
| `rows` | `number` | no | `min-height = rows * 1.5em`, textarea-rows-equivalent; grows taller if content wraps further. |
|
|
47
|
+
| `className` | `string` | no | Appended to the root container's class list. |
|
|
48
|
+
|
|
49
|
+
## Other exports
|
|
50
|
+
|
|
51
|
+
`MentionEditorProps` (type), `MentionFieldOption` (type), `INITIAL_VALUE` (empty-doc Slate value), `MENTION_OPEN` / `MENTION_CLOSE` (the `'<@'` / `'>'` delimiter constants), `serializeToDiscordMarkup` (deprecated alias of `serialize`).
|
|
52
|
+
|
|
53
|
+
## Behavior that is fixed, not configurable via props
|
|
54
|
+
|
|
55
|
+
- 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`.
|
|
57
|
+
- Menu keys: `↑`/`↓` navigate, `Tab`/`Enter` select, `Escape` dismiss.
|
|
58
|
+
- Undo/redo (`Ctrl/Cmd+Z`) works via `slate-history`.
|
|
59
|
+
- A mention is atomic: one Backspace immediately after it deletes the whole node, never a partial one.
|
|
60
|
+
- Mentions get `unicode-bidi: isolate` so an embedded Latin/digit label can't flip surrounding RTL paragraph reordering.
|
|
61
|
+
- 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.
|
|
62
|
+
|
|
63
|
+
## Styling hook classes (all normal, single-class specificity — a later rule wins)
|
|
64
|
+
|
|
65
|
+
`.mention-editor` (root), `.mention-editor__editable`, `.mention-editor__paragraph`, `.mention-editor__mention`, `.mention-editor__menu`, `.mention-editor__menu-item`.
|
|
66
|
+
|
|
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:
|
|
70
|
+
|
|
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`.
|
|
72
|
+
|
|
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.
|
|
74
|
+
|
|
75
|
+
## Full docs
|
|
76
|
+
|
|
77
|
+
See `README.md` in this package for the complete prop/export/styling reference and more detail on each point above.
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@itsammarb/mention-editor",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "A Discord-style @mention rich-text editor built on Slate.js",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
8
8
|
"files": [
|
|
9
|
-
"dist"
|
|
9
|
+
"dist",
|
|
10
|
+
"llms.txt"
|
|
10
11
|
],
|
|
11
12
|
"exports": {
|
|
12
13
|
".": {
|