@keboola/design 13.1.0 → 13.1.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # @keboola/design
2
2
 
3
+ ## 13.1.2
4
+
5
+ ### Patch Changes
6
+
7
+ - `MarkdownEditor`'s floating toolbar works inside a modal `Modal` or `Sheet`.
8
+ - The toolbar is a `Popover` anchored to the selection, so it stacks over a surrounding Radix dialog like any popover. Its buttons take clicks, and the link panel's fields take clicks and typing. It used to be portalled to `document.body`, under the dialog's `pointer-events: none` and outside its focus trap.
9
+ - Escape goes to the top layer: the link panel, then the toolbar, then the dialog. Escape with the toolbar showing hides it until the selection changes, and still reaches the editor's own Escape handling (`onCancel` with `saveOnBlur` or `singleLine`). Escape with focus on a toolbar button sends focus back to the editor.
10
+ - Inside a dialog, the dialog's focus trap is paused while the toolbar shows, as with any non-modal popover. Tab cycles inside the toolbar.
11
+ - A click or focus outside the editor and the toolbar hides it, so Escape in another field reaches that field; focus back in the editor shows it again. It used to stay up, pointing at the editor's selection, with an open link panel inside it.
12
+ - The toolbar follows its text when the text moves (an alignment change, a container scroll, a window resize), hides while the text is scrolled out of view, and moves below the selection when there is no room above it. It used to move only on an editor update, not on a scroll or resize.
13
+ - Nothing else changes: it shows for a non-empty selection, a button press keeps the editor's focus and selection, and it still renders in `document.body`. `toolbarMode="fixed"` is unchanged.
14
+
15
+ ## 13.1.1
16
+
17
+ ### Patch Changes
18
+
19
+ - `MarkdownEditor`'s toolbar names its shortcuts for the platform it runs on, and two open link panels on one page each label their own fields.
20
+ - Bold, Italic and Underline are named by the action alone: `Bold`, `Italic`, `Underline`. The shortcut is in `aria-keyshortcuts`, `Meta+B` on Apple platforms, where Lexical binds Cmd, and `Control+B` elsewhere. The tooltip shows the action with its keys, `⌘+B` or `Ctrl+B`.
21
+ - The link panel's Label and URL fields take their ids from `useId`, so a second editor's panel no longer points its labels at the first editor's fields.
22
+
23
+ - `MarkdownEditor`'s formatting toolbar names its buttons, and its link panel takes clicks and Escape, in both `toolbarMode`s.
24
+ - Each icon-only button has an accessible name (`Bold`, `Insert Link`, or `Remove Link` inside a link, `Text alignment`, ...), so a screen reader announces it and a test finds it with `getByRole('button', { name })`. Bold, Italic and Underline give their shortcut in `aria-keyshortcuts`, not in the name: `Meta+B` on Apple platforms, `Control+B` elsewhere. The block-type dropdown already had a name from its visible text.
25
+ - A click in the link panel's Label or URL field focuses it. The toolbar still keeps focus in the editor when a button is pressed, which is what keeps the selection a format applies to; the panel saves that selection when it opens.
26
+ - Escape while the link panel is open closes the panel and nothing around it. The panel registers in Radix's dismissable-layer stack and claims the key, so a surrounding `Modal`, `Sheet`, `Popover` or `FullScreenModal` stays open. Focus goes back to the editor when it was in the panel and stays put when it was elsewhere. Escape with the panel closed reaches the surface as before, and an outside click or focus still leaves the panel open.
27
+
3
28
  ## 13.1.0
4
29
 
5
30
  ### Minor Changes
@@ -3,7 +3,7 @@ import { ReactNode } from "react";
3
3
  /**
4
4
  * How the toolbar is presented.
5
5
  *
6
- * - `floating`: a popup over the selection, portalled to `document.body`.
6
+ * - `floating`: a `Popover` over the selection, so it stacks over a surrounding dialog.
7
7
  * - `fixed`: a static bar at the top of the editor, visible with no selection.
8
8
  */
9
9
  type ToolbarMode = 'floating' | 'fixed';
@@ -6,13 +6,15 @@ import { ButtonGroup } from "./internal/components/ButtonGroup.js";
6
6
  import { DropdownMenu } from "./DropdownMenu-BJGLjpVc.js";
7
7
  import { Label } from "./internal/components/Label.js";
8
8
  import { useStableCallback } from "./internal/hooks/useStableCallback.js";
9
+ import { Popover } from "./Popover-CEk915I1.js";
9
10
  import { TextInput } from "./TextInput-BWYXbfs4.js";
10
11
  import { FormGroup } from "./FormGroup-CmCppxOn.js";
11
12
  import { Skeleton } from "./internal/components/Skeleton/Skeleton.js";
13
+ import { Kbd } from "./Kbd-J4n1J6i4.js";
12
14
  import { toast } from "./toast-CyXEdaCZ.js";
13
- import { Suspense, createContext, lazy, use, useCallback, useEffect, useRef, useState } from "react";
15
+ import { Suspense, createContext, lazy, use, useCallback, useEffect, useId, useRef, useState } from "react";
14
16
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
15
- import { createPortal } from "react-dom";
17
+ import { DismissableLayer } from "@radix-ui/react-dismissable-layer";
16
18
  import { $createCodeNode, $isCodeNode, CodeNode } from "@lexical/code";
17
19
  import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin";
18
20
  import { AutoLinkPlugin } from "@lexical/react/LexicalAutoLinkPlugin";
@@ -29,8 +31,8 @@ import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPl
29
31
  import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
30
32
  import { TabIndentationPlugin } from "@lexical/react/LexicalTabIndentationPlugin";
31
33
  import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
32
- import { $addUpdateTag, $createParagraphNode, $createTextNode, $getNodeByKey, $getSelection, $insertNodes, $isParagraphNode, $isRangeSelection, $isTextNode, $parseSerializedNode, $setSelection, COMMAND_PRIORITY_CRITICAL, COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_LOW, DROP_COMMAND, DecoratorNode, FORMAT_ELEMENT_COMMAND, FORMAT_TEXT_COMMAND, HISTORY_PUSH_TAG, KEY_DOWN_COMMAND, PASTE_COMMAND, PASTE_TAG, createEditor } from "lexical";
33
- import { $findMatchingParent, $getNearestBlockElementAncestorOrThrow } from "@lexical/utils";
34
+ import { $addUpdateTag, $createParagraphNode, $createTextNode, $getNodeByKey, $getSelection, $insertNodes, $isParagraphNode, $isRangeSelection, $isTextNode, $parseSerializedNode, $setSelection, COMMAND_PRIORITY_CRITICAL, COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_LOW, DROP_COMMAND, DecoratorNode, FOCUS_COMMAND, FORMAT_ELEMENT_COMMAND, FORMAT_TEXT_COMMAND, HISTORY_PUSH_TAG, KEY_DOWN_COMMAND, PASTE_COMMAND, PASTE_TAG, createEditor } from "lexical";
35
+ import { $findMatchingParent, $getNearestBlockElementAncestorOrThrow, IS_APPLE } from "@lexical/utils";
34
36
  import { $createLinkNode, $isAutoLinkNode, $isLinkNode, AutoLinkNode, LinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link";
35
37
  import { $isListNode, INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND, ListItemNode, ListNode, REMOVE_LIST_COMMAND } from "@lexical/list";
36
38
  import { $createHorizontalRuleNode, $isHorizontalRuleNode, HorizontalRuleNode } from "@lexical/react/LexicalHorizontalRuleNode";
@@ -754,6 +756,7 @@ const ALIGNMENT_OPTIONS = [
754
756
  icon: AlignJustify
755
757
  }
756
758
  ];
759
+ const TRIGGER_LABEL = "Text alignment";
757
760
  const ALIGNMENT_ICONS = Object.fromEntries(ALIGNMENT_OPTIONS.map(({ value, icon }) => [value, icon]));
758
761
  const AlignmentDropdown = ({ editor, alignment }) => {
759
762
  const savedSelectionRef = useRef(null);
@@ -773,13 +776,14 @@ const AlignmentDropdown = ({ editor, alignment }) => {
773
776
  return /* @__PURE__ */ jsxs(DropdownMenu, {
774
777
  onOpenChange: handleOpenChange,
775
778
  children: [/* @__PURE__ */ jsx(Tooltip, {
776
- tooltip: "Text alignment",
779
+ tooltip: TRIGGER_LABEL,
777
780
  placement: "top",
778
781
  triggerAsChild: true,
779
782
  children: /* @__PURE__ */ jsx(DropdownMenu.Trigger, {
780
783
  asChild: true,
781
784
  children: /* @__PURE__ */ jsxs("button", {
782
785
  type: "button",
786
+ "aria-label": TRIGGER_LABEL,
783
787
  className: cn("tw:flex tw:h-7 tw:cursor-pointer tw:items-center tw:gap-1 tw:rounded tw:border-0 tw:bg-transparent tw:px-1.5 tw:text-xs tw:text-muted-foreground", "tw:hover:bg-accent"),
784
788
  children: [/* @__PURE__ */ jsx(CurrentAlignmentIcon, { className: "tw:size-4" }), /* @__PURE__ */ jsx(ChevronDown, { className: "tw:size-3" })]
785
789
  })
@@ -906,12 +910,39 @@ const BlockTypeDropdown = ({ editor, blockType }) => {
906
910
  };
907
911
  //#endregion
908
912
  //#region src/blocks/MarkdownEditor/plugins/toolbar/ToolbarButton.tsx
909
- const ToolbarButton = ({ active, disabled, onClick, tooltip, children, className: extraClassName }) => /* @__PURE__ */ jsx(Tooltip, {
910
- tooltip,
913
+ /**
914
+ * The modifier Lexical binds its format keys to: Cmd on Apple platforms, Ctrl elsewhere.
915
+ *
916
+ * - Read from Lexical's own `IS_APPLE`, which counts iPhone and iPad where `isMac` does not.
917
+ */
918
+ const MOD_KEY = IS_APPLE ? {
919
+ aria: "Meta",
920
+ symbol: "⌘"
921
+ } : {
922
+ aria: "Control",
923
+ symbol: "Ctrl"
924
+ };
925
+ const ToolbarButton = ({ active, disabled, onClick, tooltip, shortcutKey, children, className: extraClassName }) => /* @__PURE__ */ jsx(Tooltip, {
926
+ tooltip: shortcutKey ? /* @__PURE__ */ jsxs("span", {
927
+ className: "tw:inline-flex tw:items-center tw:gap-2",
928
+ children: [tooltip, /* @__PURE__ */ jsxs(Kbd.Group, {
929
+ "aria-hidden": true,
930
+ separator: "+",
931
+ children: [/* @__PURE__ */ jsx(Kbd, {
932
+ variant: "primary",
933
+ children: MOD_KEY.symbol
934
+ }), /* @__PURE__ */ jsx(Kbd, {
935
+ variant: "primary",
936
+ children: shortcutKey
937
+ })]
938
+ })]
939
+ }) : tooltip,
911
940
  placement: "top",
912
941
  triggerAsChild: true,
913
942
  children: /* @__PURE__ */ jsx("button", {
914
943
  type: "button",
944
+ "aria-label": tooltip,
945
+ "aria-keyshortcuts": shortcutKey ? `${MOD_KEY.aria}+${shortcutKey}` : void 0,
915
946
  disabled,
916
947
  onClick,
917
948
  className: cn("tw:flex tw:h-7 tw:min-w-7 tw:cursor-pointer tw:items-center tw:justify-center tw:rounded tw:border-0 tw:bg-transparent tw:px-1 tw:text-xs tw:text-muted-foreground", "tw:hover:bg-accent tw:disabled:cursor-not-allowed tw:disabled:opacity-30", active && "tw:bg-secondary-100 tw:text-secondary-500", extraClassName),
@@ -943,6 +974,8 @@ const LinkPanel = ({ editor, isLink }) => {
943
974
  const [label, setLabel] = useState("");
944
975
  const savedSelectionRef = useRef(null);
945
976
  const panelRef = useRef(null);
977
+ const labelId = useId();
978
+ const urlId = useId();
946
979
  const handleOpen = () => {
947
980
  editor.getEditorState().read(() => {
948
981
  const selection = $getSelection();
@@ -954,13 +987,27 @@ const LinkPanel = ({ editor, isLink }) => {
954
987
  });
955
988
  setOpen(true);
956
989
  };
957
- const handleClose = () => {
990
+ const resetPanel = () => {
958
991
  setOpen(false);
959
992
  setUrl("");
960
993
  setLabel("");
961
994
  savedSelectionRef.current = null;
995
+ };
996
+ const handleClose = () => {
997
+ resetPanel();
962
998
  editor.focus();
963
999
  };
1000
+ /**
1001
+ * Closes the panel on Escape pressed anywhere while it is open.
1002
+ *
1003
+ * - `preventDefault` claims the key from a surface outside Radix's stack (`FullScreenModal`).
1004
+ * - Focus goes back to the editor only from inside the panel; anywhere else it stays put.
1005
+ */
1006
+ const handleEscape = (event) => {
1007
+ event.preventDefault();
1008
+ if (panelRef.current?.contains(document.activeElement)) handleClose();
1009
+ else resetPanel();
1010
+ };
964
1011
  const handleSubmit = () => {
965
1012
  const trimmedUrl = url.trim();
966
1013
  if (!trimmedUrl) return;
@@ -972,10 +1019,6 @@ const LinkPanel = ({ editor, isLink }) => {
972
1019
  e.preventDefault();
973
1020
  handleSubmit();
974
1021
  }
975
- if (e.key === "Escape") {
976
- e.preventDefault();
977
- handleClose();
978
- }
979
1022
  };
980
1023
  if (isLink) return /* @__PURE__ */ jsx(ToolbarButton, {
981
1024
  active: true,
@@ -990,18 +1033,19 @@ const LinkPanel = ({ editor, isLink }) => {
990
1033
  onClick: handleOpen,
991
1034
  tooltip: "Insert Link",
992
1035
  children: /* @__PURE__ */ jsx(Link, { className: "tw:size-4" })
993
- }), open && /* @__PURE__ */ jsx("div", {
1036
+ }), open && /* @__PURE__ */ jsx(DismissableLayer, {
994
1037
  ref: panelRef,
1038
+ onEscapeKeyDown: handleEscape,
995
1039
  className: "tw:absolute tw:left-0 tw:top-full tw:z-50 tw:mt-1 tw:w-80 tw:rounded-md tw:border tw:border-border tw:bg-popover tw:p-3 tw:text-popover-foreground tw:shadow-lg",
996
1040
  children: /* @__PURE__ */ jsxs("div", {
997
1041
  className: "tw:flex tw:flex-col tw:gap-2",
998
1042
  children: [
999
1043
  /* @__PURE__ */ jsxs(FormGroup, { children: [/* @__PURE__ */ jsx(Label, {
1000
- htmlFor: "link-label",
1044
+ htmlFor: labelId,
1001
1045
  className: "tw:mb-1 tw:text-xs",
1002
1046
  children: "Label"
1003
1047
  }), /* @__PURE__ */ jsx(TextInput, {
1004
- id: "link-label",
1048
+ id: labelId,
1005
1049
  autoFocus: true,
1006
1050
  placeholder: "Link text",
1007
1051
  value: label,
@@ -1010,11 +1054,11 @@ const LinkPanel = ({ editor, isLink }) => {
1010
1054
  variant: "secondary"
1011
1055
  })] }),
1012
1056
  /* @__PURE__ */ jsxs(FormGroup, { children: [/* @__PURE__ */ jsx(Label, {
1013
- htmlFor: "link-url",
1057
+ htmlFor: urlId,
1014
1058
  className: "tw:mb-1 tw:text-xs",
1015
1059
  children: "URL"
1016
1060
  }), /* @__PURE__ */ jsx(TextInput, {
1017
- id: "link-url",
1061
+ id: urlId,
1018
1062
  placeholder: "https://…",
1019
1063
  value: url,
1020
1064
  onValueChange: setUrl,
@@ -1054,6 +1098,16 @@ const ELEMENT_FORMAT_TYPES = [
1054
1098
  const isElementFormatType = (value) => ELEMENT_FORMAT_TYPES.includes(value);
1055
1099
  const toElementFormatType = (value) => isElementFormatType(value) ? value : "left";
1056
1100
  const TOOLBAR_BASE_CLASS = "tw:flex tw:items-center tw:gap-0.5 tw:rounded-lg tw:border tw:border-border tw:px-1.5 tw:py-1";
1101
+ const hasFocus = (element) => !!element?.contains(document.activeElement);
1102
+ /**
1103
+ * Keeps focus, and the editor selection with it, where it was when a toolbar control is pressed.
1104
+ *
1105
+ * - A link panel field takes focus as usual, so a click there lands the caret in it.
1106
+ */
1107
+ const keepEditorFocus = (event) => {
1108
+ if (event.target instanceof HTMLInputElement) return;
1109
+ event.preventDefault();
1110
+ };
1057
1111
  const ToolbarPlugin = ({ mode = "floating" }) => {
1058
1112
  const [editor] = useLexicalComposerContext();
1059
1113
  const [isBold, setIsBold] = useState(false);
@@ -1065,23 +1119,68 @@ const ToolbarPlugin = ({ mode = "floating" }) => {
1065
1119
  const [blockType, setBlockType] = useState("paragraph");
1066
1120
  const [alignment, setAlignment] = useState("left");
1067
1121
  const [isVisible, setIsVisible] = useState(false);
1068
- const [position, setPosition] = useState(null);
1069
- const toolbarRef = useRef(null);
1122
+ const [anchor, setAnchor] = useState(null);
1070
1123
  const isMouseDownRef = useRef(false);
1071
1124
  const hasSelectionRef = useRef(false);
1125
+ const toolbarRef = useRef(null);
1126
+ /** The selection an Escape hid the toolbar on; it stays hidden until the selection differs. */
1127
+ const dismissedRef = useRef(null);
1072
1128
  const isFloating = mode === "floating";
1073
- const updatePosition = useCallback(() => {
1129
+ /**
1130
+ * Anchors the floating toolbar to the DOM selection inside the editor.
1131
+ *
1132
+ * - The range is a copy, so it keeps measuring these nodes when the page selection moves.
1133
+ * - Measured live, so the toolbar follows the text on scroll and resize.
1134
+ * - A range whose nodes Lexical replaced measures zero; the last real rect stands in for it.
1135
+ * - A new anchor on every update, so floating-ui places the toolbar again when the text moves.
1136
+ */
1137
+ const updateAnchor = useCallback(() => {
1074
1138
  const nativeSelection = window.getSelection();
1075
- if (!nativeSelection || nativeSelection.rangeCount === 0) return;
1076
- const rect = nativeSelection.getRangeAt(0).getBoundingClientRect();
1077
- if (rect.width > 0) {
1078
- const toolbarWidth = toolbarRef.current?.offsetWidth ?? 300;
1079
- setPosition({
1080
- top: rect.top + window.scrollY - 8,
1081
- left: Math.max(8, rect.left + window.scrollX + rect.width / 2 - toolbarWidth / 2)
1082
- });
1139
+ const root = editor.getRootElement();
1140
+ if (!root || !nativeSelection || nativeSelection.rangeCount === 0) return;
1141
+ const range = nativeSelection.getRangeAt(0).cloneRange();
1142
+ if (!root.contains(range.commonAncestorContainer)) return;
1143
+ let rect = range.getBoundingClientRect();
1144
+ if (rect.width === 0) return;
1145
+ const getBoundingClientRect = () => {
1146
+ const next = range.getBoundingClientRect();
1147
+ if (next.width > 0) rect = next;
1148
+ return rect;
1149
+ };
1150
+ setAnchor({
1151
+ getBoundingClientRect,
1152
+ contextElement: root
1153
+ });
1154
+ }, [editor]);
1155
+ /** Whether focus is in the editor or the toolbar, the places a selection shows the toolbar from. */
1156
+ const isFocusWithin = useCallback(() => hasFocus(editor.getRootElement()) || hasFocus(toolbarRef.current), [editor]);
1157
+ /**
1158
+ * Hides the floating toolbar on a click or focus outside it and the editor.
1159
+ *
1160
+ * - So Escape in another field reaches that field, not this layer.
1161
+ * - Radix counts the toolbar's own dropdown menus as inside.
1162
+ */
1163
+ const handleInteractOutside = (event) => {
1164
+ const target = event.target;
1165
+ if (target instanceof Node && editor.getRootElement()?.contains(target)) return;
1166
+ setIsVisible(false);
1167
+ };
1168
+ /**
1169
+ * Escape on the floating toolbar, which `PopoverContent` stops at `document`.
1170
+ *
1171
+ * 1. Focus in the toolbar goes back to the editor, and the toolbar stays.
1172
+ * 2. Otherwise the toolbar hides until the selection changes; with focus in the editor, its own
1173
+ * Escape handling gets the key as a command.
1174
+ */
1175
+ const handleEscape = (event) => {
1176
+ if (hasFocus(toolbarRef.current)) {
1177
+ editor.focus();
1178
+ return;
1083
1179
  }
1084
- }, []);
1180
+ setIsVisible(false);
1181
+ dismissedRef.current = editor.getEditorState().read(() => $getSelection()?.clone() ?? null);
1182
+ if (hasFocus(editor.getRootElement())) editor.dispatchCommand(KEY_DOWN_COMMAND, event);
1183
+ };
1085
1184
  useEffect(() => {
1086
1185
  if (!isFloating) return;
1087
1186
  const handleMouseDown = () => {
@@ -1089,15 +1188,24 @@ const ToolbarPlugin = ({ mode = "floating" }) => {
1089
1188
  };
1090
1189
  const handleMouseUp = () => {
1091
1190
  isMouseDownRef.current = false;
1092
- if (hasSelectionRef.current) setIsVisible(true);
1191
+ if (hasSelectionRef.current && !dismissedRef.current && isFocusWithin()) setIsVisible(true);
1093
1192
  };
1094
1193
  document.addEventListener("mousedown", handleMouseDown);
1095
1194
  document.addEventListener("mouseup", handleMouseUp);
1195
+ const unregisterFocus = editor.registerCommand(FOCUS_COMMAND, () => {
1196
+ if (hasSelectionRef.current && !dismissedRef.current) setIsVisible(true);
1197
+ return false;
1198
+ }, COMMAND_PRIORITY_LOW);
1096
1199
  return () => {
1097
1200
  document.removeEventListener("mousedown", handleMouseDown);
1098
1201
  document.removeEventListener("mouseup", handleMouseUp);
1202
+ unregisterFocus();
1099
1203
  };
1100
- }, [isFloating]);
1204
+ }, [
1205
+ editor,
1206
+ isFloating,
1207
+ isFocusWithin
1208
+ ]);
1101
1209
  const updateToolbar = useCallback(() => {
1102
1210
  const selection = $getSelection();
1103
1211
  if (!$isRangeSelection(selection)) {
@@ -1107,9 +1215,10 @@ const ToolbarPlugin = ({ mode = "floating" }) => {
1107
1215
  }
1108
1216
  const hasSelection = !selection.isCollapsed() && selection.getTextContent().length > 0;
1109
1217
  hasSelectionRef.current = hasSelection;
1218
+ if (dismissedRef.current && !selection.is(dismissedRef.current)) dismissedRef.current = null;
1110
1219
  if (!hasSelection) setIsVisible(false);
1111
- else if (!isMouseDownRef.current) setIsVisible(true);
1112
- if (hasSelection) updatePosition();
1220
+ else if (!isMouseDownRef.current && !dismissedRef.current && isFocusWithin()) setIsVisible(true);
1221
+ if (hasSelection && isFloating) updateAnchor();
1113
1222
  setIsBold(selection.hasFormat("bold"));
1114
1223
  setIsItalic(selection.hasFormat("italic"));
1115
1224
  setIsUnderline(selection.hasFormat("underline"));
@@ -1127,7 +1236,11 @@ const ToolbarPlugin = ({ mode = "floating" }) => {
1127
1236
  else if ($isQuoteNode(blockElement)) setBlockType("quote");
1128
1237
  else setBlockType("paragraph");
1129
1238
  setAlignment(toElementFormatType(blockElement.getFormatType()));
1130
- }, [updatePosition]);
1239
+ }, [
1240
+ isFloating,
1241
+ isFocusWithin,
1242
+ updateAnchor
1243
+ ]);
1131
1244
  useEffect(() => {
1132
1245
  const unregisterUpdate = editor.registerUpdateListener(({ editorState }) => {
1133
1246
  editorState.read(() => {
@@ -1138,7 +1251,6 @@ const ToolbarPlugin = ({ mode = "floating" }) => {
1138
1251
  unregisterUpdate();
1139
1252
  };
1140
1253
  }, [editor, updateToolbar]);
1141
- if (isFloating && (!isVisible || !position)) return null;
1142
1254
  const buttons = /* @__PURE__ */ jsxs(Fragment$1, { children: [
1143
1255
  /* @__PURE__ */ jsx(BlockTypeDropdown, {
1144
1256
  editor,
@@ -1148,19 +1260,22 @@ const ToolbarPlugin = ({ mode = "floating" }) => {
1148
1260
  /* @__PURE__ */ jsx(ToolbarButton, {
1149
1261
  active: isBold,
1150
1262
  onClick: () => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold"),
1151
- tooltip: "Bold (Ctrl+B)",
1263
+ tooltip: "Bold",
1264
+ shortcutKey: "B",
1152
1265
  children: /* @__PURE__ */ jsx(Bold, { className: "tw:size-4" })
1153
1266
  }),
1154
1267
  /* @__PURE__ */ jsx(ToolbarButton, {
1155
1268
  active: isItalic,
1156
1269
  onClick: () => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic"),
1157
- tooltip: "Italic (Ctrl+I)",
1270
+ tooltip: "Italic",
1271
+ shortcutKey: "I",
1158
1272
  children: /* @__PURE__ */ jsx(Italic, { className: "tw:size-4" })
1159
1273
  }),
1160
1274
  /* @__PURE__ */ jsx(ToolbarButton, {
1161
1275
  active: isUnderline,
1162
1276
  onClick: () => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "underline"),
1163
- tooltip: "Underline (Ctrl+U)",
1277
+ tooltip: "Underline",
1278
+ shortcutKey: "U",
1164
1279
  children: /* @__PURE__ */ jsx(Underline, { className: "tw:size-4" })
1165
1280
  }),
1166
1281
  /* @__PURE__ */ jsx(ToolbarButton, {
@@ -1241,22 +1356,29 @@ const ToolbarPlugin = ({ mode = "floating" }) => {
1241
1356
  if (!isFloating) return /* @__PURE__ */ jsx("div", {
1242
1357
  role: "toolbar",
1243
1358
  "aria-label": "Formatting options",
1244
- onMouseDown: (event) => event.preventDefault(),
1359
+ onMouseDown: keepEditorFocus,
1245
1360
  className: cn(TOOLBAR_BASE_CLASS, "tw:mb-1 tw:flex-wrap tw:bg-background"),
1246
1361
  children: buttons
1247
1362
  });
1248
- return createPortal(/* @__PURE__ */ jsx("div", {
1249
- ref: toolbarRef,
1250
- role: "toolbar",
1251
- "aria-label": "Formatting options",
1252
- onMouseDown: (event) => event.preventDefault(),
1253
- style: {
1254
- top: position?.top,
1255
- left: position?.left
1256
- },
1257
- className: cn(TOOLBAR_BASE_CLASS, "tw:absolute tw:z-[1060] tw:-translate-y-full tw:bg-popover tw:text-popover-foreground tw:shadow-lg"),
1258
- children: buttons
1259
- }), document.body);
1363
+ if (!isVisible || !anchor) return null;
1364
+ return /* @__PURE__ */ jsxs(Popover, {
1365
+ open: true,
1366
+ children: [/* @__PURE__ */ jsx(Popover.Anchor, { virtualRef: { current: anchor } }), /* @__PURE__ */ jsx(Popover.Content, {
1367
+ ref: toolbarRef,
1368
+ role: "toolbar",
1369
+ "aria-label": "Formatting options",
1370
+ side: "top",
1371
+ sideOffset: 8,
1372
+ collisionPadding: 8,
1373
+ hideWhenDetached: true,
1374
+ onOpenAutoFocus: (event) => event.preventDefault(),
1375
+ onEscapeKeyDown: handleEscape,
1376
+ onInteractOutside: handleInteractOutside,
1377
+ onMouseDown: keepEditorFocus,
1378
+ className: cn(TOOLBAR_BASE_CLASS, "tw:flex-row tw:overflow-visible"),
1379
+ children: buttons
1380
+ })]
1381
+ });
1260
1382
  };
1261
1383
  //#endregion
1262
1384
  //#region src/blocks/MarkdownEditor/theme.ts