@riducms/plugin-richtext 0.2.2 → 0.3.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.
Files changed (43) hide show
  1. package/README.md +7 -1
  2. package/package.json +7 -5
  3. package/src/block/rich-text-block-card.scss +139 -0
  4. package/src/block/rich-text-block-card.svelte +42 -21
  5. package/src/block/rich-text-block-clipboard.ts +1 -1
  6. package/src/block/rich-text-block-node.ts +1 -1
  7. package/src/block/rich-text-blocks-plugin.svelte +5 -2
  8. package/src/document-validation.ts +7 -5
  9. package/src/field/reference-node-placement.ts +49 -0
  10. package/src/field/rich-text-blocks.ts +1 -5
  11. package/src/field/rich-text-config.ts +1 -4
  12. package/src/field/rich-text-document.ts +1 -4
  13. package/src/field/rich-text-editor.svelte +105 -57
  14. package/src/field/rich-text-footer.svelte +7 -12
  15. package/src/field/rich-text-markdown.ts +62 -0
  16. package/src/link/link-url.ts +30 -0
  17. package/src/link/rich-text-link-plugin.svelte +528 -0
  18. package/src/link/rich-text-link.scss +144 -0
  19. package/src/link/safe-link-transform.ts +20 -0
  20. package/src/link-url-policy.ts +14 -0
  21. package/src/menu/rich-text-block-toolbar.svelte +403 -70
  22. package/src/menu/rich-text-commands.ts +10 -1
  23. package/src/menu/rich-text-menu.scss +92 -0
  24. package/src/menu/rich-text-menu.svelte +62 -69
  25. package/src/menu/rich-text-option-filter.ts +30 -0
  26. package/src/menu/rich-text-options.ts +81 -84
  27. package/src/menu/rich-text-slash-menu.svelte +15 -11
  28. package/src/messages.ts +72 -27
  29. package/src/relationship/rich-text-relationship-node.scss +98 -0
  30. package/src/relationship/rich-text-relationship-node.svelte +38 -32
  31. package/src/relationship/rich-text-relationship-plugin.svelte +24 -40
  32. package/src/render/rich-text.svelte +10 -5
  33. package/src/render.ts +3 -8
  34. package/src/styles/integration.scss +384 -0
  35. package/src/toolbar/PAYLOAD-LICENSE.md +22 -0
  36. package/src/toolbar/rich-text-floating-toolbar.svelte +331 -169
  37. package/src/toolbar/rich-text-toolbar.scss +80 -0
  38. package/src/toolbar/toolbar-icon.svelte +200 -30
  39. package/src/upload/rich-text-upload-node.scss +308 -0
  40. package/src/upload/rich-text-upload-node.svelte +96 -125
  41. package/src/upload/rich-text-upload-node.ts +1 -0
  42. package/src/upload/rich-text-upload-plugin.svelte +10 -33
  43. package/src/styles/integration.css +0 -83
@@ -9,13 +9,14 @@
9
9
  } from "@riducms/ui";
10
10
 
11
11
  import type { RichTextMenuOption } from "@plugin-richtext/menu/rich-text-options";
12
+ import ToolbarIcon from "@plugin-richtext/toolbar/toolbar-icon.svelte";
13
+ import "@plugin-richtext/menu/rich-text-menu.scss";
12
14
 
13
15
  let {
14
16
  options,
15
17
  selectedIndex = null,
16
18
  onHighlight = () => undefined,
17
19
  onSelect,
18
- embedded = false,
19
20
  command = false,
20
21
  idPrefix = "richtext-menu-item",
21
22
  }: {
@@ -23,85 +24,77 @@
23
24
  selectedIndex?: number | null;
24
25
  onHighlight?: (index: number) => void;
25
26
  onSelect: (option: RichTextMenuOption) => void;
26
- embedded?: boolean;
27
27
  command?: boolean;
28
28
  idPrefix?: string;
29
29
  } = $props();
30
30
  const i18n = getAdminI18n();
31
- const menuClasses = $derived([
32
- "ridu-richtext-menu max-h-80 w-[min(19rem,calc(100vw-2rem))] overflow-y-auto rounded-[4px] border border-control-border bg-popover p-[0.35rem] shadow-[var(--shadow-popover)] animate-[ridu-richtext-menu-in_150ms_ease] motion-reduce:animate-none",
33
- embedded && "max-h-68 w-full rounded-0 border-0 shadow-none animate-none",
34
- ]);
35
- const optionClasses =
36
- "grid min-h-10 cursor-pointer grid-cols-[1.85rem_minmax(0,1fr)_auto] items-center gap-[0.6rem] rounded-[3px] px-[0.55rem] py-[0.35rem] text-foreground-muted outline-none hover:bg-control-hover hover:text-foreground data-[selected]:bg-control-hover data-[selected]:text-foreground";
31
+ const groups = $derived(
32
+ [...new Set(options.map((option) => option.group))].map((name) => ({
33
+ name,
34
+ items: options
35
+ .map((option, index) => ({ option, index }))
36
+ .filter(({ option }) => option.group === name),
37
+ }))
38
+ );
37
39
  </script>
38
40
 
39
41
  {#snippet optionContent(option: RichTextMenuOption)}
40
- <span
41
- class="grid size-7 place-items-center rounded-[3px] border border-control-border bg-control font-mono text-[10px] text-foreground-muted"
42
- aria-hidden="true"
43
- >
44
- {option.glyph}
45
- </span>
46
- <span class="grid min-w-0 gap-[0.1rem]">
47
- <strong class="text-[13px] font-medium">{option.label}</strong>
48
- <small class="text-[11px] text-foreground-faint">{option.description}</small>
49
- </span>
50
- <span class="font-mono text-[11px] text-foreground-faint">{option.keyHint}</span>
42
+ <ToolbarIcon name={option.icon} />
43
+ <span class="ridu-richtext-menu__label">{option.label}</span>
51
44
  {/snippet}
52
45
 
53
46
  {#if command}
54
- <CommandList class={menuClasses}>
55
- <CommandGroup>
56
- <CommandGroupHeading
57
- class="px-[0.6rem] pt-[0.45rem] pb-[0.35rem] font-mono text-[10px] tracking-[0.13em] text-foreground-faint uppercase"
58
- >
59
- {i18n.t("plugin.richtext:editor.insertBlock")}
60
- </CommandGroupHeading>
61
- <CommandGroupItems>
62
- {#each options as option, index (option.key)}
63
- <CommandItem
64
- class={optionClasses}
65
- id={`${idPrefix}-${index}`}
66
- value={option.label}
67
- keywords={[...option.keywords]}
68
- onSelect={() => onSelect(option)}
69
- >
70
- {@render optionContent(option)}
71
- </CommandItem>
72
- {/each}
73
- </CommandGroupItems>
74
- </CommandGroup>
47
+ <CommandList class="ridu-richtext-menu">
48
+ {#each groups as group (group.name)}
49
+ <CommandGroup class="ridu-richtext-menu__group">
50
+ <CommandGroupHeading class="ridu-richtext-menu__heading">
51
+ {i18n.t(`plugin.richtext:menu.${group.name}`)}
52
+ </CommandGroupHeading>
53
+ <CommandGroupItems class="ridu-richtext-menu__items">
54
+ {#each group.items as { option, index } (option.key)}
55
+ <CommandItem
56
+ class="ridu-richtext-menu__option"
57
+ id={`${idPrefix}-${index}`}
58
+ value={option.key}
59
+ keywords={[option.label, ...option.keywords]}
60
+ onSelect={() => onSelect(option)}
61
+ >
62
+ {@render optionContent(option)}
63
+ </CommandItem>
64
+ {/each}
65
+ </CommandGroupItems>
66
+ </CommandGroup>
67
+ {/each}
75
68
  </CommandList>
76
69
  {:else}
77
- <div class={menuClasses} data-richtext-menu-surface>
78
- <p
79
- class="px-[0.6rem] pt-[0.45rem] pb-[0.35rem] font-mono text-[10px] tracking-[0.13em] text-foreground-faint uppercase"
80
- >
81
- {i18n.t("plugin.richtext:editor.insertBlock")}
82
- </p>
83
- <ul class="grid list-none gap-[0.1rem] p-0" role="presentation">
84
- {#each options as option, index (option.key)}
85
- <li
86
- id={`${idPrefix}-${index}`}
87
- class={[optionClasses, selectedIndex === index && "bg-control-hover text-foreground"]}
88
- role="option"
89
- aria-selected={selectedIndex === index}
90
- tabindex="-1"
91
- {@attach option.attach}
92
- onmouseenter={() => onHighlight(index)}
93
- onmousedown={(event) => event.preventDefault()}
94
- onclick={() => onSelect(option)}
95
- onkeydown={(event) => {
96
- if (event.key === "Enter" || event.key === " ") {
97
- event.preventDefault();
98
- onSelect(option);
99
- }
100
- }}
101
- >
102
- {@render optionContent(option)}
103
- </li>
104
- {/each}
105
- </ul>
70
+ <div class="ridu-richtext-menu" data-richtext-menu-surface>
71
+ {#each groups as group (group.name)}
72
+ <div class="ridu-richtext-menu__group" role="presentation">
73
+ <p class="ridu-richtext-menu__heading">{i18n.t(`plugin.richtext:menu.${group.name}`)}</p>
74
+ <ul class="ridu-richtext-menu__items" role="presentation">
75
+ {#each group.items as { option, index } (option.key)}
76
+ <li
77
+ id={`${idPrefix}-${index}`}
78
+ class={["ridu-richtext-menu__option", selectedIndex === index && "is-selected"]}
79
+ role="option"
80
+ aria-selected={selectedIndex === index}
81
+ tabindex="-1"
82
+ {@attach option.attach}
83
+ onmouseenter={() => onHighlight(index)}
84
+ onmousedown={(event) => event.preventDefault()}
85
+ onclick={() => onSelect(option)}
86
+ onkeydown={(event) => {
87
+ if (event.key === "Enter" || event.key === " ") {
88
+ event.preventDefault();
89
+ onSelect(option);
90
+ }
91
+ }}
92
+ >
93
+ {@render optionContent(option)}
94
+ </li>
95
+ {/each}
96
+ </ul>
97
+ </div>
98
+ {/each}
106
99
  </div>
107
100
  {/if}
@@ -0,0 +1,30 @@
1
+ interface FilterableRichTextOption {
2
+ label: string;
3
+ description: string;
4
+ keywords: readonly string[];
5
+ }
6
+
7
+ export function filterRichTextOptions<Option extends FilterableRichTextOption>(
8
+ options: readonly Option[],
9
+ query: string,
10
+ language?: string
11
+ ): Option[] {
12
+ const normalized = query.trim().toLocaleLowerCase(language);
13
+ if (normalized === "") return [...options];
14
+ const compact = normalized.replace(/[\s\-_]/g, "");
15
+ const matches = (value: string) => {
16
+ const lower = value.toLocaleLowerCase(language);
17
+ return (
18
+ lower.includes(normalized) ||
19
+ (compact !== "" && lower.replace(/[\s\-_]/g, "").includes(compact))
20
+ );
21
+ };
22
+ const exactLabel = (option: Option) =>
23
+ option.label.toLocaleLowerCase(language).replace(/[\s\-_]/g, "") === compact;
24
+ return options
25
+ .filter(
26
+ (option) =>
27
+ matches(option.label) || matches(option.description) || option.keywords.some(matches)
28
+ )
29
+ .sort((a, b) => Number(exactLabel(b)) - Number(exactLabel(a)));
30
+ }
@@ -1,10 +1,11 @@
1
+ import type { RichTextIconName } from "@plugin-richtext/toolbar/toolbar-icon.svelte";
1
2
  import { $createCodeNode } from "@lexical/code";
2
3
  import {
3
4
  INSERT_CHECK_LIST_COMMAND,
4
5
  INSERT_ORDERED_LIST_COMMAND,
5
6
  INSERT_UNORDERED_LIST_COMMAND,
6
7
  } from "@lexical/list";
7
- import { $createHeadingNode, $createQuoteNode } from "@lexical/rich-text";
8
+ import { $createHeadingNode, $createQuoteNode, type HeadingTagType } from "@lexical/rich-text";
8
9
  import { $setBlocksType } from "@lexical/selection";
9
10
  import type { SchemaBlockType } from "@riducms/protocol";
10
11
  import type { AdminI18n, FieldAuthoringHost } from "@riducms/plugin";
@@ -24,13 +25,15 @@ import {
24
25
  } from "@plugin-richtext/menu/rich-text-commands";
25
26
  import { hasRichTextFeature, type RichTextConfig } from "@plugin-richtext/field/rich-text-config";
26
27
 
28
+ export { filterRichTextOptions } from "@plugin-richtext/menu/rich-text-option-filter";
29
+
27
30
  export class RichTextMenuOption extends MenuOption {
28
31
  readonly label: string;
29
32
  readonly description: string;
30
- readonly glyph: string;
33
+ readonly icon: RichTextIconName;
34
+ readonly group: "lists" | "basic" | "blocks";
31
35
  readonly keywords: readonly string[];
32
36
  readonly select: () => void;
33
- readonly keyHint: string;
34
37
  readonly preservePlaceholder: boolean;
35
38
  readonly restoreEditorFocus: boolean;
36
39
  readonly blockType: string | undefined;
@@ -39,11 +42,11 @@ export class RichTextMenuOption extends MenuOption {
39
42
  key: string,
40
43
  label: string,
41
44
  description: string,
42
- glyph: string,
45
+ icon: RichTextIconName,
43
46
  keywords: readonly string[],
44
47
  select: () => void,
45
48
  options: {
46
- keyHint?: string;
49
+ group?: "lists" | "basic" | "blocks";
47
50
  preservePlaceholder?: boolean;
48
51
  restoreEditorFocus?: boolean;
49
52
  blockType?: string;
@@ -52,10 +55,10 @@ export class RichTextMenuOption extends MenuOption {
52
55
  super(key);
53
56
  this.label = label;
54
57
  this.description = description;
55
- this.glyph = glyph;
58
+ this.icon = icon;
59
+ this.group = options.group ?? "basic";
56
60
  this.keywords = keywords;
57
61
  this.select = select;
58
- this.keyHint = options.keyHint ?? "↵";
59
62
  this.preservePlaceholder = options.preservePlaceholder ?? true;
60
63
  this.restoreEditorFocus = options.restoreEditorFocus ?? true;
61
64
  this.blockType = options.blockType;
@@ -74,31 +77,26 @@ export function buildRichTextOptions(
74
77
  "paragraph",
75
78
  i18n.t("plugin.richtext:option.paragraph.label"),
76
79
  i18n.t("plugin.richtext:option.paragraph.description"),
77
- "",
80
+ "paragraph",
78
81
  keywords(i18n.t("plugin.richtext:option.paragraph.keywords")),
79
82
  () => $setBlock($createParagraphNode)
80
83
  ),
81
- new RichTextMenuOption(
82
- "heading-2",
83
- i18n.t("plugin.richtext:option.heading2.label"),
84
- i18n.t("plugin.richtext:option.heading2.description"),
85
- "h2",
86
- keywords(i18n.t("plugin.richtext:option.heading2.keywords")),
87
- () => $setBlock(() => $createHeadingNode("h2"))
88
- ),
89
- new RichTextMenuOption(
90
- "heading-3",
91
- i18n.t("plugin.richtext:option.heading3.label"),
92
- i18n.t("plugin.richtext:option.heading3.description"),
93
- "h3",
94
- keywords(i18n.t("plugin.richtext:option.heading3.keywords")),
95
- () => $setBlock(() => $createHeadingNode("h3"))
84
+ ...[1, 2, 3, 4, 5, 6].map(
85
+ (level) =>
86
+ new RichTextMenuOption(
87
+ `heading-${level}`,
88
+ i18n.t("plugin.richtext:editor.heading", { level }),
89
+ "",
90
+ `h${level}` as RichTextIconName,
91
+ ["heading", "title", `h${level}`],
92
+ () => $setBlock(() => $createHeadingNode(`h${level}` as HeadingTagType))
93
+ )
96
94
  ),
97
95
  new RichTextMenuOption(
98
96
  "quote",
99
97
  i18n.t("plugin.richtext:option.quote.label"),
100
98
  i18n.t("plugin.richtext:option.quote.description"),
101
- '"',
99
+ "quote",
102
100
  keywords(i18n.t("plugin.richtext:option.quote.keywords")),
103
101
  () => $setBlock($createQuoteNode)
104
102
  ),
@@ -110,25 +108,28 @@ export function buildRichTextOptions(
110
108
  "bulleted-list",
111
109
  i18n.t("plugin.richtext:option.bulletedList.label"),
112
110
  i18n.t("plugin.richtext:option.bulletedList.description"),
113
- "•",
111
+ "unordered",
114
112
  keywords(i18n.t("plugin.richtext:option.bulletedList.keywords")),
115
- () => editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined)
113
+ () => editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined),
114
+ { group: "lists" }
116
115
  ),
117
116
  new RichTextMenuOption(
118
117
  "numbered-list",
119
118
  i18n.t("plugin.richtext:option.numberedList.label"),
120
119
  i18n.t("plugin.richtext:option.numberedList.description"),
121
- "1.",
120
+ "ordered",
122
121
  keywords(i18n.t("plugin.richtext:option.numberedList.keywords")),
123
- () => editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined)
122
+ () => editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined),
123
+ { group: "lists" }
124
124
  ),
125
125
  new RichTextMenuOption(
126
126
  "checklist",
127
127
  i18n.t("plugin.richtext:option.checklist.label"),
128
128
  i18n.t("plugin.richtext:option.checklist.description"),
129
- "☑",
129
+ "check",
130
130
  keywords(i18n.t("plugin.richtext:option.checklist.keywords")),
131
- () => editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined)
131
+ () => editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined),
132
+ { group: "lists" }
132
133
  )
133
134
  );
134
135
  }
@@ -139,7 +140,7 @@ export function buildRichTextOptions(
139
140
  "code-block",
140
141
  i18n.t("plugin.richtext:option.codeBlock.label"),
141
142
  i18n.t("plugin.richtext:option.codeBlock.description"),
142
- "</>",
143
+ "code-block",
143
144
  keywords(i18n.t("plugin.richtext:option.codeBlock.keywords")),
144
145
  () => $setBlock($createCodeNode)
145
146
  )
@@ -152,7 +153,7 @@ export function buildRichTextOptions(
152
153
  "divider",
153
154
  i18n.t("plugin.richtext:option.divider.label"),
154
155
  i18n.t("plugin.richtext:option.divider.description"),
155
- "—",
156
+ "divider",
156
157
  keywords(i18n.t("plugin.richtext:option.divider.keywords")),
157
158
  () => editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined),
158
159
  { preservePlaceholder: false }
@@ -174,7 +175,7 @@ export function buildRichTextOptions(
174
175
  i18n.t("plugin.richtext:option.upload.description", {
175
176
  label: collection.labels.singular.toLocaleLowerCase(i18n.language),
176
177
  }),
177
- "ref",
178
+ "upload",
178
179
  [...keywords(i18n.t("plugin.richtext:option.upload.keywords")), collection.slug],
179
180
  () =>
180
181
  queueMicrotask(() =>
@@ -186,77 +187,73 @@ export function buildRichTextOptions(
186
187
  )
187
188
  );
188
189
  }
189
- options.splice(4, 0, ...uploadOptions);
190
+ options.push(...uploadOptions);
190
191
  }
191
192
 
192
- if (hasRichTextFeature(config, "relationships")) {
193
- const allowed = new Set(config.relationshipCollections);
194
- const relationshipOptions: RichTextMenuOption[] = [];
195
- for (const collection of authoring?.collections ?? []) {
196
- if (allowed.size > 0 && !allowed.has(collection.slug)) continue;
197
- relationshipOptions.push(
198
- new RichTextMenuOption(
199
- `relationship-${collection.slug}`,
200
- i18n.t("plugin.richtext:option.relationship.label", {
201
- label: collection.labels.singular,
202
- }),
203
- i18n.t("plugin.richtext:option.relationship.description", {
204
- label: collection.labels.singular.toLocaleLowerCase(i18n.language),
205
- }),
206
- "rel",
207
- [...keywords(i18n.t("plugin.richtext:option.relationship.keywords")), collection.slug],
208
- () =>
209
- queueMicrotask(() =>
210
- editor.dispatchCommand(OPEN_RELATIONSHIP_BROWSER_COMMAND, {
211
- collectionSlug: collection.slug,
212
- })
213
- ),
214
- { restoreEditorFocus: false }
215
- )
216
- );
217
- }
218
- // Upload and relationship labels can intentionally overlap (for example,
219
- // Asset and Related Asset). Preserve the established upload result first
220
- // for keyboard selection and append the explicit relationship commands.
221
- options.push(...relationshipOptions);
193
+ if (
194
+ hasRichTextFeature(config, "relationships") &&
195
+ authoring?.collections.some(
196
+ (collection) =>
197
+ config.relationshipCollections.length === 0 ||
198
+ config.relationshipCollections.includes(collection.slug)
199
+ )
200
+ ) {
201
+ options.push(
202
+ new RichTextMenuOption(
203
+ "relationship",
204
+ i18n.t("plugin.richtext:option.relationship.label"),
205
+ i18n.t("plugin.richtext:option.relationship.description"),
206
+ "relationship",
207
+ keywords(i18n.t("plugin.richtext:option.relationship.keywords")),
208
+ () => queueMicrotask(() => editor.dispatchCommand(OPEN_RELATIONSHIP_BROWSER_COMMAND, {})),
209
+ { restoreEditorFocus: false }
210
+ )
211
+ );
222
212
  }
223
213
 
224
214
  if (hasRichTextFeature(config, "blocks") && authoring?.beginSchemaDraft !== undefined) {
225
- options.splice(
226
- 4,
227
- 0,
215
+ options.push(
228
216
  ...blockTypes.map(
229
217
  (type) =>
230
218
  new RichTextMenuOption(
231
219
  `block-${type.slug}`,
232
220
  type.labels.singular,
233
221
  i18n.t("plugin.richtext:block.description", { key: type.slug }),
234
- "▣",
222
+ "block",
235
223
  ["block", type.slug, type.labels.singular],
236
224
  () =>
237
225
  queueMicrotask(() =>
238
226
  editor.dispatchCommand(OPEN_BLOCK_EDITOR_COMMAND, { blockType: type.slug })
239
227
  ),
240
- { restoreEditorFocus: false, blockType: type.slug }
228
+ { restoreEditorFocus: false, blockType: type.slug, group: "blocks" }
241
229
  )
242
230
  )
243
231
  );
244
232
  }
245
- return options;
246
- }
247
-
248
- export function filterRichTextOptions(
249
- options: readonly RichTextMenuOption[],
250
- query: string,
251
- language?: string
252
- ): RichTextMenuOption[] {
253
- const normalized = query.trim().toLocaleLowerCase(language);
254
- if (normalized === "") return [...options];
255
- return options.filter(
256
- (option) =>
257
- option.label.toLocaleLowerCase(language).includes(normalized) ||
258
- option.description.toLocaleLowerCase(language).includes(normalized) ||
259
- option.keywords.some((keyword) => keyword.toLocaleLowerCase(language).includes(normalized))
233
+ // Later basic features move their merged group after the list group in Payload's default menu.
234
+ const groupOrder = { lists: 0, basic: 1, blocks: 2 };
235
+ const iconOrder: RichTextIconName[] = [
236
+ "check",
237
+ "ordered",
238
+ "unordered",
239
+ "divider",
240
+ "upload",
241
+ "quote",
242
+ "relationship",
243
+ "h1",
244
+ "h2",
245
+ "h3",
246
+ "h4",
247
+ "h5",
248
+ "h6",
249
+ "paragraph",
250
+ "code-block",
251
+ "block",
252
+ ];
253
+ return options.sort(
254
+ (a, b) =>
255
+ groupOrder[a.group] - groupOrder[b.group] ||
256
+ iconOrder.indexOf(a.icon) - iconOrder.indexOf(b.icon)
260
257
  );
261
258
  }
262
259
 
@@ -22,8 +22,10 @@
22
22
  let { authoring, config }: { authoring: FieldAuthoringHost | undefined; config: RichTextConfig } =
23
23
  $props();
24
24
  const i18n = getAdminI18n();
25
+ const componentID = $props.id();
26
+ const menuLabelID = `${componentID}-label`;
25
27
  const editor = useLexicalComposerContext()[0];
26
- const trigger = createBasicTypeaheadTriggerMatch("/", { allowWhitespace: true, minLength: 0 });
28
+ const trigger = createBasicTypeaheadTriggerMatch("/", { minLength: 0 });
27
29
  // Menu options carry attachment identity and the mounted field's feature set is fixed.
28
30
  // svelte-ignore state_referenced_locally
29
31
  const baseOptions = buildRichTextOptions(
@@ -38,16 +40,15 @@
38
40
  contextElement: HTMLElement;
39
41
  getBoundingClientRect: () => DOMRect;
40
42
  } | null>(null);
41
- let menuHost = $state<HTMLElement | null>(null);
43
+ let menuHost = $state.raw<HTMLElement | null>(null);
42
44
  const options = $derived(filterRichTextOptions(baseOptions, query ?? "", i18n.language));
43
45
 
44
46
  $effect(() => {
45
- if (query !== null) {
46
- menuHost?.parentElement?.setAttribute(
47
- "aria-label",
48
- i18n.t("plugin.richtext:editor.insertBlock")
49
- );
50
- }
47
+ const anchor = menuHost?.parentElement;
48
+ if (anchor === undefined || anchor === null) return;
49
+ // The upstream anchor rewrites aria-label on every query resolution.
50
+ anchor.setAttribute("aria-labelledby", menuLabelID);
51
+ return () => anchor.removeAttribute("aria-labelledby");
51
52
  });
52
53
 
53
54
  function openMenu(resolution: MenuResolution) {
@@ -95,12 +96,16 @@
95
96
  {trigger}
96
97
  >
97
98
  {#snippet menu(menuProps)}
98
- <div bind:this={menuHost} role="presentation"></div>
99
+ <div bind:this={menuHost} role="presentation">
100
+ <span id={menuLabelID} class="ridu-richtext-announcement">
101
+ {i18n.t("plugin.richtext:editor.insertBlock")}
102
+ </span>
103
+ </div>
99
104
  {#if menuProps.options.length > 0 && menuAnchor !== null && menuHost !== null}
100
105
  <PopoverRoot open>
101
106
  <PopoverContent
102
107
  portalTo={menuHost}
103
- class="w-[min(19rem,calc(100vw-2rem))] p-0 animate-[ridu-richtext-menu-in_150ms_ease] motion-reduce:animate-none"
108
+ class="ridu-richtext-menu-popover"
104
109
  role="presentation"
105
110
  customAnchor={menuAnchor}
106
111
  side="bottom"
@@ -122,7 +127,6 @@
122
127
  ?.setAttribute("aria-activedescendant", `typeahead-item-${index}`);
123
128
  }}
124
129
  onSelect={menuProps.selectOption}
125
- embedded
126
130
  idPrefix="typeahead-item"
127
131
  />
128
132
  </PopoverContent>