@edux-design/comments 0.2.0 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # @edux-design/comments
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Resolve mention menus into their containing dialog by default, with a typed portalContainer override and lifecycle-safe observation of reparenting and disconnected containers. Preserve composer values, mentions, external submission and imperative APIs. Require Menus ^1.1.0 for the checked-item and native Tab contract.
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies
12
+ - @edux-design/menus@1.1.0
13
+
3
14
  ## 0.2.0
4
15
 
5
16
  ### Minor Changes
package/README.md CHANGED
@@ -119,3 +119,5 @@ override the label if needed.
119
119
  ## Additional components
120
120
 
121
121
  [Composer values, focus and external submission](./docs/composer.md).
122
+
123
+ [Mention menu ownership in Drawers and Modals](./docs/mention-portals.md).
package/dist/index.d.mts CHANGED
@@ -14,7 +14,7 @@ type CommentAvatarProps$1 = React.HTMLAttributes<HTMLSpanElement> & React.Button
14
14
  asButton?: boolean;
15
15
  };
16
16
 
17
- declare function CommentMention({ peopleList, onQueryChange }: CommentMentionProps$1): react_jsx_runtime.JSX.Element;
17
+ declare function CommentMention({ peopleList, onQueryChange, portalContainer, }: CommentMentionProps$1): react_jsx_runtime.JSX.Element;
18
18
  type CommentMentionPerson$1 = {
19
19
  id: string | number;
20
20
  name: string;
@@ -25,6 +25,7 @@ type CommentMentionProps$1 = {
25
25
  peopleList?: CommentMentionPerson$1[];
26
26
  onQueryChange?: (query: string) => void;
27
27
  placeholder?: string;
28
+ portalContainer?: HTMLElement | null;
28
29
  };
29
30
 
30
31
  declare const CommentComposer: React.ForwardRefExoticComponent<Omit<React.FormHTMLAttributes<HTMLFormElement>, "onChange" | "onSubmit"> & {
package/dist/index.d.ts CHANGED
@@ -14,7 +14,7 @@ type CommentAvatarProps$1 = React.HTMLAttributes<HTMLSpanElement> & React.Button
14
14
  asButton?: boolean;
15
15
  };
16
16
 
17
- declare function CommentMention({ peopleList, onQueryChange }: CommentMentionProps$1): react_jsx_runtime.JSX.Element;
17
+ declare function CommentMention({ peopleList, onQueryChange, portalContainer, }: CommentMentionProps$1): react_jsx_runtime.JSX.Element;
18
18
  type CommentMentionPerson$1 = {
19
19
  id: string | number;
20
20
  name: string;
@@ -25,6 +25,7 @@ type CommentMentionProps$1 = {
25
25
  peopleList?: CommentMentionPerson$1[];
26
26
  onQueryChange?: (query: string) => void;
27
27
  placeholder?: string;
28
+ portalContainer?: HTMLElement | null;
28
29
  };
29
30
 
30
31
  declare const CommentComposer: React.ForwardRefExoticComponent<Omit<React.FormHTMLAttributes<HTMLFormElement>, "onChange" | "onSubmit"> & {
package/dist/index.js CHANGED
@@ -558,36 +558,95 @@ function CommentComposerComponent({
558
558
  var CommentComposer = (0, import_react4.forwardRef)(CommentComposerComponent);
559
559
 
560
560
  // src/elements/CommentMention.jsx
561
- var import_react6 = __toESM(require("react"));
561
+ var import_react7 = __toESM(require("react"));
562
562
  var import_menus = require("@edux-design/menus");
563
563
  var import_avatars3 = require("@edux-design/avatars");
564
+
565
+ // src/elements/useMentionPortalContainer.js
566
+ var import_react6 = require("react");
567
+ function resolveMentionPortalContainer(anchor, override) {
568
+ const document = anchor?.ownerDocument;
569
+ if (!document || !anchor.isConnected) return void 0;
570
+ if (override === null) return void 0;
571
+ if (override?.isConnected && override.ownerDocument === document)
572
+ return override;
573
+ const dialog = anchor.closest(
574
+ '[role="dialog"], [role="alertdialog"], dialog'
575
+ );
576
+ return dialog?.isConnected ? dialog : void 0;
577
+ }
578
+ function useMentionPortalContainer(anchorRef, override, active) {
579
+ const [container, setContainer] = (0, import_react6.useState)(void 0);
580
+ (0, import_react6.useLayoutEffect)(() => {
581
+ let disposed = false;
582
+ let observer;
583
+ let observedDocument;
584
+ const refresh = () => {
585
+ if (disposed) return;
586
+ const anchor = anchorRef?.current;
587
+ const next = resolveMentionPortalContainer(anchor, override);
588
+ setContainer((previous) => previous === next ? previous : next);
589
+ const document = anchor?.ownerDocument;
590
+ if (!active || observedDocument === document) return;
591
+ observer?.disconnect();
592
+ observedDocument = document;
593
+ const MutationObserver = document?.defaultView?.MutationObserver;
594
+ if (MutationObserver && document.documentElement) {
595
+ observer = new MutationObserver(refresh);
596
+ observer.observe(document.documentElement, {
597
+ childList: true,
598
+ subtree: true,
599
+ attributes: true,
600
+ attributeFilter: ["role"]
601
+ });
602
+ }
603
+ };
604
+ refresh();
605
+ return () => {
606
+ disposed = true;
607
+ observer?.disconnect();
608
+ };
609
+ }, [anchorRef, override, active]);
610
+ return container;
611
+ }
612
+
613
+ // src/elements/CommentMention.jsx
564
614
  var import_jsx_runtime4 = require("react/jsx-runtime");
565
- var CommentMention = ({ peopleList = [], onQueryChange }) => {
615
+ var CommentMention = ({
616
+ peopleList = [],
617
+ onQueryChange,
618
+ portalContainer
619
+ }) => {
566
620
  const { editor, editorContainerRef, editable = true } = useCommentComposer();
567
- const [menuOpen, setMenuOpen] = (0, import_react6.useState)(false);
568
- const [query, setQuery] = (0, import_react6.useState)("");
569
- const [anchorStyle, setAnchorStyle] = (0, import_react6.useState)({ left: 0, top: 0 });
570
- const menuListRef = (0, import_react6.useRef)(null);
571
- const rangeRef = (0, import_react6.useRef)(null);
572
- const lastQueryRef = (0, import_react6.useRef)("");
573
- const dismissedRangeRef = (0, import_react6.useRef)(null);
574
- const focusFrameRef = (0, import_react6.useRef)(null);
575
- const focusTimeoutRef = (0, import_react6.useRef)(null);
576
- const dismissMenu = (0, import_react6.useCallback)(() => {
621
+ const [menuOpen, setMenuOpen] = (0, import_react7.useState)(false);
622
+ const mentionPortalContainer = useMentionPortalContainer(
623
+ editorContainerRef,
624
+ portalContainer,
625
+ menuOpen
626
+ );
627
+ const [query, setQuery] = (0, import_react7.useState)("");
628
+ const [anchorStyle, setAnchorStyle] = (0, import_react7.useState)({ left: 0, top: 0 });
629
+ const menuListRef = (0, import_react7.useRef)(null);
630
+ const rangeRef = (0, import_react7.useRef)(null);
631
+ const lastQueryRef = (0, import_react7.useRef)("");
632
+ const dismissedRangeRef = (0, import_react7.useRef)(null);
633
+ const focusFrameRef = (0, import_react7.useRef)(null);
634
+ const focusTimeoutRef = (0, import_react7.useRef)(null);
635
+ const dismissMenu = (0, import_react7.useCallback)(() => {
577
636
  const activeRange = rangeRef.current;
578
637
  dismissedRangeRef.current = activeRange ? `${activeRange.from}:${activeRange.to}` : null;
579
638
  setMenuOpen(false);
580
639
  if (editor && !editor.isDestroyed && editor.isEditable !== false)
581
640
  editor.view.focus();
582
641
  }, [editor]);
583
- const filteredPeople = (0, import_react6.useMemo)(() => {
642
+ const filteredPeople = (0, import_react7.useMemo)(() => {
584
643
  if (!query) return peopleList;
585
644
  const lowered = query.toLowerCase();
586
645
  return peopleList.filter(
587
646
  (person) => person.handle.toLowerCase().startsWith(lowered)
588
647
  );
589
648
  }, [peopleList, query]);
590
- const updateMenuState = (0, import_react6.useCallback)(() => {
649
+ const updateMenuState = (0, import_react7.useCallback)(() => {
591
650
  if (!editor || !editable || editor.isEditable === false) {
592
651
  setMenuOpen(false);
593
652
  rangeRef.current = null;
@@ -630,7 +689,7 @@ var CommentMention = ({ peopleList = [], onQueryChange }) => {
630
689
  onQueryChange("");
631
690
  }
632
691
  }, [editor, editorContainerRef, onQueryChange, editable]);
633
- const handleSelectPerson = (0, import_react6.useCallback)(
692
+ const handleSelectPerson = (0, import_react7.useCallback)(
634
693
  (person) => {
635
694
  if (!editor || !editable || editor.isEditable === false || !rangeRef.current)
636
695
  return;
@@ -675,14 +734,14 @@ var CommentMention = ({ peopleList = [], onQueryChange }) => {
675
734
  },
676
735
  [editor, onQueryChange, editable]
677
736
  );
678
- (0, import_react6.useEffect)(
737
+ (0, import_react7.useEffect)(
679
738
  () => () => {
680
739
  cancelAnimationFrame(focusFrameRef.current);
681
740
  clearTimeout(focusTimeoutRef.current);
682
741
  },
683
742
  [editor]
684
743
  );
685
- (0, import_react6.useEffect)(() => {
744
+ (0, import_react7.useEffect)(() => {
686
745
  if (!editor) return;
687
746
  updateMenuState();
688
747
  editor.on("transaction", updateMenuState);
@@ -692,7 +751,7 @@ var CommentMention = ({ peopleList = [], onQueryChange }) => {
692
751
  editor.off("selectionUpdate", updateMenuState);
693
752
  };
694
753
  }, [editor, updateMenuState]);
695
- (0, import_react6.useEffect)(() => {
754
+ (0, import_react7.useEffect)(() => {
696
755
  if (!editor) return;
697
756
  const handleKeyDown = (event) => {
698
757
  if (!menuOpen) return;
@@ -717,7 +776,7 @@ var CommentMention = ({ peopleList = [], onQueryChange }) => {
717
776
  dom?.addEventListener("keydown", handleKeyDown, true);
718
777
  return () => dom?.removeEventListener("keydown", handleKeyDown, true);
719
778
  }, [editor, menuOpen, dismissMenu]);
720
- const menuContent = (0, import_react6.useMemo)(
779
+ const menuContent = (0, import_react7.useMemo)(
721
780
  () => /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
722
781
  import_menus.Menu,
723
782
  {
@@ -746,6 +805,7 @@ var CommentMention = ({ peopleList = [], onQueryChange }) => {
746
805
  sideOffset: 4,
747
806
  avoidCollisions: true,
748
807
  collisionPadding: 12,
808
+ container: mentionPortalContainer,
749
809
  onCloseAutoFocus: (event) => event.preventDefault()
750
810
  },
751
811
  children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
@@ -787,7 +847,14 @@ var CommentMention = ({ peopleList = [], onQueryChange }) => {
787
847
  )
788
848
  }
789
849
  ),
790
- [filteredPeople, handleSelectPerson, menuOpen, anchorStyle, dismissMenu]
850
+ [
851
+ filteredPeople,
852
+ handleSelectPerson,
853
+ menuOpen,
854
+ anchorStyle,
855
+ dismissMenu,
856
+ mentionPortalContainer
857
+ ]
791
858
  );
792
859
  if (!editor) return null;
793
860
  return menuContent;
package/dist/index.mjs CHANGED
@@ -540,19 +540,78 @@ var CommentComposer = forwardRef2(CommentComposerComponent);
540
540
  // src/elements/CommentMention.jsx
541
541
  import React4, {
542
542
  useMemo as useMemo3,
543
- useState as useState3,
543
+ useState as useState4,
544
544
  useCallback,
545
545
  useEffect as useEffect3,
546
546
  useRef as useRef2
547
547
  } from "react";
548
548
  import { Menu, MenuList, MenuItem } from "@edux-design/menus";
549
549
  import { Avatar as Avatar3 } from "@edux-design/avatars";
550
+
551
+ // src/elements/useMentionPortalContainer.js
552
+ import { useLayoutEffect, useState as useState3 } from "react";
553
+ function resolveMentionPortalContainer(anchor, override) {
554
+ const document = anchor?.ownerDocument;
555
+ if (!document || !anchor.isConnected) return void 0;
556
+ if (override === null) return void 0;
557
+ if (override?.isConnected && override.ownerDocument === document)
558
+ return override;
559
+ const dialog = anchor.closest(
560
+ '[role="dialog"], [role="alertdialog"], dialog'
561
+ );
562
+ return dialog?.isConnected ? dialog : void 0;
563
+ }
564
+ function useMentionPortalContainer(anchorRef, override, active) {
565
+ const [container, setContainer] = useState3(void 0);
566
+ useLayoutEffect(() => {
567
+ let disposed = false;
568
+ let observer;
569
+ let observedDocument;
570
+ const refresh = () => {
571
+ if (disposed) return;
572
+ const anchor = anchorRef?.current;
573
+ const next = resolveMentionPortalContainer(anchor, override);
574
+ setContainer((previous) => previous === next ? previous : next);
575
+ const document = anchor?.ownerDocument;
576
+ if (!active || observedDocument === document) return;
577
+ observer?.disconnect();
578
+ observedDocument = document;
579
+ const MutationObserver = document?.defaultView?.MutationObserver;
580
+ if (MutationObserver && document.documentElement) {
581
+ observer = new MutationObserver(refresh);
582
+ observer.observe(document.documentElement, {
583
+ childList: true,
584
+ subtree: true,
585
+ attributes: true,
586
+ attributeFilter: ["role"]
587
+ });
588
+ }
589
+ };
590
+ refresh();
591
+ return () => {
592
+ disposed = true;
593
+ observer?.disconnect();
594
+ };
595
+ }, [anchorRef, override, active]);
596
+ return container;
597
+ }
598
+
599
+ // src/elements/CommentMention.jsx
550
600
  import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
551
- var CommentMention = ({ peopleList = [], onQueryChange }) => {
601
+ var CommentMention = ({
602
+ peopleList = [],
603
+ onQueryChange,
604
+ portalContainer
605
+ }) => {
552
606
  const { editor, editorContainerRef, editable = true } = useCommentComposer();
553
- const [menuOpen, setMenuOpen] = useState3(false);
554
- const [query, setQuery] = useState3("");
555
- const [anchorStyle, setAnchorStyle] = useState3({ left: 0, top: 0 });
607
+ const [menuOpen, setMenuOpen] = useState4(false);
608
+ const mentionPortalContainer = useMentionPortalContainer(
609
+ editorContainerRef,
610
+ portalContainer,
611
+ menuOpen
612
+ );
613
+ const [query, setQuery] = useState4("");
614
+ const [anchorStyle, setAnchorStyle] = useState4({ left: 0, top: 0 });
556
615
  const menuListRef = useRef2(null);
557
616
  const rangeRef = useRef2(null);
558
617
  const lastQueryRef = useRef2("");
@@ -732,6 +791,7 @@ var CommentMention = ({ peopleList = [], onQueryChange }) => {
732
791
  sideOffset: 4,
733
792
  avoidCollisions: true,
734
793
  collisionPadding: 12,
794
+ container: mentionPortalContainer,
735
795
  onCloseAutoFocus: (event) => event.preventDefault()
736
796
  },
737
797
  children: /* @__PURE__ */ jsx4(
@@ -773,7 +833,14 @@ var CommentMention = ({ peopleList = [], onQueryChange }) => {
773
833
  )
774
834
  }
775
835
  ),
776
- [filteredPeople, handleSelectPerson, menuOpen, anchorStyle, dismissMenu]
836
+ [
837
+ filteredPeople,
838
+ handleSelectPerson,
839
+ menuOpen,
840
+ anchorStyle,
841
+ dismissMenu,
842
+ mentionPortalContainer
843
+ ]
777
844
  );
778
845
  if (!editor) return null;
779
846
  return menuContent;
@@ -0,0 +1,105 @@
1
+ # Mention menus in dialogs
2
+
3
+ `CommentMention` automatically portals its menu into the nearest connected
4
+ `[role="dialog"]`, `[role="alertdialog"]` or native `<dialog>` containing its
5
+ editor. This keeps menu pointer and keyboard interaction inside an owning modal
6
+ Drawer or Modal. Outside a dialog, the menu uses its ordinary document-body
7
+ portal.
8
+
9
+ ```jsx
10
+ <Drawer>
11
+ <DrawerTrigger asChild>
12
+ <Button>Open comments</Button>
13
+ </DrawerTrigger>
14
+ <DrawerContent aria-label="Comments" aria-describedby={undefined}>
15
+ <DrawerHeader>
16
+ <ModalTitle>Comments</ModalTitle>
17
+ </DrawerHeader>
18
+ <DrawerBody>
19
+ <CommentComposer
20
+ value={draft}
21
+ valueFormat="text"
22
+ onChange={(event) => setDraft(event.target.value)}
23
+ onValueChange={setSnapshot}
24
+ >
25
+ <CommentMention peopleList={people} onQueryChange={setQuery} />
26
+ </CommentComposer>
27
+ </DrawerBody>
28
+ </DrawerContent>
29
+ </Drawer>
30
+ ```
31
+
32
+ Import the Drawer components from `@edux-design/drawers`, `Button` from
33
+ `@edux-design/buttons`, and the composer and mention components from
34
+ `@edux-design/comments`. `ModalTitle` from `@edux-design/modals` supplies the shared
35
+ Dialog title in the Drawer header. Modal components have the same automatic
36
+ ownership behaviour.
37
+
38
+ ## Optional portal override
39
+
40
+ | `portalContainer` | Behaviour |
41
+ | ------------------------------------------------- | ----------------------------------------------------------------------- |
42
+ | Omitted or `undefined` | Find the nearest owning dialog; otherwise use the body portal. |
43
+ | Connected `HTMLElement` in the editor's document | Portal into that element. It takes precedence over automatic ownership. |
44
+ | `null` | Explicitly use the ordinary body portal. |
45
+ | Detached element or element from another document | Ignore the override and use automatic ownership. |
46
+
47
+ Pass the committed element itself, rather than a ref object. A callback ref stored
48
+ in state makes the element available after mount:
49
+
50
+ ```jsx
51
+ const [mentionHost, setMentionHost] = React.useState(null);
52
+
53
+ <ModalContent>
54
+ <ModalHeader>
55
+ <ModalTitle>Comments</ModalTitle>
56
+ <ModalDescription>Discuss the current draft.</ModalDescription>
57
+ </ModalHeader>
58
+ <ModalBody>
59
+ <CommentComposer>
60
+ <CommentMention
61
+ peopleList={people}
62
+ portalContainer={mentionHost ?? undefined}
63
+ />
64
+ </CommentComposer>
65
+ <div ref={setMentionHost} />
66
+ </ModalBody>
67
+ </ModalContent>;
68
+ ```
69
+
70
+ Use `?? undefined` when a not-yet-mounted callback ref should retain automatic
71
+ ownership. Passing its initial `null` directly requests the body portal. Keep a
72
+ custom host inside the active modal's interactive surface. A body portal or a host
73
+ outside that modal remains subject to the modal's pointer and focus restrictions.
74
+
75
+ Ownership resolves after refs commit. While a mention query is active, it follows
76
+ editor reparenting, dialog role changes, and removal of an override element.
77
+ Nested dialogs select the nearest owning dialog. Each composer resolves its own
78
+ owner. Tracking stops when the query closes and when the composer unmounts.
79
+
80
+ ## Interaction and values
81
+
82
+ Typing `@query` filters the supplied people by handle and calls `onQueryChange`.
83
+ Consumers continue to own asynchronous lookup, permissions and persistence.
84
+ Pointer activation or ArrowDown/ArrowUp followed by Enter inserts one mention,
85
+ closes the menu, and returns focus to the editor. Escape first dismisses an open
86
+ mention menu and returns focus to the editor; a subsequent Escape can close the
87
+ owning dialog according to that dialog's existing policy.
88
+
89
+ The mention menu retains `trapFocus={false}` and
90
+ `disableOutsidePointerEvents={false}`. The owning Drawer or Modal retains its
91
+ normal focus trap and outside-pointer blocking. Disabled or submitting composers
92
+ do not offer editable mention interaction.
93
+
94
+ Portal ownership does not change the composer model or submission contract.
95
+ `onValueChange` and `composerRef.current.getValue()` continue returning `text`,
96
+ `legacyText`, `html`, `json`, `mentions` and `isEmpty`. Existing `onChange`,
97
+ `onSubmit`, `onCancel`, `externalSubmit`, `submitOnModEnter`, editor labels,
98
+ autofocus, avatar/actions/children, and all composer handle methods remain
99
+ available. See [Composer values, focus and external submission](./composer.md).
100
+
101
+ The Storybook **Content / Comment Mention Portals** stories exercise real modal
102
+ Drawers and Modals, pointer and keyboard selection, Escape ordering, nested and
103
+ changing owners, overrides, independent composers, asynchronous people, locked
104
+ states, and a dark narrow Drawer. They use synthetic people and strict
105
+ accessibility checks.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edux-design/comments",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css"
@@ -18,7 +18,7 @@
18
18
  "@edux-design/buttons": "*",
19
19
  "@edux-design/forms": "*",
20
20
  "@edux-design/mentions": "*",
21
- "@edux-design/menus": "*",
21
+ "@edux-design/menus": "^1.1.0",
22
22
  "@edux-design/utils": "*"
23
23
  },
24
24
  "peerDependencies": {
@@ -33,8 +33,6 @@
33
33
  "dist",
34
34
  "docs",
35
35
  "README.md",
36
- "CHANGELOG.md",
37
- "LICENSE",
38
- "LICENSE.md"
36
+ "CHANGELOG.md"
39
37
  ]
40
38
  }