@adatechnology/conversations-ui 0.1.0-rc.41 → 0.1.0-rc.42

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.
@@ -858,8 +858,42 @@ function FlowPortalNode({ data }) {
858
858
  var flowPortalNodeTypes = { flowPortal: FlowPortalNode };
859
859
 
860
860
  // src/flows/FlowPalette.tsx
861
- import { useEffect, useRef, useState as useState2 } from "react";
861
+ import { useEffect, useLayoutEffect, useRef, useState as useState2 } from "react";
862
862
  import { Plus as Plus2, MessageCircleQuestion as MessageCircleQuestion2, GitBranch as GitBranch3, Zap as Zap2, Diamond as Diamond2, ChevronRight } from "lucide-react";
863
+
864
+ // src/flows/flowMenuPlacement.ts
865
+ var DEFAULT_GAP = 4;
866
+ var DEFAULT_MARGIN = 8;
867
+ function clamp(value, minimum, maximum) {
868
+ return Math.max(minimum, Math.min(value, Math.max(minimum, maximum)));
869
+ }
870
+ function placeFloatingPanel({
871
+ anchor,
872
+ panel,
873
+ viewport,
874
+ prefer,
875
+ gap = DEFAULT_GAP,
876
+ margin = DEFAULT_MARGIN
877
+ }) {
878
+ const maxHeight = Math.min(panel.height, viewport.height - margin * 2);
879
+ let left;
880
+ if (prefer === "side") {
881
+ const toTheRight = anchor.right + gap;
882
+ const toTheLeft = anchor.left - gap - panel.width;
883
+ left = toTheRight + panel.width <= viewport.width - margin ? toTheRight : toTheLeft;
884
+ } else {
885
+ left = anchor.left + gap;
886
+ }
887
+ left = clamp(left, margin, viewport.width - margin - panel.width);
888
+ const top = prefer === "side" ? anchor.top : anchor.bottom;
889
+ return {
890
+ left,
891
+ top: clamp(top, margin, viewport.height - margin - maxHeight),
892
+ maxHeight
893
+ };
894
+ }
895
+
896
+ // src/flows/FlowPalette.tsx
863
897
  import { Fragment, jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
864
898
  var QUESTION_TYPES = ["text", "money", "date", "int", "cpf"];
865
899
  function FlowPaletteMenu({ onSelect, labels, actionOptions }) {
@@ -867,6 +901,38 @@ function FlowPaletteMenu({ onSelect, labels, actionOptions }) {
867
901
  { actionKind: "handoff", label: labels.actionKindLabels.handoff ?? "Encaminhar para atendimento" }
868
902
  ];
869
903
  const [submenu, setSubmenu] = useState2(null);
904
+ const questionTriggerRef = useRef(null);
905
+ const actionTriggerRef = useRef(null);
906
+ const submenuRef = useRef(null);
907
+ const [placement, setPlacement] = useState2(null);
908
+ useLayoutEffect(() => {
909
+ if (!submenu) {
910
+ setPlacement(null);
911
+ return;
912
+ }
913
+ const trigger = submenu === "question" ? questionTriggerRef.current : actionTriggerRef.current;
914
+ const panel = submenuRef.current;
915
+ if (!trigger || !panel) return;
916
+ const anchor = trigger.getBoundingClientRect();
917
+ setPlacement(
918
+ placeFloatingPanel({
919
+ anchor: { left: anchor.left, top: anchor.top, right: anchor.right, bottom: anchor.bottom },
920
+ panel: { width: panel.offsetWidth, height: panel.scrollHeight },
921
+ viewport: { width: window.innerWidth, height: window.innerHeight },
922
+ prefer: "side"
923
+ })
924
+ );
925
+ }, [submenu]);
926
+ const submenuStyle = {
927
+ position: "fixed",
928
+ left: placement?.left ?? 0,
929
+ top: placement?.top ?? 0,
930
+ maxHeight: placement?.maxHeight,
931
+ overflowY: "auto",
932
+ // Até a medição terminar o painel existe mas não aparece — senão ele pisca um quadro na
933
+ // posição errada, que é justamente o salto que esta correção remove.
934
+ visibility: placement ? "visible" : "hidden"
935
+ };
870
936
  function select(spec) {
871
937
  onSelect(spec);
872
938
  setSubmenu(null);
@@ -876,6 +942,7 @@ function FlowPaletteMenu({ onSelect, labels, actionOptions }) {
876
942
  /* @__PURE__ */ jsxs6(
877
943
  "button",
878
944
  {
945
+ ref: questionTriggerRef,
879
946
  "data-cv-tooltip": labels.palette.question,
880
947
  "aria-label": labels.palette.question,
881
948
  onMouseEnter: () => setSubmenu("question"),
@@ -891,17 +958,25 @@ function FlowPaletteMenu({ onSelect, labels, actionOptions }) {
891
958
  ]
892
959
  }
893
960
  ),
894
- submenu === "question" && /* @__PURE__ */ jsx7("div", { className: "absolute left-full top-0 ml-1 w-56 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1", children: QUESTION_TYPES.map((qt) => /* @__PURE__ */ jsx7(
895
- "button",
961
+ submenu === "question" && /* @__PURE__ */ jsx7(
962
+ "div",
896
963
  {
897
- "data-cv-tooltip": labels.questionTypeLabels[qt],
898
- "aria-label": labels.questionTypeLabels[qt],
899
- onClick: () => select({ kind: "question", questionType: qt }),
900
- className: "w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700",
901
- children: labels.questionTypeLabels[qt]
902
- },
903
- qt
904
- )) })
964
+ ref: submenuRef,
965
+ style: submenuStyle,
966
+ className: "z-50 w-56 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1",
967
+ children: QUESTION_TYPES.map((qt) => /* @__PURE__ */ jsx7(
968
+ "button",
969
+ {
970
+ "data-cv-tooltip": labels.questionTypeLabels[qt],
971
+ "aria-label": labels.questionTypeLabels[qt],
972
+ onClick: () => select({ kind: "question", questionType: qt }),
973
+ className: "w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700",
974
+ children: labels.questionTypeLabels[qt]
975
+ },
976
+ qt
977
+ ))
978
+ }
979
+ )
905
980
  ] }),
906
981
  /* @__PURE__ */ jsxs6(
907
982
  "button",
@@ -937,6 +1012,7 @@ function FlowPaletteMenu({ onSelect, labels, actionOptions }) {
937
1012
  /* @__PURE__ */ jsxs6(
938
1013
  "button",
939
1014
  {
1015
+ ref: actionTriggerRef,
940
1016
  "data-cv-tooltip": labels.palette.action,
941
1017
  "aria-label": labels.palette.action,
942
1018
  onMouseEnter: () => setSubmenu("action"),
@@ -952,17 +1028,25 @@ function FlowPaletteMenu({ onSelect, labels, actionOptions }) {
952
1028
  ]
953
1029
  }
954
1030
  ),
955
- submenu === "action" && /* @__PURE__ */ jsx7("div", { className: "absolute left-full top-0 ml-1 w-64 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1", children: resolvedActionOptions.map((option) => /* @__PURE__ */ jsx7(
956
- "button",
1031
+ submenu === "action" && /* @__PURE__ */ jsx7(
1032
+ "div",
957
1033
  {
958
- "data-cv-tooltip": option.label,
959
- "aria-label": option.label,
960
- onClick: () => select({ kind: "action", actionKind: option.actionKind }),
961
- className: "w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700",
962
- children: option.label
963
- },
964
- option.actionKind
965
- )) })
1034
+ ref: submenuRef,
1035
+ style: submenuStyle,
1036
+ className: "z-50 w-64 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1",
1037
+ children: resolvedActionOptions.map((option) => /* @__PURE__ */ jsx7(
1038
+ "button",
1039
+ {
1040
+ "data-cv-tooltip": option.label,
1041
+ "aria-label": option.label,
1042
+ onClick: () => select({ kind: "action", actionKind: option.actionKind }),
1043
+ className: "w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700",
1044
+ children: option.label
1045
+ },
1046
+ option.actionKind
1047
+ ))
1048
+ }
1049
+ )
966
1050
  ] })
967
1051
  ] });
968
1052
  }
@@ -1541,7 +1625,7 @@ function FlowNodePanel({
1541
1625
  }
1542
1626
 
1543
1627
  // src/flows/FlowsWorkspace.tsx
1544
- import { useCallback, useEffect as useEffect3, useMemo as useMemo2, useRef as useRef3, useState as useState6 } from "react";
1628
+ import { useCallback, useEffect as useEffect3, useLayoutEffect as useLayoutEffect2, useMemo as useMemo2, useRef as useRef3, useState as useState6 } from "react";
1545
1629
  import {
1546
1630
  ReactFlow as ReactFlow2,
1547
1631
  Background as Background2,
@@ -1872,6 +1956,7 @@ var EDGE_COLOR_LINEAR = "#94a3b8";
1872
1956
  var FOCUS_MAX_ZOOM = 1;
1873
1957
  var FOCUS_PADDING = 0.2;
1874
1958
  var FOCUS_DURATION_MS = 400;
1959
+ var QUICK_ADD_MENU_GAP = 12;
1875
1960
  var EDGE_COLOR_BRANCH = "#8b5cf6";
1876
1961
  var EDGE_COLOR_FALLBACK = "#cbd5e1";
1877
1962
  var EDGE_COLOR_LIVE = "#3b82f6";
@@ -1941,6 +2026,8 @@ function FlowsWorkspace({
1941
2026
  const [flowInstance, setFlowInstance] = useState6(null);
1942
2027
  const [pendingFocusNodeId, setPendingFocusNodeId] = useState6(null);
1943
2028
  const [pendingFocusFlowKey, setPendingFocusFlowKey] = useState6(null);
2029
+ const quickAddMenuRef = useRef3(null);
2030
+ const [quickAddPlacement, setQuickAddPlacement] = useState6(null);
1944
2031
  const reloadGraphs = useCallback(async () => {
1945
2032
  try {
1946
2033
  const loaded = await api.getGraphs();
@@ -2239,6 +2326,31 @@ function FlowsWorkspace({
2239
2326
  });
2240
2327
  setPendingFocusFlowKey(null);
2241
2328
  }, [pendingFocusFlowKey, flowInstance, rfNodes]);
2329
+ useLayoutEffect2(() => {
2330
+ if (!quickAddFrom) {
2331
+ setQuickAddPlacement(null);
2332
+ return;
2333
+ }
2334
+ const panel = quickAddMenuRef.current;
2335
+ if (!panel) return;
2336
+ const { x, y } = quickAddFrom.anchor;
2337
+ setQuickAddPlacement(
2338
+ placeFloatingPanel({
2339
+ anchor: { left: x, top: y, right: x, bottom: y },
2340
+ panel: { width: panel.offsetWidth, height: panel.scrollHeight },
2341
+ viewport: { width: window.innerWidth, height: window.innerHeight },
2342
+ prefer: "below",
2343
+ gap: QUICK_ADD_MENU_GAP
2344
+ })
2345
+ );
2346
+ }, [quickAddFrom]);
2347
+ const quickAddMenuStyle = {
2348
+ left: quickAddPlacement?.left ?? 0,
2349
+ top: quickAddPlacement?.top ?? 0,
2350
+ maxHeight: quickAddPlacement?.maxHeight,
2351
+ overflowY: "auto",
2352
+ visibility: quickAddPlacement ? "visible" : "hidden"
2353
+ };
2242
2354
  const onNodesChange = useCallback((changes) => {
2243
2355
  setRfNodes((current) => applyNodeChanges(changes, current));
2244
2356
  }, []);
@@ -2620,8 +2732,9 @@ function FlowsWorkspace({
2620
2732
  /* @__PURE__ */ jsxs11(
2621
2733
  "div",
2622
2734
  {
2735
+ ref: quickAddMenuRef,
2623
2736
  className: "fixed z-50 w-64 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1",
2624
- style: { left: quickAddFrom.anchor.x + 12, top: quickAddFrom.anchor.y },
2737
+ style: quickAddMenuStyle,
2625
2738
  children: [
2626
2739
  /* @__PURE__ */ jsx12("p", { className: "px-3 py-1.5 text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500", children: labels.quickAdd.title }),
2627
2740
  /* @__PURE__ */ jsx12(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adatechnology/conversations-ui",
3
- "version": "0.1.0-rc.41",
3
+ "version": "0.1.0-rc.42",
4
4
  "description": "WhatsApp conversation UI components — parametrizável por endpoint, tema e feature flags",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -1,5 +1,7 @@
1
- import { useEffect, useRef, useState } from 'react'
1
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
2
2
  import { Plus, MessageCircleQuestion, GitBranch, Zap, Diamond, ChevronRight } from 'lucide-react'
3
+
4
+ import { placeFloatingPanel, type FloatingPlacement } from './flowMenuPlacement'
3
5
  import { DEFAULT_FLOW_EDITOR_LABELS, type FlowEditorLabels } from './labels'
4
6
  import type { FlowActionKind, FlowQuestionType } from './flowGraph'
5
7
 
@@ -45,6 +47,43 @@ export function FlowPaletteMenu({ onSelect, labels, actionOptions }: FlowPalette
45
47
  { actionKind: 'handoff', label: labels.actionKindLabels.handoff ?? 'Encaminhar para atendimento' },
46
48
  ]
47
49
  const [submenu, setSubmenu] = useState<'question' | 'action' | null>(null)
50
+ const questionTriggerRef = useRef<HTMLButtonElement>(null)
51
+ const actionTriggerRef = useRef<HTMLButtonElement>(null)
52
+ const submenuRef = useRef<HTMLDivElement>(null)
53
+ const [placement, setPlacement] = useState<FloatingPlacement | null>(null)
54
+
55
+ // `fixed` posicionado por medição, e não preso ao item por CSS: alinhado sempre à direita e ao
56
+ // topo do item, o submenu era cortado pela borda da tela — e sem rolagem os últimos itens ficavam
57
+ // inalcançáveis. Roda antes da pintura, então não pisca.
58
+ useLayoutEffect(() => {
59
+ if (!submenu) {
60
+ setPlacement(null)
61
+ return
62
+ }
63
+ const trigger = submenu === 'question' ? questionTriggerRef.current : actionTriggerRef.current
64
+ const panel = submenuRef.current
65
+ if (!trigger || !panel) return
66
+ const anchor = trigger.getBoundingClientRect()
67
+ setPlacement(
68
+ placeFloatingPanel({
69
+ anchor: { left: anchor.left, top: anchor.top, right: anchor.right, bottom: anchor.bottom },
70
+ panel: { width: panel.offsetWidth, height: panel.scrollHeight },
71
+ viewport: { width: window.innerWidth, height: window.innerHeight },
72
+ prefer: 'side',
73
+ }),
74
+ )
75
+ }, [submenu])
76
+
77
+ const submenuStyle = {
78
+ position: 'fixed' as const,
79
+ left: placement?.left ?? 0,
80
+ top: placement?.top ?? 0,
81
+ maxHeight: placement?.maxHeight,
82
+ overflowY: 'auto' as const,
83
+ // Até a medição terminar o painel existe mas não aparece — senão ele pisca um quadro na
84
+ // posição errada, que é justamente o salto que esta correção remove.
85
+ visibility: placement ? ('visible' as const) : ('hidden' as const),
86
+ }
48
87
 
49
88
  function select(spec: NewNodeSpec) {
50
89
  onSelect(spec)
@@ -55,6 +94,7 @@ export function FlowPaletteMenu({ onSelect, labels, actionOptions }: FlowPalette
55
94
  <>
56
95
  <div className="relative">
57
96
  <button
97
+ ref={questionTriggerRef}
58
98
  data-cv-tooltip={labels.palette.question} aria-label={labels.palette.question}
59
99
  onMouseEnter={() => setSubmenu('question')}
60
100
  onClick={() => setSubmenu(submenu === 'question' ? null : 'question')}
@@ -66,7 +106,11 @@ export function FlowPaletteMenu({ onSelect, labels, actionOptions }: FlowPalette
66
106
  <ChevronRight size={13} className="text-gray-400" />
67
107
  </button>
68
108
  {submenu === 'question' && (
69
- <div className="absolute left-full top-0 ml-1 w-56 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1">
109
+ <div
110
+ ref={submenuRef}
111
+ style={submenuStyle}
112
+ className="z-50 w-56 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1"
113
+ >
70
114
  {QUESTION_TYPES.map((qt) => (
71
115
  <button
72
116
  data-cv-tooltip={labels.questionTypeLabels[qt]} aria-label={labels.questionTypeLabels[qt]}
@@ -101,6 +145,7 @@ export function FlowPaletteMenu({ onSelect, labels, actionOptions }: FlowPalette
101
145
 
102
146
  <div className="relative">
103
147
  <button
148
+ ref={actionTriggerRef}
104
149
  data-cv-tooltip={labels.palette.action} aria-label={labels.palette.action}
105
150
  onMouseEnter={() => setSubmenu('action')}
106
151
  onClick={() => setSubmenu(submenu === 'action' ? null : 'action')}
@@ -112,7 +157,11 @@ export function FlowPaletteMenu({ onSelect, labels, actionOptions }: FlowPalette
112
157
  <ChevronRight size={13} className="text-gray-400" />
113
158
  </button>
114
159
  {submenu === 'action' && (
115
- <div className="absolute left-full top-0 ml-1 w-64 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1">
160
+ <div
161
+ ref={submenuRef}
162
+ style={submenuStyle}
163
+ className="z-50 w-64 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1"
164
+ >
116
165
  {resolvedActionOptions.map((option) => (
117
166
  <button
118
167
  data-cv-tooltip={option.label} aria-label={option.label}
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
1
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'
2
2
  import {
3
3
  ReactFlow,
4
4
  Background,
@@ -51,6 +51,7 @@ import {
51
51
  removeNodeAndCleanRefs,
52
52
  resolveConnection,
53
53
  } from './flowEditorOps'
54
+ import { placeFloatingPanel, type FloatingPlacement } from './flowMenuPlacement'
54
55
  import {
55
56
  computeAutoLayout,
56
57
  targetsOf,
@@ -103,6 +104,9 @@ const EDGE_COLOR_LINEAR = '#94a3b8'
103
104
  const FOCUS_MAX_ZOOM = 1
104
105
  const FOCUS_PADDING = 0.2
105
106
  const FOCUS_DURATION_MS = 400
107
+
108
+ /** Folga entre o "+" e o menu que ele abre. */
109
+ const QUICK_ADD_MENU_GAP = 12
106
110
  const EDGE_COLOR_BRANCH = '#8b5cf6'
107
111
  const EDGE_COLOR_FALLBACK = '#cbd5e1'
108
112
  const EDGE_COLOR_LIVE = '#3b82f6'
@@ -268,6 +272,8 @@ export function FlowsWorkspace({
268
272
  const [flowInstance, setFlowInstance] = useState<ReactFlowInstance | null>(null)
269
273
  const [pendingFocusNodeId, setPendingFocusNodeId] = useState<string | null>(null)
270
274
  const [pendingFocusFlowKey, setPendingFocusFlowKey] = useState<string | null>(null)
275
+ const quickAddMenuRef = useRef<HTMLDivElement>(null)
276
+ const [quickAddPlacement, setQuickAddPlacement] = useState<FloatingPlacement | null>(null)
271
277
 
272
278
  const reloadGraphs = useCallback(async () => {
273
279
  try {
@@ -663,6 +669,35 @@ export function FlowsWorkspace({
663
669
  setPendingFocusFlowKey(null)
664
670
  }, [pendingFocusFlowKey, flowInstance, rfNodes])
665
671
 
672
+ // O menu do "+" saía da tela quando o card estava perto da borda: a âncora era usada crua, sem
673
+ // consultar o tamanho da janela. Mede depois de montar e reposiciona antes da pintura.
674
+ useLayoutEffect(() => {
675
+ if (!quickAddFrom) {
676
+ setQuickAddPlacement(null)
677
+ return
678
+ }
679
+ const panel = quickAddMenuRef.current
680
+ if (!panel) return
681
+ const { x, y } = quickAddFrom.anchor
682
+ setQuickAddPlacement(
683
+ placeFloatingPanel({
684
+ anchor: { left: x, top: y, right: x, bottom: y },
685
+ panel: { width: panel.offsetWidth, height: panel.scrollHeight },
686
+ viewport: { width: window.innerWidth, height: window.innerHeight },
687
+ prefer: 'below',
688
+ gap: QUICK_ADD_MENU_GAP,
689
+ }),
690
+ )
691
+ }, [quickAddFrom])
692
+
693
+ const quickAddMenuStyle = {
694
+ left: quickAddPlacement?.left ?? 0,
695
+ top: quickAddPlacement?.top ?? 0,
696
+ maxHeight: quickAddPlacement?.maxHeight,
697
+ overflowY: 'auto' as const,
698
+ visibility: quickAddPlacement ? ('visible' as const) : ('hidden' as const),
699
+ }
700
+
666
701
  const onNodesChange = useCallback((changes: NodeChange[]) => {
667
702
  setRfNodes((current) => applyNodeChanges(changes, current))
668
703
  }, [])
@@ -1071,8 +1106,9 @@ export function FlowsWorkspace({
1071
1106
  <>
1072
1107
  <div className="fixed inset-0 z-40" onClick={() => setQuickAddFrom(null)} />
1073
1108
  <div
1109
+ ref={quickAddMenuRef}
1074
1110
  className="fixed z-50 w-64 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1"
1075
- style={{ left: quickAddFrom.anchor.x + 12, top: quickAddFrom.anchor.y }}
1111
+ style={quickAddMenuStyle}
1076
1112
  >
1077
1113
  <p className="px-3 py-1.5 text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
1078
1114
  {labels.quickAdd.title}
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Copyright (c) 2026 Ada Technology. MIT License.
3
+ *
4
+ * O caso que originou isto: o submenu "Ação" aberto perto do rodapé mostrava metade da lista, com
5
+ * "Enviar catálogo de produtos" cortado pela borda — e sem rolagem, o item era inalcançável.
6
+ */
7
+
8
+ import { describe, expect, it } from 'bun:test'
9
+
10
+ import { placeFloatingPanel } from './flowMenuPlacement'
11
+
12
+ const VIEWPORT = { width: 1200, height: 800 }
13
+ const rect = (left: number, top: number, width = 0, height = 0) => ({
14
+ left,
15
+ top,
16
+ right: left + width,
17
+ bottom: top + height,
18
+ })
19
+
20
+ describe('menu abaixo do ponto clicado', () => {
21
+ it('fica onde foi pedido quando há espaço', () => {
22
+ const placement = placeFloatingPanel({
23
+ anchor: rect(100, 100),
24
+ panel: { width: 256, height: 300 },
25
+ viewport: VIEWPORT,
26
+ prefer: 'below',
27
+ })
28
+
29
+ expect(placement.left).toBeGreaterThanOrEqual(100)
30
+ expect(placement.top).toBe(100)
31
+ expect(placement.maxHeight).toBe(300)
32
+ })
33
+
34
+ it('encosta na borda direita em vez de sair dela', () => {
35
+ const placement = placeFloatingPanel({
36
+ anchor: rect(1150, 100),
37
+ panel: { width: 256, height: 300 },
38
+ viewport: VIEWPORT,
39
+ prefer: 'below',
40
+ })
41
+
42
+ expect(placement.left + 256).toBeLessThanOrEqual(VIEWPORT.width)
43
+ })
44
+
45
+ it('sobe quando não cabe para baixo, em vez de vazar pelo rodapé', () => {
46
+ const placement = placeFloatingPanel({
47
+ anchor: rect(100, 700),
48
+ panel: { width: 256, height: 300 },
49
+ viewport: VIEWPORT,
50
+ prefer: 'below',
51
+ })
52
+
53
+ expect(placement.top + placement.maxHeight).toBeLessThanOrEqual(VIEWPORT.height)
54
+ expect(placement.top).toBeLessThan(700)
55
+ })
56
+ })
57
+
58
+ describe('submenu ao lado do item', () => {
59
+ it('abre à direita quando cabe', () => {
60
+ const placement = placeFloatingPanel({
61
+ anchor: rect(300, 200, 256, 36),
62
+ panel: { width: 256, height: 240 },
63
+ viewport: VIEWPORT,
64
+ prefer: 'side',
65
+ })
66
+
67
+ expect(placement.left).toBeGreaterThanOrEqual(300 + 256)
68
+ })
69
+
70
+ it('vira para a esquerda quando não cabe à direita', () => {
71
+ const placement = placeFloatingPanel({
72
+ anchor: rect(900, 200, 256, 36),
73
+ panel: { width: 256, height: 240 },
74
+ viewport: VIEWPORT,
75
+ prefer: 'side',
76
+ })
77
+
78
+ expect(placement.left).toBeLessThan(900)
79
+ expect(placement.left).toBeGreaterThanOrEqual(0)
80
+ })
81
+
82
+ it('lista longa perto do rodapé ganha rolagem em vez de ser cortada', () => {
83
+ // O caso da captura: submenu de ações mais alto que o espaço abaixo do item.
84
+ const placement = placeFloatingPanel({
85
+ anchor: rect(300, 620, 256, 36),
86
+ panel: { width: 256, height: 520 },
87
+ viewport: VIEWPORT,
88
+ prefer: 'side',
89
+ })
90
+
91
+ expect(placement.top + placement.maxHeight).toBeLessThanOrEqual(VIEWPORT.height)
92
+ expect(placement.maxHeight).toBeLessThanOrEqual(520)
93
+ })
94
+
95
+ it('painel mais alto que a tela cabe inteiro na tela, rolando por dentro', () => {
96
+ const placement = placeFloatingPanel({
97
+ anchor: rect(300, 400, 256, 36),
98
+ panel: { width: 256, height: 2000 },
99
+ viewport: VIEWPORT,
100
+ prefer: 'side',
101
+ })
102
+
103
+ expect(placement.top).toBeGreaterThanOrEqual(0)
104
+ expect(placement.maxHeight).toBeLessThan(VIEWPORT.height)
105
+ expect(placement.top + placement.maxHeight).toBeLessThanOrEqual(VIEWPORT.height)
106
+ })
107
+ })
108
+
109
+ describe('os menus do editor usam a conta acima', () => {
110
+ /**
111
+ * A função pura pode estar correta e ninguém chamá-la. Estes dois amarram o uso: sem eles a
112
+ * correção some no primeiro refactor e o menu volta a encostar na borda.
113
+ */
114
+ it('o menu do "+" mede antes de posicionar, em vez de usar a âncora crua', async () => {
115
+ const content = await Bun.file(`${import.meta.dir}/FlowsWorkspace.tsx`).text()
116
+
117
+ expect(content).toContain('placeFloatingPanel')
118
+ expect(content).not.toContain('left: quickAddFrom.anchor.x + 12')
119
+ })
120
+
121
+ it('o submenu não fica mais preso ao item por CSS', async () => {
122
+ const content = await Bun.file(`${import.meta.dir}/FlowPalette.tsx`).text()
123
+
124
+ expect(content).toContain('placeFloatingPanel')
125
+ // Verifica o uso em JSX, não a menção: o comentário do arquivo cita a classe antiga para
126
+ // explicar o que mudou, e casar com o texto solto reprovaria a própria explicação.
127
+ expect(content).not.toContain('className="absolute left-full')
128
+ expect(content).toContain('overflowY')
129
+ })
130
+ })
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Copyright (c) 2026 Ada Technology. MIT License.
3
+ *
4
+ * Onde um menu flutuante do editor pode aparecer sem sair da tela.
5
+ *
6
+ * Existe porque tanto o menu do "+" quanto os submenus dele eram posicionados por uma âncora crua:
7
+ * o menu em `fixed` na coordenada do clique, os submenus em `left-full top-0`. Card perto da borda
8
+ * direita jogava o menu para fora; item de ação perto do rodapé cortava a lista no meio, e não havia
9
+ * como rolar nem alcançar o resto — o menu ficava preso contra a borda.
10
+ *
11
+ * A conta é pura de propósito: é ela que decide se o operador consegue clicar na opção, e testá-la
12
+ * exige apenas números.
13
+ */
14
+
15
+ /** Retângulo do gatilho. Um clique é um retângulo de tamanho zero. */
16
+ export type AnchorRect = {
17
+ readonly left: number
18
+ readonly top: number
19
+ readonly right: number
20
+ readonly bottom: number
21
+ }
22
+
23
+ export type PanelSize = {
24
+ readonly width: number
25
+ readonly height: number
26
+ }
27
+
28
+ export type ViewportSize = {
29
+ readonly width: number
30
+ readonly height: number
31
+ }
32
+
33
+ export type FloatingPlacement = {
34
+ readonly left: number
35
+ readonly top: number
36
+ /** Teto de altura: acima disso o painel rola por dentro em vez de vazar pelo rodapé. */
37
+ readonly maxHeight: number
38
+ }
39
+
40
+ export type PlaceFloatingPanelParams = {
41
+ readonly anchor: AnchorRect
42
+ readonly panel: PanelSize
43
+ readonly viewport: ViewportSize
44
+ /** `side`: submenu sai ao lado do item; `below`: menu sai abaixo do ponto clicado. */
45
+ readonly prefer: 'side' | 'below'
46
+ readonly gap?: number
47
+ readonly margin?: number
48
+ }
49
+
50
+ const DEFAULT_GAP = 4
51
+ const DEFAULT_MARGIN = 8
52
+
53
+ function clamp(value: number, minimum: number, maximum: number): number {
54
+ // `maximum` menor que `minimum` acontece com painel maior que a viewport: a margem de cima manda,
55
+ // porque cortar em cima esconde o começo da lista.
56
+ return Math.max(minimum, Math.min(value, Math.max(minimum, maximum)))
57
+ }
58
+
59
+ export function placeFloatingPanel({
60
+ anchor,
61
+ panel,
62
+ viewport,
63
+ prefer,
64
+ gap = DEFAULT_GAP,
65
+ margin = DEFAULT_MARGIN,
66
+ }: PlaceFloatingPanelParams): FloatingPlacement {
67
+ const maxHeight = Math.min(panel.height, viewport.height - margin * 2)
68
+
69
+ let left: number
70
+ if (prefer === 'side') {
71
+ // Vira para a esquerda quando não cabe à direita — o inverso de escolher sempre um lado.
72
+ const toTheRight = anchor.right + gap
73
+ const toTheLeft = anchor.left - gap - panel.width
74
+ left = toTheRight + panel.width <= viewport.width - margin ? toTheRight : toTheLeft
75
+ } else {
76
+ left = anchor.left + gap
77
+ }
78
+ left = clamp(left, margin, viewport.width - margin - panel.width)
79
+
80
+ const top = prefer === 'side' ? anchor.top : anchor.bottom
81
+ return {
82
+ left,
83
+ top: clamp(top, margin, viewport.height - margin - maxHeight),
84
+ maxHeight,
85
+ }
86
+ }