@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.
- package/dist/cjs/FloatingPanel/index.cjs +85 -29
- package/dist/cjs/FloatingPanel/index.native.js +108 -11
- package/dist/cjs/FloatingPanel/index.native.js.map +1 -1
- package/dist/cjs/FloatingPanel/pointAnchor.cjs +88 -0
- package/dist/cjs/FloatingPanel/pointAnchor.native.js +98 -0
- package/dist/cjs/FloatingPanel/pointAnchor.native.js.map +1 -0
- package/dist/cjs/FloatingPanel/useFloatingPanel.cjs +4 -3
- package/dist/cjs/FloatingPanel/useFloatingPanel.native.js +4 -3
- package/dist/cjs/FloatingPanel/useFloatingPanel.native.js.map +1 -1
- package/dist/cjs/InputParts/index.cjs +9 -1
- package/dist/cjs/InputParts/index.native.js +9 -1
- package/dist/cjs/InputParts/index.native.js.map +1 -1
- package/dist/cjs/fields/Combobox/index.cjs +0 -2
- package/dist/cjs/fields/Combobox/index.native.js +0 -2
- package/dist/cjs/fields/Combobox/index.native.js.map +1 -1
- package/dist/cjs/fields/Select/index.cjs +2 -4
- package/dist/cjs/fields/Select/index.native.js +2 -4
- package/dist/cjs/fields/Select/index.native.js.map +1 -1
- package/dist/cjs/shared/utils.cjs +2 -1
- package/dist/cjs/shared/utils.native.js +2 -1
- package/dist/cjs/shared/utils.native.js.map +1 -1
- package/dist/esm/FloatingPanel/index.mjs +86 -30
- package/dist/esm/FloatingPanel/index.mjs.map +1 -1
- package/dist/esm/FloatingPanel/index.native.js +107 -10
- package/dist/esm/FloatingPanel/index.native.js.map +1 -1
- package/dist/esm/FloatingPanel/pointAnchor.mjs +59 -0
- package/dist/esm/FloatingPanel/pointAnchor.mjs.map +1 -0
- package/dist/esm/FloatingPanel/pointAnchor.native.js +66 -0
- package/dist/esm/FloatingPanel/pointAnchor.native.js.map +1 -0
- package/dist/esm/FloatingPanel/useFloatingPanel.mjs +4 -3
- package/dist/esm/FloatingPanel/useFloatingPanel.mjs.map +1 -1
- package/dist/esm/FloatingPanel/useFloatingPanel.native.js +4 -3
- package/dist/esm/FloatingPanel/useFloatingPanel.native.js.map +1 -1
- package/dist/esm/InputParts/index.mjs +10 -2
- package/dist/esm/InputParts/index.mjs.map +1 -1
- package/dist/esm/InputParts/index.native.js +10 -2
- package/dist/esm/InputParts/index.native.js.map +1 -1
- package/dist/esm/fields/Combobox/index.mjs +0 -2
- package/dist/esm/fields/Combobox/index.mjs.map +1 -1
- package/dist/esm/fields/Combobox/index.native.js +0 -2
- package/dist/esm/fields/Combobox/index.native.js.map +1 -1
- package/dist/esm/fields/Select/index.mjs +2 -4
- package/dist/esm/fields/Select/index.mjs.map +1 -1
- package/dist/esm/fields/Select/index.native.js +2 -4
- package/dist/esm/fields/Select/index.native.js.map +1 -1
- package/dist/esm/shared/utils.mjs +2 -1
- package/dist/esm/shared/utils.mjs.map +1 -1
- package/dist/esm/shared/utils.native.js +2 -1
- package/dist/esm/shared/utils.native.js.map +1 -1
- package/dist/jsx/FloatingPanel/index.mjs +86 -30
- package/dist/jsx/FloatingPanel/index.mjs.map +1 -1
- package/dist/jsx/FloatingPanel/index.native.js +108 -11
- package/dist/jsx/FloatingPanel/index.native.js.map +1 -1
- package/dist/jsx/FloatingPanel/pointAnchor.mjs +59 -0
- package/dist/jsx/FloatingPanel/pointAnchor.mjs.map +1 -0
- package/dist/jsx/FloatingPanel/pointAnchor.native.js +98 -0
- package/dist/jsx/FloatingPanel/pointAnchor.native.js.map +1 -0
- package/dist/jsx/FloatingPanel/useFloatingPanel.mjs +4 -3
- package/dist/jsx/FloatingPanel/useFloatingPanel.mjs.map +1 -1
- package/dist/jsx/FloatingPanel/useFloatingPanel.native.js +4 -3
- package/dist/jsx/FloatingPanel/useFloatingPanel.native.js.map +1 -1
- package/dist/jsx/InputParts/index.mjs +10 -2
- package/dist/jsx/InputParts/index.mjs.map +1 -1
- package/dist/jsx/InputParts/index.native.js +9 -1
- package/dist/jsx/InputParts/index.native.js.map +1 -1
- package/dist/jsx/fields/Combobox/index.mjs +0 -2
- package/dist/jsx/fields/Combobox/index.mjs.map +1 -1
- package/dist/jsx/fields/Combobox/index.native.js +0 -2
- package/dist/jsx/fields/Combobox/index.native.js.map +1 -1
- package/dist/jsx/fields/Select/index.mjs +2 -4
- package/dist/jsx/fields/Select/index.mjs.map +1 -1
- package/dist/jsx/fields/Select/index.native.js +2 -4
- package/dist/jsx/fields/Select/index.native.js.map +1 -1
- package/dist/jsx/shared/utils.mjs +2 -1
- package/dist/jsx/shared/utils.mjs.map +1 -1
- package/dist/jsx/shared/utils.native.js +2 -1
- package/dist/jsx/shared/utils.native.js.map +1 -1
- package/package.json +18 -8
- package/src/FloatingPanel/FloatingPanel.pointMenu.native.spec.tsx +126 -0
- package/src/FloatingPanel/index.native.tsx +199 -81
- package/src/FloatingPanel/index.tsx +130 -42
- package/src/FloatingPanel/pointAnchor.spec.ts +141 -0
- package/src/FloatingPanel/pointAnchor.ts +122 -0
- package/src/FloatingPanel/useFloatingPanel.ts +9 -2
- package/src/InputParts/index.tsx +20 -1
- package/src/fieldFamilyStories.radius.spec.tsx +23 -15
- package/src/fields/Combobox/index.tsx +0 -2
- package/src/fields/Input/Input.spec.tsx +23 -0
- package/src/fields/Select/index.tsx +6 -4
- package/src/shared/utils.ts +7 -2
- package/types/FloatingPanel/index.d.ts +4 -1
- package/types/FloatingPanel/index.d.ts.map +1 -1
- package/types/FloatingPanel/index.native.d.ts +5 -0
- package/types/FloatingPanel/index.native.d.ts.map +1 -1
- package/types/FloatingPanel/pointAnchor.d.ts +66 -0
- package/types/FloatingPanel/pointAnchor.d.ts.map +1 -0
- package/types/FloatingPanel/useFloatingPanel.d.ts +7 -1
- package/types/FloatingPanel/useFloatingPanel.d.ts.map +1 -1
- package/types/InputParts/index.d.ts.map +1 -1
- package/types/fields/Combobox/index.d.ts.map +1 -1
- package/types/fields/Select/index.d.ts.map +1 -1
- package/types/shared/utils.d.ts +1 -0
- 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
|
|
206
|
-
* top math
|
|
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
|
|
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)
|
|
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
|
|
232
|
-
|
|
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
|
|
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
|
-
|
|
482
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 -
|
|
601
|
+
const cssTop = top - containingBlock.top;
|
|
541
602
|
|
|
542
603
|
const cached = overlayStyleRef.current;
|
|
543
|
-
if (
|
|
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 -
|
|
680
|
+
const cssTop = top - containingBlock.top;
|
|
615
681
|
const cached = overlayStyleRef.current;
|
|
616
|
-
if (
|
|
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 =
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
onOpenChange(true);
|
|
830
|
+
const openFromContextMenu = (
|
|
831
|
+
event: ContextMenuEventLike & {
|
|
832
|
+
preventDefault: () => void;
|
|
833
|
+
nativeEvent?: ContextMenuEventLike;
|
|
764
834
|
},
|
|
765
|
-
|
|
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(
|
|
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
|
|
package/src/InputParts/index.tsx
CHANGED
|
@@ -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
|
)}
|