@tensor_1/react 2.39.0 → 2.40.0

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
@@ -103,6 +103,23 @@ hidden input), ARIA props, `variant` (`field` / `composer`, which renders in `Co
103
103
  work in every preset; the toolbar is one tab stop with arrow-key navigation and scrolls sideways on narrow screens.
104
104
  `RichTextEditorFallback` takes the same props and renders a plain textarea, for loading and error-boundary states.
105
105
 
106
+ **Mentions (`onMentionSearch`).** Pass `onMentionSearch(query)` and typing `@` opens a "People" list of the
107
+ candidates it answers (`{ id, label, description? }`, at most 20, ids of letters, digits and `_ . -` only). The
108
+ product owns the query and who may be mentioned; UIx never fetches. Arrow keys move, Enter or Tab picks, Escape closes
109
+ and keeps the typed text; while the answer is pending, empty or failed the list is a status line
110
+ (`mentionSearching`, `mentionEmpty`, `mentionFailed`), never an empty listbox. A pick becomes one token (selected and
111
+ deleted as a unit) drawn with `.uix-mention`, stored as `@[Name](mention:<id>)` with `[`, `]` and `\` in the name
112
+ escaped. The token is part of the schema in every editor, so stored mentions survive load → edit → save byte-exact; it
113
+ is never a link, so `isSafeUrl` neither sees nor drops it. `Markdown` draws the same token and takes
114
+ `resolveMention(id, storedName)` for a current or masked name. `findMentions(markdown)`, `formatMention`,
115
+ `readMention` and `isMentionId` (from `@tensor_1/react/markdown` and `/rich-text`) read and write the form on a
116
+ server; code spans and blocks hold no mentions.
117
+
118
+ ```tsx
119
+ <RichTextEditor value={md} onChange={setMd} onMentionSearch={(q) => api.people.search(q)} labels={{ mentionSuggestions: t('people') }} />
120
+ <Markdown resolveMention={(id, stored) => nameOf(id) ?? stored}>{md}</Markdown>
121
+ ```
122
+
106
123
  **No network access.** The editor, picker and reaction bar make no requests. `@tiptap/extension-emoji` is configured
107
124
  with a list stripped of its CDN `fallbackImage` URLs and GitHub image emoji (`forceFallbackImages: false`), and the
108
125
  emoji dataset is a bundled dynamic-import chunk. A jsdom egress test guards this.
@@ -180,6 +197,7 @@ Radial mode still shortens long labels to fit its ring, but every node carries i
180
197
  ### Conversations, save feedback and kept tabs
181
198
 
182
199
  - **`Comment variant="system"`** marks a product event, such as a status change, apart from people. It has no speech bubble and a `systemLabel` byline (default "System").
200
+ - **`Comment avatar={false}`** drops the avatar column (`.uix-comment--bare`), for a comment on a rail that already marks each row, such as a system event in a timeline.
183
201
  - **`Comment replyTo`** quotes the answered message above the body. Its label comes from `replyToLabel` (default "In reply to"). Mentions in a body use the `.uix-mention` class.
184
202
  - **`SaveStatus`** shows `idle`, `saving`, `saved` or `failed` for inline edits and autosave. It offers `onRetry` when a save fails, and every string comes from `labels`. The status text sits in a polite live region that stays mounted while idle, so keep one `SaveStatus` mounted and change its `state`. Mounting it only when a save starts means the first announcement is lost.
185
203
  - **`TabPanel keepMounted`** keeps an inactive panel in the DOM, `hidden` and out of the tab order, so switching back keeps its state and data. It is off by default.
@@ -131,7 +131,11 @@ declare function partialPeriod(lastX: ChartX, options: {
131
131
  * or at the start of a run of nulls). `badSide` defaults to `above` (`floor` thresholds: `below`).
132
132
  */
133
133
  declare function findCrossings(values: readonly (number | null)[], threshold: Pick<ChartThreshold, 'value' | 'badSide' | 'kind'>): number[];
134
- /** A ringed point at each first crossing, labelled "Crossed <x>" above it. */
134
+ /**
135
+ * A ringed point at each first crossing. Only the latest carries a visible "Crossed <x>" label (it is
136
+ * the one a headline's "since" names); earlier crossings show theirs on hover, so a series that
137
+ * crosses repeatedly never stacks colliding labels along the line.
138
+ */
135
139
  declare function crossingPoints(values: readonly (number | null)[], threshold: ChartThreshold, options: {
136
140
  x: readonly ChartX[];
137
141
  tokens: UixChartTokens;
@@ -221,22 +221,29 @@ function findCrossings(values, threshold) {
221
221
  }
222
222
  function crossingPoints(values, threshold, options) {
223
223
  const labels = { ...DEFAULT_ANALYTIC_LABELS, ...options.labels };
224
- const data = findCrossings(values, threshold).map((index) => ({
225
- name: labels.crossed(labels.formatX(options.x[index])),
226
- coord: [options.x[index], values[index]],
227
- symbol: "circle",
228
- symbolSize: 10,
229
- itemStyle: { color: options.tokens.surface, borderColor: options.tokens.text, borderWidth: 2 },
230
- label: {
231
- show: true,
232
- position: "top",
233
- distance: 8,
234
- formatter: labels.crossed(labels.formatX(options.x[index])),
235
- color: options.tokens.text,
236
- fontFamily: options.tokens.fontSans,
237
- fontSize: 11
238
- }
239
- }));
224
+ const crossings = findCrossings(values, threshold);
225
+ const label = (text, show) => ({
226
+ show,
227
+ position: "top",
228
+ distance: 8,
229
+ formatter: text,
230
+ color: options.tokens.text,
231
+ fontFamily: options.tokens.fontSans,
232
+ fontSize: 11
233
+ });
234
+ const data = crossings.map((index, i) => {
235
+ const text = labels.crossed(labels.formatX(options.x[index]));
236
+ const latest = i === crossings.length - 1;
237
+ return {
238
+ name: text,
239
+ coord: [options.x[index], values[index]],
240
+ symbol: "circle",
241
+ symbolSize: 10,
242
+ itemStyle: { color: options.tokens.surface, borderColor: options.tokens.text, borderWidth: 2 },
243
+ label: label(text, latest),
244
+ ...latest ? {} : { emphasis: { label: label(text, true) } }
245
+ };
246
+ });
240
247
  return data.length ? { markPoint: { animation: false, data } } : {};
241
248
  }
242
249
  export {
@@ -599,22 +599,29 @@ function findCrossings(values, threshold) {
599
599
  }
600
600
  function crossingPoints(values, threshold, options) {
601
601
  const labels = { ...DEFAULT_ANALYTIC_LABELS, ...options.labels };
602
- const data = findCrossings(values, threshold).map((index) => ({
603
- name: labels.crossed(labels.formatX(options.x[index])),
604
- coord: [options.x[index], values[index]],
605
- symbol: "circle",
606
- symbolSize: 10,
607
- itemStyle: { color: options.tokens.surface, borderColor: options.tokens.text, borderWidth: 2 },
608
- label: {
609
- show: true,
610
- position: "top",
611
- distance: 8,
612
- formatter: labels.crossed(labels.formatX(options.x[index])),
613
- color: options.tokens.text,
614
- fontFamily: options.tokens.fontSans,
615
- fontSize: 11
616
- }
617
- }));
602
+ const crossings = findCrossings(values, threshold);
603
+ const label = (text, show) => ({
604
+ show,
605
+ position: "top",
606
+ distance: 8,
607
+ formatter: text,
608
+ color: options.tokens.text,
609
+ fontFamily: options.tokens.fontSans,
610
+ fontSize: 11
611
+ });
612
+ const data = crossings.map((index, i) => {
613
+ const text = labels.crossed(labels.formatX(options.x[index]));
614
+ const latest = i === crossings.length - 1;
615
+ return {
616
+ name: text,
617
+ coord: [options.x[index], values[index]],
618
+ symbol: "circle",
619
+ symbolSize: 10,
620
+ itemStyle: { color: options.tokens.surface, borderColor: options.tokens.text, borderWidth: 2 },
621
+ label: label(text, latest),
622
+ ...latest ? {} : { emphasis: { label: label(text, true) } }
623
+ };
624
+ });
618
625
  return data.length ? { markPoint: { animation: false, data } } : {};
619
626
  }
620
627
 
@@ -136,7 +136,11 @@ declare function partialPeriod(lastX: ChartX, options: {
136
136
  * or at the start of a run of nulls). `badSide` defaults to `above` (`floor` thresholds: `below`).
137
137
  */
138
138
  declare function findCrossings(values: readonly (number | null)[], threshold: Pick<ChartThreshold, 'value' | 'badSide' | 'kind'>): number[];
139
- /** A ringed point at each first crossing, labelled "Crossed <x>" above it. */
139
+ /**
140
+ * A ringed point at each first crossing. Only the latest carries a visible "Crossed <x>" label (it is
141
+ * the one a headline's "since" names); earlier crossings show theirs on hover, so a series that
142
+ * crosses repeatedly never stacks colliding labels along the line.
143
+ */
140
144
  declare function crossingPoints(values: readonly (number | null)[], threshold: ChartThreshold, options: {
141
145
  x: readonly ChartX[];
142
146
  tokens: UixChartTokens;
@@ -11,7 +11,10 @@ interface CommentsProps extends HTMLAttributes<HTMLDivElement> {
11
11
  */
12
12
  declare function Comments({ children, className, ...props }: CommentsProps): react.JSX.Element;
13
13
  interface CommentProps extends HTMLAttributes<HTMLDivElement> {
14
- /** Usually an `<Avatar />` (the grid reserves the leading column). */
14
+ /**
15
+ * Usually an `<Avatar />` (the grid reserves the leading column). `false` drops the
16
+ * column, for a comment that sits on a rail which already marks each row (a timeline).
17
+ */
15
18
  avatar?: ReactNode;
16
19
  author?: ReactNode;
17
20
  meta?: ReactNode;
@@ -17,14 +17,15 @@ function Comment({
17
17
  }) {
18
18
  const system = variant === "system";
19
19
  const byline = author ?? (system ? systemLabel : void 0);
20
+ const bare = avatar === false;
20
21
  return /* @__PURE__ */ jsxs(
21
22
  "div",
22
23
  {
23
- className: cx("uix-comment", system && "uix-comment--system", className),
24
+ className: cx("uix-comment", system && "uix-comment--system", bare && "uix-comment--bare", className),
24
25
  "data-variant": system ? "system" : void 0,
25
26
  ...props,
26
27
  children: [
27
- avatar ?? /* @__PURE__ */ jsx("span", { "aria-hidden": "true" }),
28
+ bare ? null : avatar ?? /* @__PURE__ */ jsx("span", { "aria-hidden": "true" }),
28
29
  /* @__PURE__ */ jsxs("div", { className: "uix-comment__body", children: [
29
30
  (byline != null || meta != null) && /* @__PURE__ */ jsxs("div", { className: "uix-comment__meta", children: [
30
31
  byline != null && /* @__PURE__ */ jsx("span", { className: "uix-comment__author", children: byline }),
@@ -19,6 +19,12 @@ interface MarkdownProps {
19
19
  * (`#` → `<h3>` by default, as in TENSOR's original renderer). Capped at `<h6>`.
20
20
  */
21
21
  headingOffset?: number;
22
+ /**
23
+ * The name to show for a mention (`@[Name](mention:<id>)`), e.g. the person's current
24
+ * or masked name. Return `undefined` to show the name stored in the text. Without it
25
+ * every mention shows its stored name.
26
+ */
27
+ resolveMention?: (id: string, storedLabel: string) => string | undefined;
22
28
  }
23
29
  /**
24
30
  * Safe markdown viewer over `.uix-prose`. It never produces an HTML string and never
@@ -28,9 +34,10 @@ interface MarkdownProps {
28
34
  * Supports headings, paragraphs (single newline = line break), bold, italic,
29
35
  * strikethrough, inline and fenced code, links and bare http(s) URLs, blockquotes,
30
36
  * horizontal rules, nested bulleted/numbered lists, read-only task lists, GFM tables
31
- * (in a keyboard-scrollable wrapper) and images gated by `resolveImageSrc`.
37
+ * (in a keyboard-scrollable wrapper), images gated by `resolveImageSrc` and mentions
38
+ * (`@[Name](mention:<id>)`, drawn as `.uix-mention`, named by `resolveMention`).
32
39
  * Server-component safe.
33
40
  */
34
- declare function Markdown({ children, className, isSafeUrl, resolveImageSrc, headingOffset }: MarkdownProps): react.JSX.Element | null;
41
+ declare function Markdown({ children, className, isSafeUrl, resolveImageSrc, headingOffset, resolveMention, }: MarkdownProps): react.JSX.Element | null;
35
42
 
36
43
  export { Markdown, type MarkdownProps, defaultMarkdownIsSafeUrl };
@@ -33,6 +33,13 @@ function renderInlines(nodes, policy) {
33
33
  i
34
34
  );
35
35
  }
36
+ case "mention": {
37
+ const name = policy.resolveMention?.(node.id, node.label) ?? node.label;
38
+ return /* @__PURE__ */ jsxs("span", { className: "uix-mention", "data-mention-id": node.id, children: [
39
+ "@",
40
+ name
41
+ ] }, i);
42
+ }
36
43
  case "image": {
37
44
  const resolved = policy.resolveImageSrc?.(node.src) ?? null;
38
45
  if (resolved) {
@@ -91,10 +98,17 @@ function renderBlock(block, policy, headingOffset, key) {
91
98
  );
92
99
  }
93
100
  }
94
- function Markdown({ children, className, isSafeUrl = defaultMarkdownIsSafeUrl, resolveImageSrc, headingOffset = 2 }) {
101
+ function Markdown({
102
+ children,
103
+ className,
104
+ isSafeUrl = defaultMarkdownIsSafeUrl,
105
+ resolveImageSrc,
106
+ headingOffset = 2,
107
+ resolveMention
108
+ }) {
95
109
  const source = typeof children === "string" ? children : "";
96
110
  if (source.trim() === "") return null;
97
- const policy = { isSafeUrl, resolveImageSrc };
111
+ const policy = { isSafeUrl, resolveImageSrc, resolveMention };
98
112
  const blocks = parseMarkdown(source);
99
113
  return /* @__PURE__ */ jsx("div", { className: cx("uix-prose", "uix-markdown", className), "data-component": "markdown", children: blocks.map((block, i) => renderBlock(block, policy, headingOffset, i)) });
100
114
  }
@@ -2,7 +2,9 @@ import * as react from 'react';
2
2
  import { ReactNode } from 'react';
3
3
  import { EmojiItem } from '@tiptap/extension-emoji';
4
4
  import { EmojiLocale } from '../emoji-model.js';
5
+ import { MentionCandidate } from '../rich-text/mention.js';
5
6
  import { EmojiPickerLabels } from './EmojiPicker.js';
7
+ import '@tiptap/core';
6
8
  import '../overlay-position.js';
7
9
 
8
10
  type RichTextFeatures = 'full' | 'comment' | 'template';
@@ -44,6 +46,14 @@ interface RichTextLabels {
44
46
  emoji: string;
45
47
  /** Accessible name of the `:shortcode` suggestion list. */
46
48
  emojiSuggestions: string;
49
+ /** Accessible name of the `@` people list. */
50
+ mentionSuggestions: string;
51
+ /** Shown while `onMentionSearch` has not answered. */
52
+ mentionSearching: string;
53
+ /** Shown when `onMentionSearch` found nobody. */
54
+ mentionEmpty: string;
55
+ /** Shown when `onMentionSearch` failed. */
56
+ mentionFailed: string;
47
57
  undo: string;
48
58
  redo: string;
49
59
  /** Toggle to markdown source. */
@@ -86,6 +96,13 @@ interface RichTextEditorProps {
86
96
  onSubmitShortcut?: () => void;
87
97
  /** Emoji button and `:shortcode` suggestions. Default `true`. */
88
98
  emoji?: boolean;
99
+ /**
100
+ * People for the `@` list: called with the text typed after `@` (possibly empty) and
101
+ * answering the candidates to offer. The product owns the query and who may be
102
+ * mentioned; UIx never fetches. A picked person is inserted as one token, stored as
103
+ * `@[Name](mention:<id>)`. Without it, typing `@` does nothing special.
104
+ */
105
+ onMentionSearch?: (query: string) => Promise<ReadonlyArray<MentionCandidate>> | ReadonlyArray<MentionCandidate>;
89
106
  /**
90
107
  * Same-origin folder of fallback emoji PNGs for devices without a colour emoji font
91
108
  * (e.g. `/static/emoji`). Emoji the device cannot draw show as
@@ -24,6 +24,7 @@ import {
24
24
  readMarkdown,
25
25
  writeMarkdown
26
26
  } from "../rich-text/pipeline.js";
27
+ import { isMentionId } from "../markdown-model.js";
27
28
  import { Composer, ComposerBar } from "./Composer.js";
28
29
  import { EditorIcon } from "./EditorIcons.js";
29
30
  import { EmojiPicker } from "./EmojiPicker.js";
@@ -62,6 +63,10 @@ const DEFAULT_RICH_TEXT_LABELS = {
62
63
  imagesUnsupported: "Images can't be added here.",
63
64
  emoji: "Emoji",
64
65
  emojiSuggestions: "Emoji suggestions",
66
+ mentionSuggestions: "People",
67
+ mentionSearching: "Searching\u2026",
68
+ mentionEmpty: "No matching people",
69
+ mentionFailed: "People could not be loaded.",
65
70
  undo: "Undo",
66
71
  redo: "Redo",
67
72
  sourceMode: "Show markdown",
@@ -92,6 +97,8 @@ function suggestEmoji(query, limit = 8) {
92
97
  }
93
98
  return [...starts, ...tagged].slice(0, limit);
94
99
  }
100
+ const mentionSuggestionKey = new PluginKey("uixMentionSuggestion");
101
+ const usableCandidates = (items) => items.filter((c) => isMentionId(c.id) && c.label.trim() !== "").slice(0, 20);
95
102
  const htmlAttr = (value) => value === void 0 || value === false || value === "" ? void 0 : String(value);
96
103
  const popoverOpen = (el) => {
97
104
  try {
@@ -190,8 +197,10 @@ function RichTextEditor(props) {
190
197
  className,
191
198
  onBlur,
192
199
  emojiImageBaseUrl,
193
- imagesUnavailableReason
200
+ imagesUnavailableReason,
201
+ onMentionSearch
194
202
  } = props;
203
+ const mentionsOn = typeof onMentionSearch === "function";
195
204
  const emojiBase = useMemo(() => normalizeEmojiImageBaseUrl(emojiImageBaseUrl), [emojiImageBaseUrl]);
196
205
  const renderEmoji = useEmojiRenderer(emojiImageBaseUrl);
197
206
  const labels = useMemo(() => ({ ...DEFAULT_RICH_TEXT_LABELS, ...labelOverrides }), [labelOverrides]);
@@ -199,10 +208,11 @@ function RichTextEditor(props) {
199
208
  const counterId = `${uid}-counter`;
200
209
  const statusId = `${uid}-status`;
201
210
  const suggestId = `${uid}-emoji`;
211
+ const mentionId = `${uid}-people`;
202
212
  const linkPopoverId = `${uid}-link`;
203
213
  const controls = PRESETS[features];
204
214
  const has = (control) => controls.has(control) && (control !== "emoji" || emoji) && (control !== "image" || !!onUploadImage);
205
- const latest = useRef({ onChange, onSubmitShortcut, onBlur, isSafeUrl, resolveImageSrc, onUploadImage, placeholder, imagesOn: false, imagesOffText: "" });
215
+ const latest = useRef({ onChange, onSubmitShortcut, onBlur, isSafeUrl, resolveImageSrc, onUploadImage, onMentionSearch, placeholder, imagesOn: false, imagesOffText: "" });
206
216
  latest.current = {
207
217
  onChange,
208
218
  onSubmitShortcut,
@@ -210,6 +220,7 @@ function RichTextEditor(props) {
210
220
  isSafeUrl,
211
221
  resolveImageSrc,
212
222
  onUploadImage,
223
+ onMentionSearch,
213
224
  placeholder,
214
225
  imagesOn: controls.has("image") && !!onUploadImage,
215
226
  imagesOffText: imagesUnavailableReason?.trim() || labels.imagesUnsupported
@@ -264,6 +275,59 @@ function RichTextEditor(props) {
264
275
  return false;
265
276
  }
266
277
  };
278
+ const [mention, setMention] = useState(null);
279
+ const [mentionIndex, setMentionIndex] = useState(0);
280
+ const mentionState = useRef({ mention, mentionIndex });
281
+ mentionState.current = { mention, mentionIndex };
282
+ const mentionSeq = useRef(0);
283
+ const mentionBridge = useRef({ update: () => {
284
+ }, keyDown: () => false });
285
+ mentionBridge.current = {
286
+ update: (p) => {
287
+ const seq = ++mentionSeq.current;
288
+ const search = latest.current.onMentionSearch;
289
+ if (!p || !search) {
290
+ setMention(null);
291
+ return;
292
+ }
293
+ const rect = p.clientRect?.() ?? null;
294
+ const base = { query: p.query, command: p.command, rect };
295
+ setMention((prev) => ({ ...base, status: "loading", items: prev?.query && p.query.startsWith(prev.query) ? prev.items : [] }));
296
+ setMentionIndex(0);
297
+ Promise.resolve().then(() => search(p.query)).then(
298
+ (found) => {
299
+ if (seq !== mentionSeq.current) return;
300
+ setMention({ ...base, status: "ready", items: usableCandidates(found ?? []) });
301
+ },
302
+ () => {
303
+ if (seq !== mentionSeq.current) return;
304
+ setMention({ ...base, status: "error", items: [] });
305
+ }
306
+ );
307
+ },
308
+ keyDown: ({ event }) => {
309
+ const { mention: open, mentionIndex: index } = mentionState.current;
310
+ if (!open) return false;
311
+ if (event.key === "Escape") {
312
+ mentionSeq.current += 1;
313
+ setMention(null);
314
+ return true;
315
+ }
316
+ const picks = open.status === "ready" ? open.items : [];
317
+ if (picks.length === 0) return false;
318
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
319
+ const delta = event.key === "ArrowDown" ? 1 : -1;
320
+ setMentionIndex((index + delta + picks.length) % picks.length);
321
+ return true;
322
+ }
323
+ if (event.key === "Enter" || event.key === "Tab") {
324
+ const item = picks[Math.min(index, picks.length - 1)];
325
+ if (item) open.command(item);
326
+ return true;
327
+ }
328
+ return false;
329
+ }
330
+ };
267
331
  const headingKey = [...headingLevels].sort().join(",");
268
332
  const extensions = useMemo(() => {
269
333
  const image = Image.extend({
@@ -386,6 +450,30 @@ function RichTextEditor(props) {
386
450
  })];
387
451
  }
388
452
  });
453
+ const mentionSuggest = Extension.create({
454
+ name: "uixMentionSuggestion",
455
+ addProseMirrorPlugins() {
456
+ return [Suggestion({
457
+ editor: this.editor,
458
+ char: "@",
459
+ pluginKey: mentionSuggestionKey,
460
+ items: () => [],
461
+ command: ({ editor: ed, range, props: item }) => {
462
+ ed.chain().focus().insertContentAt(range, [
463
+ { type: "mention", attrs: { id: item.id, label: item.label.replace(/\s+/gu, " ").trim() } },
464
+ { type: "text", text: " " }
465
+ ]).run();
466
+ },
467
+ allow: ({ state: state2, range }) => !state2.doc.resolve(range.from).parent.type.spec.code,
468
+ render: () => ({
469
+ onStart: (p) => mentionBridge.current.update(p),
470
+ onUpdate: (p) => mentionBridge.current.update(p),
471
+ onExit: () => mentionBridge.current.update(null),
472
+ onKeyDown: (p) => mentionBridge.current.keyDown(p)
473
+ })
474
+ })];
475
+ }
476
+ });
389
477
  return [
390
478
  ...createSchemaExtensions({
391
479
  headingLevels: headingKey.split(",").map(Number),
@@ -396,9 +484,10 @@ function RichTextEditor(props) {
396
484
  Placeholder.configure({ placeholder: () => latest.current.placeholder ?? "" }),
397
485
  shortcuts,
398
486
  ...emoji ? [unicodeEmoji] : [],
399
- ...emojiBase ? [emojiImages] : []
487
+ ...emojiBase ? [emojiImages] : [],
488
+ ...mentionsOn ? [mentionSuggest] : []
400
489
  ];
401
- }, [headingKey, emoji, emojiBase]);
490
+ }, [headingKey, emoji, emojiBase, mentionsOn]);
402
491
  const managerOf = (ed) => {
403
492
  const manager = ed.markdown ?? ed.storage.markdown.manager;
404
493
  return patchManager(manager);
@@ -480,6 +569,8 @@ function RichTextEditor(props) {
480
569
  const invalid = props["aria-invalid"] || over;
481
570
  const describedBy = [props["aria-describedby"], maxLength !== void 0 ? counterId : void 0, status ? statusId : void 0].filter(Boolean).join(" ") || void 0;
482
571
  const suggestOpen = !!suggest && mode === "rich";
572
+ const mentionOpen = !suggestOpen && !!mention && mode === "rich";
573
+ const mentionActive = mentionOpen && mention?.status === "ready" && mention.items.length > 0 ? `${mentionId}-${Math.min(mentionIndex, mention.items.length - 1)}` : void 0;
483
574
  const rows = minRows ?? (variant === "composer" ? 3 : 4);
484
575
  useEffect(() => {
485
576
  if (!editor || editor.isDestroyed) return;
@@ -497,9 +588,9 @@ function RichTextEditor(props) {
497
588
  "aria-required": htmlAttr(props["aria-required"]),
498
589
  "aria-disabled": htmlAttr(disabled),
499
590
  "aria-readonly": htmlAttr(readOnly),
500
- "aria-autocomplete": emoji ? "list" : void 0,
501
- "aria-controls": suggestOpen ? suggestId : void 0,
502
- "aria-activedescendant": suggestOpen ? `${suggestId}-${suggestIndex}` : void 0,
591
+ "aria-autocomplete": emoji || mentionsOn ? "list" : void 0,
592
+ "aria-controls": suggestOpen ? suggestId : mentionOpen ? mentionId : void 0,
593
+ "aria-activedescendant": suggestOpen ? `${suggestId}-${suggestIndex}` : mentionActive,
503
594
  class: "uix-rich-text__content uix-prose",
504
595
  style: `min-height: calc(${rows} * 1lh + 2 * var(--uix-space-3))`
505
596
  }).filter(([, v]) => v !== void 0))
@@ -520,7 +611,11 @@ function RichTextEditor(props) {
520
611
  suggestOpen,
521
612
  suggestId,
522
613
  suggestIndex,
523
- rows
614
+ rows,
615
+ mentionsOn,
616
+ mentionOpen,
617
+ mentionId,
618
+ mentionActive
524
619
  ]);
525
620
  const state = useEditorState({
526
621
  editor,
@@ -874,6 +969,16 @@ function RichTextEditor(props) {
874
969
  onPick: (item) => suggest.command(item),
875
970
  renderEmoji
876
971
  }
972
+ ) : null,
973
+ mentionOpen && mention ? /* @__PURE__ */ jsx(
974
+ MentionSuggestions,
975
+ {
976
+ id: mentionId,
977
+ labels,
978
+ state: mention,
979
+ active: Math.min(mentionIndex, Math.max(mention.items.length - 1, 0)),
980
+ onPick: (item) => mention.command(item)
981
+ }
877
982
  ) : null
878
983
  ] });
879
984
  const counter = maxLength !== void 0 ? /* @__PURE__ */ jsx("span", { id: counterId, className: "uix-rich-text__counter", "data-over": over || void 0, children: formatLabel(labels.characterCount, { count: markdown.length, max: maxLength }) }) : null;
@@ -908,8 +1013,7 @@ function RichTextEditor(props) {
908
1013
  hidden
909
1014
  ] });
910
1015
  }
911
- function EmojiSuggestions({ id, label, items, query, active, rect, onPick, renderEmoji }) {
912
- const listRef = useRef(null);
1016
+ function useCaretPosition(listRef, rect, size) {
913
1017
  const [pos, setPos] = useState(null);
914
1018
  useEffect(() => {
915
1019
  const el = listRef.current;
@@ -922,7 +1026,71 @@ function EmojiSuggestions({ id, label, items, query, active, rect, onPick, rende
922
1026
  { placement: "bottom-start", offset: 4, padding: 8 }
923
1027
  );
924
1028
  setPos({ x: result.x, y: result.y });
925
- }, [rect, items.length]);
1029
+ }, [listRef, rect, size]);
1030
+ return pos;
1031
+ }
1032
+ function MentionSuggestions({ id, labels, state, active, onPick }) {
1033
+ const listRef = useRef(null);
1034
+ const items = state.status === "ready" ? state.items : [];
1035
+ const pos = useCaretPosition(listRef, state.rect, `${state.status}:${items.length}`);
1036
+ useEffect(() => {
1037
+ document.getElementById(`${id}-${active}`)?.scrollIntoView?.({ block: "nearest" });
1038
+ }, [active, id]);
1039
+ const style = { left: pos?.x ?? -9999, top: pos?.y ?? -9999 };
1040
+ if (items.length === 0) {
1041
+ const note = state.status === "loading" ? labels.mentionSearching : state.status === "error" ? labels.mentionFailed : labels.mentionEmpty;
1042
+ return /* @__PURE__ */ jsx(
1043
+ "div",
1044
+ {
1045
+ ref: listRef,
1046
+ id,
1047
+ role: "status",
1048
+ "aria-label": labels.mentionSuggestions,
1049
+ className: "uix-rich-text__suggest",
1050
+ "data-mention-list": "",
1051
+ "data-state": state.status === "ready" ? "empty" : state.status,
1052
+ style,
1053
+ children: /* @__PURE__ */ jsx("div", { className: "uix-rich-text__suggest-note", children: note })
1054
+ }
1055
+ );
1056
+ }
1057
+ return /* @__PURE__ */ jsx(
1058
+ "div",
1059
+ {
1060
+ ref: listRef,
1061
+ id,
1062
+ role: "listbox",
1063
+ "aria-label": labels.mentionSuggestions,
1064
+ "aria-busy": state.status === "loading" || void 0,
1065
+ className: "uix-rich-text__suggest",
1066
+ "data-mention-list": "",
1067
+ "data-state": "ready",
1068
+ style,
1069
+ children: items.map((item, i) => /* @__PURE__ */ jsxs(
1070
+ "div",
1071
+ {
1072
+ id: `${id}-${i}`,
1073
+ role: "option",
1074
+ "aria-selected": i === active,
1075
+ className: "uix-rich-text__suggest-item uix-rich-text__suggest-item--person",
1076
+ "data-mention-id": item.id,
1077
+ onMouseDown: (event) => {
1078
+ event.preventDefault();
1079
+ onPick(item);
1080
+ },
1081
+ children: [
1082
+ /* @__PURE__ */ jsx("span", { className: "uix-rich-text__suggest-label", children: item.label }),
1083
+ item.description ? /* @__PURE__ */ jsx("span", { className: "uix-rich-text__suggest-detail", children: item.description }) : null
1084
+ ]
1085
+ },
1086
+ item.id
1087
+ ))
1088
+ }
1089
+ );
1090
+ }
1091
+ function EmojiSuggestions({ id, label, items, query, active, rect, onPick, renderEmoji }) {
1092
+ const listRef = useRef(null);
1093
+ const pos = useCaretPosition(listRef, rect, items.length);
926
1094
  useEffect(() => {
927
1095
  document.getElementById(`${id}-${active}`)?.scrollIntoView?.({ block: "nearest" });
928
1096
  }, [active, id]);
@@ -2,12 +2,19 @@ import * as react from 'react';
2
2
  import { HTMLAttributes, ReactNode } from 'react';
3
3
 
4
4
  interface ToggleRowProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
5
+ /** The row's visible label. It is the switch's `<label>`: clicking it flips the switch, and it names the switch. */
5
6
  label: ReactNode;
7
+ /** Shown under the label and announced as the switch's description (`aria-describedby`). */
6
8
  description?: ReactNode;
7
9
  checked: boolean;
8
10
  onCheckedChange: (checked: boolean) => void;
9
11
  disabled?: boolean;
10
- accessibleLabel: string;
12
+ /**
13
+ * The switch's name when the visible label is not text (an icon, an image).
14
+ * Leave it out for a text label: the label already names the switch, and a
15
+ * repeated `aria-label` would replace it.
16
+ */
17
+ accessibleLabel?: string;
11
18
  }
12
19
  declare function ToggleRow({ label, description, checked, onCheckedChange, disabled, accessibleLabel, className, ...props }: ToggleRowProps): react.JSX.Element;
13
20
 
@@ -1,14 +1,28 @@
1
1
  "use client";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { useId } from "react";
3
4
  import { cx } from "../cx.js";
4
5
  import { Switch } from "./Switch.js";
5
6
  function ToggleRow({ label, description, checked, onCheckedChange, disabled, accessibleLabel, className, ...props }) {
7
+ const id = useId();
8
+ const switchId = `${id}-switch`;
9
+ const descriptionId = `${id}-description`;
6
10
  return /* @__PURE__ */ jsxs("div", { className: cx("uix-toggle-row", className), ...props, children: [
7
11
  /* @__PURE__ */ jsxs("div", { className: "uix-toggle-row__copy", children: [
8
- /* @__PURE__ */ jsx("div", { className: "uix-toggle-row__label", children: label }),
9
- description != null && /* @__PURE__ */ jsx("div", { className: "uix-toggle-row__description", children: description })
12
+ /* @__PURE__ */ jsx("div", { className: "uix-toggle-row__label", children: /* @__PURE__ */ jsx("label", { htmlFor: switchId, children: label }) }),
13
+ description != null && /* @__PURE__ */ jsx("div", { className: "uix-toggle-row__description", id: descriptionId, children: description })
10
14
  ] }),
11
- /* @__PURE__ */ jsx(Switch, { checked, disabled, "aria-label": accessibleLabel, onChange: (event) => onCheckedChange(event.currentTarget.checked) })
15
+ /* @__PURE__ */ jsx(
16
+ Switch,
17
+ {
18
+ id: switchId,
19
+ checked,
20
+ disabled,
21
+ ...accessibleLabel ? { "aria-label": accessibleLabel } : {},
22
+ ...description != null ? { "aria-describedby": descriptionId } : {},
23
+ onChange: (event) => onCheckedChange(event.currentTarget.checked)
24
+ }
25
+ )
12
26
  ] });
13
27
  }
14
28
  export {