@multiplatform.one/forms 7.21.0 → 7.23.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 (103) hide show
  1. package/dist/cjs/FloatingPanel/index.cjs +85 -29
  2. package/dist/cjs/FloatingPanel/index.native.js +108 -11
  3. package/dist/cjs/FloatingPanel/index.native.js.map +1 -1
  4. package/dist/cjs/FloatingPanel/pointAnchor.cjs +88 -0
  5. package/dist/cjs/FloatingPanel/pointAnchor.native.js +98 -0
  6. package/dist/cjs/FloatingPanel/pointAnchor.native.js.map +1 -0
  7. package/dist/cjs/FloatingPanel/useFloatingPanel.cjs +4 -3
  8. package/dist/cjs/FloatingPanel/useFloatingPanel.native.js +4 -3
  9. package/dist/cjs/FloatingPanel/useFloatingPanel.native.js.map +1 -1
  10. package/dist/cjs/InputParts/index.cjs +9 -1
  11. package/dist/cjs/InputParts/index.native.js +9 -1
  12. package/dist/cjs/InputParts/index.native.js.map +1 -1
  13. package/dist/cjs/fields/Combobox/index.cjs +0 -2
  14. package/dist/cjs/fields/Combobox/index.native.js +0 -2
  15. package/dist/cjs/fields/Combobox/index.native.js.map +1 -1
  16. package/dist/cjs/fields/Select/index.cjs +2 -4
  17. package/dist/cjs/fields/Select/index.native.js +2 -4
  18. package/dist/cjs/fields/Select/index.native.js.map +1 -1
  19. package/dist/cjs/shared/utils.cjs +2 -1
  20. package/dist/cjs/shared/utils.native.js +2 -1
  21. package/dist/cjs/shared/utils.native.js.map +1 -1
  22. package/dist/esm/FloatingPanel/index.mjs +86 -30
  23. package/dist/esm/FloatingPanel/index.mjs.map +1 -1
  24. package/dist/esm/FloatingPanel/index.native.js +107 -10
  25. package/dist/esm/FloatingPanel/index.native.js.map +1 -1
  26. package/dist/esm/FloatingPanel/pointAnchor.mjs +59 -0
  27. package/dist/esm/FloatingPanel/pointAnchor.mjs.map +1 -0
  28. package/dist/esm/FloatingPanel/pointAnchor.native.js +66 -0
  29. package/dist/esm/FloatingPanel/pointAnchor.native.js.map +1 -0
  30. package/dist/esm/FloatingPanel/useFloatingPanel.mjs +4 -3
  31. package/dist/esm/FloatingPanel/useFloatingPanel.mjs.map +1 -1
  32. package/dist/esm/FloatingPanel/useFloatingPanel.native.js +4 -3
  33. package/dist/esm/FloatingPanel/useFloatingPanel.native.js.map +1 -1
  34. package/dist/esm/InputParts/index.mjs +10 -2
  35. package/dist/esm/InputParts/index.mjs.map +1 -1
  36. package/dist/esm/InputParts/index.native.js +10 -2
  37. package/dist/esm/InputParts/index.native.js.map +1 -1
  38. package/dist/esm/fields/Combobox/index.mjs +0 -2
  39. package/dist/esm/fields/Combobox/index.mjs.map +1 -1
  40. package/dist/esm/fields/Combobox/index.native.js +0 -2
  41. package/dist/esm/fields/Combobox/index.native.js.map +1 -1
  42. package/dist/esm/fields/Select/index.mjs +2 -4
  43. package/dist/esm/fields/Select/index.mjs.map +1 -1
  44. package/dist/esm/fields/Select/index.native.js +2 -4
  45. package/dist/esm/fields/Select/index.native.js.map +1 -1
  46. package/dist/esm/shared/utils.mjs +2 -1
  47. package/dist/esm/shared/utils.mjs.map +1 -1
  48. package/dist/esm/shared/utils.native.js +2 -1
  49. package/dist/esm/shared/utils.native.js.map +1 -1
  50. package/dist/jsx/FloatingPanel/index.mjs +86 -30
  51. package/dist/jsx/FloatingPanel/index.mjs.map +1 -1
  52. package/dist/jsx/FloatingPanel/index.native.js +108 -11
  53. package/dist/jsx/FloatingPanel/index.native.js.map +1 -1
  54. package/dist/jsx/FloatingPanel/pointAnchor.mjs +59 -0
  55. package/dist/jsx/FloatingPanel/pointAnchor.mjs.map +1 -0
  56. package/dist/jsx/FloatingPanel/pointAnchor.native.js +98 -0
  57. package/dist/jsx/FloatingPanel/pointAnchor.native.js.map +1 -0
  58. package/dist/jsx/FloatingPanel/useFloatingPanel.mjs +4 -3
  59. package/dist/jsx/FloatingPanel/useFloatingPanel.mjs.map +1 -1
  60. package/dist/jsx/FloatingPanel/useFloatingPanel.native.js +4 -3
  61. package/dist/jsx/FloatingPanel/useFloatingPanel.native.js.map +1 -1
  62. package/dist/jsx/InputParts/index.mjs +10 -2
  63. package/dist/jsx/InputParts/index.mjs.map +1 -1
  64. package/dist/jsx/InputParts/index.native.js +9 -1
  65. package/dist/jsx/InputParts/index.native.js.map +1 -1
  66. package/dist/jsx/fields/Combobox/index.mjs +0 -2
  67. package/dist/jsx/fields/Combobox/index.mjs.map +1 -1
  68. package/dist/jsx/fields/Combobox/index.native.js +0 -2
  69. package/dist/jsx/fields/Combobox/index.native.js.map +1 -1
  70. package/dist/jsx/fields/Select/index.mjs +2 -4
  71. package/dist/jsx/fields/Select/index.mjs.map +1 -1
  72. package/dist/jsx/fields/Select/index.native.js +2 -4
  73. package/dist/jsx/fields/Select/index.native.js.map +1 -1
  74. package/dist/jsx/shared/utils.mjs +2 -1
  75. package/dist/jsx/shared/utils.mjs.map +1 -1
  76. package/dist/jsx/shared/utils.native.js +2 -1
  77. package/dist/jsx/shared/utils.native.js.map +1 -1
  78. package/package.json +18 -8
  79. package/src/FloatingPanel/FloatingPanel.pointMenu.native.spec.tsx +126 -0
  80. package/src/FloatingPanel/index.native.tsx +199 -81
  81. package/src/FloatingPanel/index.tsx +130 -42
  82. package/src/FloatingPanel/pointAnchor.spec.ts +141 -0
  83. package/src/FloatingPanel/pointAnchor.ts +122 -0
  84. package/src/FloatingPanel/useFloatingPanel.ts +9 -2
  85. package/src/InputParts/index.tsx +20 -1
  86. package/src/fieldFamilyStories.radius.spec.tsx +23 -15
  87. package/src/fields/Combobox/index.tsx +0 -2
  88. package/src/fields/Input/Input.spec.tsx +23 -0
  89. package/src/fields/Select/index.tsx +6 -4
  90. package/src/shared/utils.ts +7 -2
  91. package/types/FloatingPanel/index.d.ts +4 -1
  92. package/types/FloatingPanel/index.d.ts.map +1 -1
  93. package/types/FloatingPanel/index.native.d.ts +5 -0
  94. package/types/FloatingPanel/index.native.d.ts.map +1 -1
  95. package/types/FloatingPanel/pointAnchor.d.ts +66 -0
  96. package/types/FloatingPanel/pointAnchor.d.ts.map +1 -0
  97. package/types/FloatingPanel/useFloatingPanel.d.ts +7 -1
  98. package/types/FloatingPanel/useFloatingPanel.d.ts.map +1 -1
  99. package/types/InputParts/index.d.ts.map +1 -1
  100. package/types/fields/Combobox/index.d.ts.map +1 -1
  101. package/types/fields/Select/index.d.ts.map +1 -1
  102. package/types/shared/utils.d.ts +1 -0
  103. package/types/shared/utils.d.ts.map +1 -1
@@ -8,6 +8,7 @@ import {
8
8
  useSyncExternalStore,
9
9
  } from "react";
10
10
  import {
11
+ OVERLAY_ANCHOR_GAP,
11
12
  OVERLAY_ATTACH_GAP,
12
13
  OVERLAY_BREAKPOINT,
13
14
  useResolvedKnobs,
@@ -24,6 +25,14 @@ import type { PanelWidthMode } from "./useFloatingPanel";
24
25
  import { zIndex } from "../shared/zIndex";
25
26
  import { isNestedPanelAction, type PanelActionEvent } from "./nestedAction";
26
27
  import { PanelPortal } from "./PanelPortal";
28
+ import {
29
+ contextMenuPoint,
30
+ pointOpensUp,
31
+ pointPanelLeft,
32
+ referenceCornerPoint,
33
+ type ContextMenuEventLike,
34
+ type PanelPoint,
35
+ } from "./pointAnchor";
27
36
 
28
37
  type PointerOpenEvent = PanelActionEvent & { button?: number; target?: unknown };
29
38
 
@@ -152,7 +161,10 @@ export interface FloatingPanelProps {
152
161
  * How the trigger opens the panel. `"press"` (default) is click/tap.
153
162
  * `"contextmenu"` is the ContextMenu gesture: web `contextmenu` (right-click
154
163
  * / Shift+F10), native long-press. Primary click does not open. Same overlay
155
- * stack — not a second positioning engine.
164
+ * stack — not a second positioning engine. The floating panel opens AT the
165
+ * gesture's point as a free overlay (`OVERLAY_ANCHOR_GAP` from it, flipping
166
+ * at the viewport edges); a keyboard open has no point and opens from the
167
+ * trigger's bottom start corner.
156
168
  */
157
169
  openOn?: "press" | "contextmenu";
158
170
  }
@@ -202,15 +214,15 @@ function useKeyboardFocusIntoPanel(
202
214
  }
203
215
 
204
216
  /**
205
- * Viewport y of the box a `position: fixed` panel is laid out against. The
206
- * top math above is in viewport coordinates, which is only right while that
217
+ * Viewport origin of the box a `position: fixed` panel is laid out against.
218
+ * The top/left math is in viewport coordinates, which is only right while that
207
219
  * box is the viewport. An ancestor with a transform, filter, perspective,
208
220
  * paint containment or container query becomes the containing block instead.
209
221
  * PanelPortal lifts the panel out of scroll views, but a panel hosted in a
210
222
  * transformed dialog still lays out against that dialog. A `display: contents`
211
223
  * node has no box, so its computed `contain` (Tamagui's theme span) is ignored.
212
224
  */
213
- function fixedContainingBlockTop(element: HTMLElement): number {
225
+ function fixedContainingBlockOrigin(element: HTMLElement): { top: number; left: number } {
214
226
  for (let node = element.parentElement; node; node = node.parentElement) {
215
227
  const cs = getComputedStyle(node);
216
228
  if (cs.display === "contents") continue;
@@ -223,16 +235,27 @@ function fixedContainingBlockTop(element: HTMLElement): number {
223
235
  set(cs.containerType, "normal") ||
224
236
  /transform|perspective|filter/.test(cs.willChange) ||
225
237
  /paint|layout|strict|content/.test(cs.contain);
226
- if (captures) return node.getBoundingClientRect().top + node.clientTop - node.scrollTop;
238
+ if (captures) {
239
+ const rect = node.getBoundingClientRect();
240
+ return {
241
+ top: rect.top + node.clientTop - node.scrollTop,
242
+ left: rect.left + node.clientLeft - node.scrollLeft,
243
+ };
244
+ }
227
245
  }
228
- return 0;
246
+ return { top: 0, left: 0 };
229
247
  }
230
248
 
231
- function panelTransformOrigin(placement: string): string {
232
- const rtl =
249
+ function isRtl(): boolean {
250
+ return (
233
251
  isWeb &&
234
252
  typeof document !== "undefined" &&
235
- document.documentElement.getAttribute("dir") === "rtl";
253
+ document.documentElement.getAttribute("dir") === "rtl"
254
+ );
255
+ }
256
+
257
+ function panelTransformOrigin(placement: string): string {
258
+ const rtl = isRtl();
236
259
  const atStart = !placement.endsWith("end");
237
260
  const originX = atStart === !rtl ? "left" : "right";
238
261
  const originY = placement.startsWith("top") ? "bottom" : "top";
@@ -457,7 +480,8 @@ function FloatingPanelWeb({
457
480
  openOn = "press",
458
481
  }: FloatingPanelProps) {
459
482
  const isFill = sizing === "fill";
460
- const overlayStyleRef = useRef<{ top: number; maxHeight: number } | null>(null);
483
+ const isContextMenu = openOn === "contextmenu";
484
+ const overlayStyleRef = useRef<{ top: number; left?: number; maxHeight: number } | null>(null);
461
485
  const pinnedTopRef = useRef(0);
462
486
  const pinnedHeightRef = useRef(0);
463
487
  const grownRef = useRef(false);
@@ -466,27 +490,33 @@ function FloatingPanelWeb({
466
490
  const lastScrollTopRef = useRef(0);
467
491
  const keyboardOpenRef = useRef(false);
468
492
  const contentRef = useRef<HTMLElement | null>(null);
493
+ const anchorPointRef = useRef<PanelPoint | null>(null);
494
+ const pointPlacementRef = useRef<{ dropup: boolean; alignEnd: boolean } | null>(null);
469
495
 
470
496
  const repositionFnRef = useRef<(() => void) | null>(null);
471
497
 
498
+ const resetPosition = () => {
499
+ overlayStyleRef.current = null;
500
+ pinnedTopRef.current = 0;
501
+ pinnedHeightRef.current = 0;
502
+ grownRef.current = false;
503
+ growUpOnlyRef.current = false;
504
+ growDownOnlyRef.current = false;
505
+ lastScrollTopRef.current = 0;
506
+ pointPlacementRef.current = null;
507
+ };
508
+
472
509
  const panel = useFloatingPanel({
473
510
  open,
474
511
  onOpenChange,
475
512
  disabled,
476
513
  repositionRef: repositionFnRef,
477
514
  wheelGrowRef: isFill ? undefined : grownRef,
478
- widthMode,
515
+ widthMode: widthMode ?? (isContextMenu && fitContent === undefined ? "fit-content" : undefined),
479
516
  fitContent,
480
517
  compact,
481
- onBeforeOpen: () => {
482
- overlayStyleRef.current = null;
483
- pinnedTopRef.current = 0;
484
- pinnedHeightRef.current = 0;
485
- grownRef.current = false;
486
- growUpOnlyRef.current = false;
487
- growDownOnlyRef.current = false;
488
- lastScrollTopRef.current = 0;
489
- },
518
+ freeOverlay: isContextMenu,
519
+ onBeforeOpen: resetPosition,
490
520
  });
491
521
 
492
522
  // DG-A11Y-04 / Axiom 7: floating-ui useDismiss has no focus manager — when
@@ -496,7 +526,9 @@ function FloatingPanelWeb({
496
526
  useReturnFocusOnClose(open, skipReturnFocusRef);
497
527
  useKeyboardFocusIntoPanel(open && panel.mounted, panel.refs.floating, keyboardOpenRef);
498
528
  useEffect(() => {
499
- if (!open) keyboardOpenRef.current = false;
529
+ if (open) return;
530
+ keyboardOpenRef.current = false;
531
+ anchorPointRef.current = null;
500
532
  }, [open]);
501
533
 
502
534
  const repositionOverlay = useCallback(() => {
@@ -511,7 +543,10 @@ function FloatingPanelWeb({
511
543
  dropdownElement.scrollTop = 0;
512
544
  }
513
545
 
514
- const triggerBounds = trig.getBoundingClientRect();
546
+ const point = isContextMenu
547
+ ? (anchorPointRef.current ?? referenceCornerPoint(trig, isRtl()))
548
+ : null;
549
+ const triggerBounds = point ? { top: point.y, bottom: point.y } : trig.getBoundingClientRect();
515
550
  const viewportHeight = window.innerHeight;
516
551
  const cs = getComputedStyle(dropdownElement);
517
552
  const borderTopWidth = parseFloat(cs.borderTopWidth) || 0;
@@ -520,13 +555,39 @@ function FloatingPanelWeb({
520
555
  const scrollContentHeight = dropdownElement.scrollHeight;
521
556
  const totalContentHeight = scrollContentHeight + totalBorderWidth;
522
557
  const liveTransition = panel.knobProps.transition ? panelTransition : "none";
523
-
524
- const isDropup = panel.placement.startsWith("top");
558
+ const containingBlock = fixedContainingBlockOrigin(dropdownElement);
559
+
560
+ let isDropup = panel.placement.startsWith("top");
561
+ let cssLeft: number | undefined;
562
+ if (point) {
563
+ const bounds = {
564
+ top: panelViewportPadding,
565
+ left: panelViewportPadding,
566
+ right: (document.documentElement.clientWidth || window.innerWidth) - panelViewportPadding,
567
+ bottom: viewportHeight - panelViewportPadding,
568
+ };
569
+ // Measured at the viewport's left edge: a fixed box near the right edge
570
+ // shrinks to the room left of it, and that is not the menu's width.
571
+ // offsetWidth, not the client rect: the opening frame is scaled 0.96.
572
+ dropdownElement.style.left = `${-containingBlock.left}px`;
573
+ const width = dropdownElement.offsetWidth;
574
+ const { left, alignEnd } = pointPanelLeft(point, width, bounds, isRtl());
575
+ cssLeft = left - containingBlock.left;
576
+ dropdownElement.style.left = `${cssLeft}px`;
577
+ pointPlacementRef.current = {
578
+ dropup:
579
+ pointPlacementRef.current?.dropup ??
580
+ pointOpensUp(point, totalContentHeight, bounds, OVERLAY_ANCHOR_GAP),
581
+ alignEnd,
582
+ };
583
+ isDropup = pointPlacementRef.current.dropup;
584
+ }
525
585
  // Cover ruling (MPO-38): the panel ATTACHES to the trigger it was opened
526
586
  // from. `OVERLAY_ATTACH_GAP` is 0, so the panel's top edge coincides with
527
587
  // the trigger's bottom edge (and its bottom with the trigger's top when
528
- // dropping up) instead of floating 4px clear of it.
529
- const gap = OVERLAY_ATTACH_GAP;
588
+ // dropping up) instead of floating 4px clear of it. A context menu opened
589
+ // at a point is a FREE overlay and keeps `OVERLAY_ANCHOR_GAP` from it.
590
+ const gap = point ? OVERLAY_ANCHOR_GAP : OVERLAY_ATTACH_GAP;
530
591
  const belowTop = triggerBounds.bottom + gap;
531
592
  const aboveBottom = triggerBounds.top - gap;
532
593
 
@@ -537,14 +598,19 @@ function FloatingPanelWeb({
537
598
  let height = Math.min(totalContentHeight, Math.max(0, available));
538
599
  height = Math.max(Math.min(120, Math.max(0, available)), height);
539
600
  const top = isDropup ? aboveBottom - height : belowTop;
540
- const cssTop = top - fixedContainingBlockTop(dropdownElement);
601
+ const cssTop = top - containingBlock.top;
541
602
 
542
603
  const cached = overlayStyleRef.current;
543
- if (!cached || cached.top !== cssTop || cached.maxHeight !== height) {
604
+ if (
605
+ !cached ||
606
+ cached.top !== cssTop ||
607
+ cached.left !== cssLeft ||
608
+ cached.maxHeight !== height
609
+ ) {
544
610
  dropdownElement.style.top = `${cssTop}px`;
545
611
  dropdownElement.style.maxHeight = `${height}px`;
546
612
  dropdownElement.style.height = "";
547
- overlayStyleRef.current = { top: cssTop, maxHeight: height };
613
+ overlayStyleRef.current = { top: cssTop, left: cssLeft, maxHeight: height };
548
614
  void dropdownElement.offsetHeight;
549
615
  }
550
616
 
@@ -611,13 +677,18 @@ function FloatingPanelWeb({
611
677
  }
612
678
  }
613
679
 
614
- const cssTop = top - fixedContainingBlockTop(dropdownElement);
680
+ const cssTop = top - containingBlock.top;
615
681
  const cached = overlayStyleRef.current;
616
- if (!cached || cached.top !== cssTop || cached.maxHeight !== height) {
682
+ if (
683
+ !cached ||
684
+ cached.top !== cssTop ||
685
+ cached.left !== cssLeft ||
686
+ cached.maxHeight !== height
687
+ ) {
617
688
  dropdownElement.style.top = `${cssTop}px`;
618
689
  dropdownElement.style.maxHeight = `${height}px`;
619
690
  dropdownElement.style.height = "";
620
- overlayStyleRef.current = { top: cssTop, maxHeight: height };
691
+ overlayStyleRef.current = { top: cssTop, left: cssLeft, maxHeight: height };
621
692
  void dropdownElement.offsetHeight;
622
693
  }
623
694
 
@@ -634,6 +705,7 @@ function FloatingPanelWeb({
634
705
  });
635
706
  }, [
636
707
  isFill,
708
+ isContextMenu,
637
709
  panel.placement,
638
710
  panel.refs,
639
711
  panel.triggerRef,
@@ -643,7 +715,6 @@ function FloatingPanelWeb({
643
715
 
644
716
  repositionFnRef.current = repositionOverlay;
645
717
 
646
- const isContextMenu = openOn === "contextmenu";
647
718
  const click = useClick(panel.context, {
648
719
  event: "mousedown",
649
720
  enabled: !isContextMenu,
@@ -756,14 +827,27 @@ function FloatingPanelWeb({
756
827
  [disabled, isContextMenu, onOpenChange],
757
828
  );
758
829
 
759
- const openFromContextMenu = useCallback(
760
- (event: { preventDefault: () => void }) => {
761
- event.preventDefault();
762
- if (disabled) return;
763
- onOpenChange(true);
830
+ const openFromContextMenu = (
831
+ event: ContextMenuEventLike & {
832
+ preventDefault: () => void;
833
+ nativeEvent?: ContextMenuEventLike;
764
834
  },
765
- [disabled, onOpenChange],
766
- );
835
+ ) => {
836
+ event.preventDefault();
837
+ if (disabled) return;
838
+ anchorPointRef.current = contextMenuPoint(
839
+ event.nativeEvent ?? event,
840
+ panel.triggerRef.current,
841
+ isRtl(),
842
+ );
843
+ if (!open) {
844
+ onOpenChange(true);
845
+ return;
846
+ }
847
+ resetPosition();
848
+ panel.initializingRef.current = true;
849
+ repositionOverlay();
850
+ };
767
851
 
768
852
  // useClick's mousedown opener calls preventDefault, so a pointer open never
769
853
  // focuses the trigger the way a pressed button is focused. The render that
@@ -827,7 +911,7 @@ function FloatingPanelWeb({
827
911
  ...elevatedSurfaceStyle,
828
912
  position: panel.strategy,
829
913
  top: overlayStyleRef.current?.top ?? panel.y ?? 0,
830
- left: panel.x ?? 0,
914
+ left: overlayStyleRef.current?.left ?? panel.x ?? 0,
831
915
  scrollbarWidth: "none" as any,
832
916
  zIndex: zIndex.dropdown,
833
917
  overflow: "auto",
@@ -841,7 +925,11 @@ function FloatingPanelWeb({
841
925
  // then positions against this box and is clipped by its scroll.
842
926
  transform: panel.visible ? "none" : "scale(0.96)",
843
927
  transition: webTransition,
844
- transformOrigin: panelTransformOrigin(panel.placement),
928
+ transformOrigin: panelTransformOrigin(
929
+ pointPlacementRef.current
930
+ ? `${pointPlacementRef.current.dropup ? "top" : "bottom"}-${pointPlacementRef.current.alignEnd ? "end" : "start"}`
931
+ : panel.placement,
932
+ ),
845
933
  },
846
934
  onScroll: handleScroll,
847
935
  onTransitionEnd: (event) => {
@@ -0,0 +1,141 @@
1
+ import { OVERLAY_ANCHOR_GAP } from "@multiplatform.one/theme";
2
+ import { describe, expect, it } from "vitest";
3
+ import {
4
+ contextMenuPoint,
5
+ placeAtPoint,
6
+ pointOpensUp,
7
+ pointPanelLeft,
8
+ type PanelBounds,
9
+ } from "./pointAnchor";
10
+
11
+ const bounds: PanelBounds = { top: 10, left: 10, right: 1014, bottom: 758 };
12
+ const gap = OVERLAY_ANCHOR_GAP;
13
+
14
+ describe("pointPanelLeft", () => {
15
+ it("puts the panel's start edge on the point when it fits", () => {
16
+ expect(pointPanelLeft({ x: 300, y: 0 }, 200, bounds)).toEqual({ left: 300, alignEnd: false });
17
+ });
18
+
19
+ it("flips to open toward the start when the end side would overflow", () => {
20
+ expect(pointPanelLeft({ x: 900, y: 0 }, 200, bounds)).toEqual({ left: 700, alignEnd: true });
21
+ });
22
+
23
+ it("clamps into the bounds when neither side fits", () => {
24
+ const narrow: PanelBounds = { top: 10, left: 10, right: 310, bottom: 758 };
25
+ expect(pointPanelLeft({ x: 200, y: 0 }, 250, narrow)).toEqual({ left: 60, alignEnd: false });
26
+ });
27
+
28
+ it("pins to the start bound when the panel is wider than the bounds", () => {
29
+ const narrow: PanelBounds = { top: 10, left: 10, right: 110, bottom: 758 };
30
+ expect(pointPanelLeft({ x: 50, y: 0 }, 400, narrow).left).toBe(10);
31
+ });
32
+
33
+ it("opens toward the left from the point in RTL and flips right at the left edge", () => {
34
+ expect(pointPanelLeft({ x: 600, y: 0 }, 200, bounds, true)).toEqual({
35
+ left: 400,
36
+ alignEnd: false,
37
+ });
38
+ expect(pointPanelLeft({ x: 100, y: 0 }, 200, bounds, true)).toEqual({
39
+ left: 100,
40
+ alignEnd: true,
41
+ });
42
+ });
43
+ });
44
+
45
+ describe("pointOpensUp", () => {
46
+ it("opens down whenever the panel fits below the point", () => {
47
+ expect(pointOpensUp({ x: 0, y: 600 }, 150, bounds, gap)).toBe(false);
48
+ });
49
+
50
+ it("flips up when only the space above fits", () => {
51
+ expect(pointOpensUp({ x: 0, y: 700 }, 150, bounds, gap)).toBe(true);
52
+ });
53
+
54
+ it("takes the roomier side when neither fits", () => {
55
+ expect(pointOpensUp({ x: 0, y: 300 }, 700, bounds, gap)).toBe(false);
56
+ expect(pointOpensUp({ x: 0, y: 500 }, 700, bounds, gap)).toBe(true);
57
+ });
58
+ });
59
+
60
+ describe("placeAtPoint", () => {
61
+ it("sits OVERLAY_ANCHOR_GAP below the point, start edge on the point", () => {
62
+ expect(
63
+ placeAtPoint({ point: { x: 300, y: 200 }, width: 180, height: 120, bounds, gap }),
64
+ ).toEqual({
65
+ left: 300,
66
+ top: 200 + gap,
67
+ maxHeight: 758 - (200 + gap),
68
+ dropup: false,
69
+ alignEnd: false,
70
+ });
71
+ });
72
+
73
+ it("flips both ways in the bottom-right corner, bottom edge gap above the point", () => {
74
+ const placement = placeAtPoint({
75
+ point: { x: 1000, y: 740 },
76
+ width: 180,
77
+ height: 120,
78
+ bounds,
79
+ gap,
80
+ });
81
+ expect(placement.left).toBe(1000 - 180);
82
+ expect(placement.top).toBe(740 - gap - 120);
83
+ expect(placement.dropup).toBe(true);
84
+ expect(placement.alignEnd).toBe(true);
85
+ });
86
+
87
+ it("caps the height at the chosen side's room, so a tall menu scrolls inside the bounds", () => {
88
+ const placement = placeAtPoint({
89
+ point: { x: 300, y: 500 },
90
+ width: 180,
91
+ height: 900,
92
+ bounds,
93
+ gap,
94
+ });
95
+ expect(placement.dropup).toBe(true);
96
+ expect(placement.maxHeight).toBe(500 - gap - 10);
97
+ expect(placement.top).toBe(10);
98
+ });
99
+ });
100
+
101
+ describe("contextMenuPoint", () => {
102
+ const reference = {
103
+ getBoundingClientRect: () => ({ left: 40, right: 240, top: 100, bottom: 144 }),
104
+ };
105
+
106
+ it("takes the pointer's viewport coordinates from a mouse right-click", () => {
107
+ expect(
108
+ contextMenuPoint({ clientX: 120, clientY: 130, button: 2, pointerType: "mouse" }, reference),
109
+ ).toEqual({ x: 120, y: 130 });
110
+ });
111
+
112
+ it("takes the press point from a touch long-press", () => {
113
+ expect(
114
+ contextMenuPoint({ clientX: 90, clientY: 110, button: 0, pointerType: "touch" }, reference),
115
+ ).toEqual({ x: 90, y: 110 });
116
+ });
117
+
118
+ it("takes a right-click's coordinates when the event is a plain MouseEvent", () => {
119
+ expect(contextMenuPoint({ clientX: 150, clientY: 120, button: 2 }, reference)).toEqual({
120
+ x: 150,
121
+ y: 120,
122
+ });
123
+ });
124
+
125
+ it("anchors a keyboard open (Shift+F10, the Menu key) to the target's bottom start corner", () => {
126
+ expect(
127
+ contextMenuPoint({ clientX: 0, clientY: 0, button: 0, pointerType: "" }, reference),
128
+ ).toEqual({ x: 40, y: 144 });
129
+ expect(contextMenuPoint({ clientX: 0, clientY: 0, button: 0 }, reference)).toEqual({
130
+ x: 40,
131
+ y: 144,
132
+ });
133
+ expect(
134
+ contextMenuPoint({ clientX: 0, clientY: 0, button: 0, pointerType: "" }, reference, true),
135
+ ).toEqual({ x: 240, y: 144 });
136
+ });
137
+
138
+ it("has no point without a pointer or a reference", () => {
139
+ expect(contextMenuPoint({ clientX: 0, clientY: 0, button: 0 }, null)).toBeNull();
140
+ });
141
+ });
@@ -0,0 +1,122 @@
1
+ import { OVERLAY_ANCHOR_GAP } from "@multiplatform.one/theme";
2
+
3
+ /**
4
+ * A panel opened at a POINT rather than from a trigger's box: the ContextMenu
5
+ * gesture (MPO-21 G5). layoutTokens.ts files a pointer-positioned context menu
6
+ * as a FREE overlay: content-sized, `OVERLAY_ANCHOR_GAP` from its anchor,
7
+ * flipping at the viewport edges instead of overflowing. The anchor here is a
8
+ * zero-size box at the point, so "start-aligned to the anchor" puts the
9
+ * panel's start edge on the pointer's x.
10
+ */
11
+ export interface PanelPoint {
12
+ x: number;
13
+ y: number;
14
+ }
15
+
16
+ /** The box a panel must stay inside, already inset by padding and safe area. */
17
+ export interface PanelBounds {
18
+ top: number;
19
+ right: number;
20
+ bottom: number;
21
+ left: number;
22
+ }
23
+
24
+ export function pointPanelLeft(
25
+ point: PanelPoint,
26
+ width: number,
27
+ bounds: PanelBounds,
28
+ rtl = false,
29
+ ): { left: number; alignEnd: boolean } {
30
+ const towardEnd = rtl ? point.x - width : point.x;
31
+ const towardStart = rtl ? point.x : point.x - width;
32
+ const fits = (left: number) => left >= bounds.left && left + width <= bounds.right;
33
+ const alignEnd = !fits(towardEnd) && fits(towardStart);
34
+ const raw = alignEnd ? towardStart : towardEnd;
35
+ return { left: Math.max(bounds.left, Math.min(raw, bounds.right - width)), alignEnd };
36
+ }
37
+
38
+ /** Below when it fits, above when only that fits, else the roomier side. */
39
+ export function pointOpensUp(
40
+ point: PanelPoint,
41
+ height: number,
42
+ bounds: PanelBounds,
43
+ gap: number = OVERLAY_ANCHOR_GAP,
44
+ ): boolean {
45
+ const below = bounds.bottom - (point.y + gap);
46
+ const above = point.y - gap - bounds.top;
47
+ if (height <= below) return false;
48
+ if (height <= above) return true;
49
+ return above > below;
50
+ }
51
+
52
+ export interface PointPlacement {
53
+ left: number;
54
+ top: number;
55
+ maxHeight: number;
56
+ dropup: boolean;
57
+ alignEnd: boolean;
58
+ }
59
+
60
+ export function placeAtPoint({
61
+ point,
62
+ width,
63
+ height,
64
+ bounds,
65
+ gap = OVERLAY_ANCHOR_GAP,
66
+ rtl = false,
67
+ }: {
68
+ point: PanelPoint;
69
+ width: number;
70
+ height: number;
71
+ bounds: PanelBounds;
72
+ gap?: number;
73
+ rtl?: boolean;
74
+ }): PointPlacement {
75
+ const { left, alignEnd } = pointPanelLeft(point, width, bounds, rtl);
76
+ const dropup = pointOpensUp(point, height, bounds, gap);
77
+ const maxHeight = Math.max(
78
+ 0,
79
+ dropup ? point.y - gap - bounds.top : bounds.bottom - (point.y + gap),
80
+ );
81
+ const top = dropup ? point.y - gap - Math.min(height, maxHeight) : point.y + gap;
82
+ return { left, top, maxHeight, dropup, alignEnd };
83
+ }
84
+
85
+ export interface ContextMenuEventLike {
86
+ clientX: number;
87
+ clientY: number;
88
+ button?: number;
89
+ pointerType?: string;
90
+ }
91
+
92
+ /**
93
+ * Where a `contextmenu` event asks the menu to open. A mouse right-click or a
94
+ * touch long-press carries the pointer's viewport coordinates. A keyboard open
95
+ * (Shift+F10, the Menu key) has no pointer: Chromium sends a PointerEvent with
96
+ * an empty `pointerType` aimed at the focused element's centre
97
+ * (`EventHandler::ShowNonLocatedContextMenu`). It opens from the target's
98
+ * bottom start corner. A plain MouseEvent counts as a pointer only when it is
99
+ * a right-click or carries coordinates.
100
+ */
101
+ export function contextMenuPoint(
102
+ event: ContextMenuEventLike,
103
+ reference: PointReference | null,
104
+ rtl = false,
105
+ ): PanelPoint | null {
106
+ const fromPointer =
107
+ event.pointerType !== undefined
108
+ ? event.pointerType !== ""
109
+ : event.button === 2 || event.clientX !== 0 || event.clientY !== 0;
110
+ if (fromPointer) return { x: event.clientX, y: event.clientY };
111
+ return reference ? referenceCornerPoint(reference, rtl) : null;
112
+ }
113
+
114
+ export interface PointReference {
115
+ getBoundingClientRect(): { left: number; right: number; bottom: number };
116
+ }
117
+
118
+ /** The target's bottom start corner: where a menu with no pointer opens. */
119
+ export function referenceCornerPoint(reference: PointReference, rtl = false): PanelPoint {
120
+ const rect = reference.getBoundingClientRect();
121
+ return { x: rtl ? rect.right : rect.left, y: rect.bottom };
122
+ }
@@ -104,6 +104,12 @@ export interface UseFloatingPanelOptions {
104
104
  * at the compact space step.
105
105
  */
106
106
  compact?: boolean;
107
+ /**
108
+ * The panel opens at a point, not from the trigger's box (ContextMenu). It
109
+ * shares no corner with the trigger, so its radius stays the CONTAINER-CAP
110
+ * value instead of dropping to the trigger's drawn arc.
111
+ */
112
+ freeOverlay?: boolean;
107
113
  }
108
114
 
109
115
  /** One place that resolves the deprecated boolean onto the three modes, so
@@ -225,6 +231,7 @@ export function useFloatingPanel({
225
231
  widthMode: widthModeProp,
226
232
  fitContent,
227
233
  compact,
234
+ freeOverlay = false,
228
235
  }: UseFloatingPanelOptions): UseFloatingPanelReturn {
229
236
  const widthMode = resolvePanelWidthMode(widthModeProp, fitContent);
230
237
  const { knobProps } = useResolvedKnobs({ compact });
@@ -295,14 +302,14 @@ export function useFloatingPanel({
295
302
  const setupForOpen = useCallback(() => {
296
303
  computePlacement();
297
304
  if (canUseDom && triggerRef.current) {
298
- setMeasuredRadius(measureDrawnRadius(triggerRef.current));
305
+ setMeasuredRadius(freeOverlay ? null : measureDrawnRadius(triggerRef.current));
299
306
  }
300
307
  onBeforeOpenRef.current?.();
301
308
  if (closingTimerRef.current) clearTimeout(closingTimerRef.current);
302
309
  floatingStyleRef.current = {};
303
310
  initializingRef.current = true;
304
311
  setMounted(true);
305
- }, [computePlacement]);
312
+ }, [computePlacement, freeOverlay]);
306
313
 
307
314
  // ── Lifecycle effect ──────────────────────────────────────
308
315
 
@@ -32,7 +32,9 @@ import {
32
32
  useTouchSurface,
33
33
  isTouchSurface,
34
34
  recipeFamilies,
35
+ resolveControlRadius,
35
36
  resolveGlyphPaint,
37
+ SIZE_RECIPE_RADIUS_TOKEN_TO_STOP,
36
38
  sizeRecipeBoxVariants,
37
39
  sizeRecipeForToken,
38
40
  sizeRecipeIconVariants,
@@ -493,13 +495,29 @@ const InputGroupImpl = InputGroupFrame.styleable<{
493
495
  family: recipeFamily,
494
496
  });
495
497
  const controlFragment =
496
- recipeFamily === "control"
498
+ recipeFamily === "control" && sizeToken === knobProps.sizeToken
497
499
  ? knobProps.control
498
500
  : {
499
501
  height: familyRecipe.height,
500
502
  paddingHorizontal: familyRecipe.paddingHorizontal,
501
503
  gap: familyRecipe.gap,
502
504
  };
505
+ const radiusToken = knobProps.borderRadius.borderRadius;
506
+ const radiusStop =
507
+ typeof radiusToken === "string"
508
+ ? SIZE_RECIPE_RADIUS_TOKEN_TO_STOP[
509
+ radiusToken as keyof typeof SIZE_RECIPE_RADIUS_TOKEN_TO_STOP
510
+ ]
511
+ : undefined;
512
+ const sizeTableRadius =
513
+ radiusStop === "medium" || radiusStop == null
514
+ ? {
515
+ borderRadius: resolveControlRadius(
516
+ sizeRecipeForToken(sizeToken, { family: recipeFamily }),
517
+ "medium",
518
+ ).radius,
519
+ }
520
+ : undefined;
503
521
  const sizeRecipeEscaped =
504
522
  typeof sizeRecipeEscape === "string" && sizeRecipeEscape.trim().length > 0;
505
523
  const sizeRecipeEscapeOverride = sizeRecipeEscaped
@@ -623,6 +641,7 @@ const InputGroupImpl = InputGroupFrame.styleable<{
623
641
  backgroundColor={knobProps.inputBackground}
624
642
  {...(knobProps.elevation == null ? { elevation: undefined } : knobProps.elevationChrome)}
625
643
  {...knobProps.borderRadius}
644
+ {...sizeTableRadius}
626
645
  {...getInputFrameTransitionProps(
627
646
  typeof knobProps.transition === "string" ? knobProps.transition : undefined,
628
647
  )}