@multiplatform.one/forms 7.45.1 → 7.46.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 +158 -40
- package/dist/cjs/shared/focusManagement.cjs +3 -3
- package/dist/cjs/shared/focusManagement.native.js +3 -3
- package/dist/cjs/shared/focusManagement.native.js.map +1 -1
- package/dist/cjs/shared/useSheetEscapeDismiss.cjs +5 -4
- package/dist/cjs/shared/useSheetEscapeDismiss.native.js +5 -3
- package/dist/cjs/shared/useSheetEscapeDismiss.native.js.map +1 -1
- package/dist/esm/FloatingPanel/index.mjs +160 -42
- package/dist/esm/FloatingPanel/index.mjs.map +1 -1
- package/dist/esm/shared/focusManagement.mjs +3 -3
- package/dist/esm/shared/focusManagement.mjs.map +1 -1
- package/dist/esm/shared/focusManagement.native.js +3 -3
- package/dist/esm/shared/focusManagement.native.js.map +1 -1
- package/dist/esm/shared/useSheetEscapeDismiss.mjs +5 -4
- package/dist/esm/shared/useSheetEscapeDismiss.mjs.map +1 -1
- package/dist/esm/shared/useSheetEscapeDismiss.native.js +5 -3
- package/dist/esm/shared/useSheetEscapeDismiss.native.js.map +1 -1
- package/dist/jsx/FloatingPanel/index.mjs +160 -42
- package/dist/jsx/FloatingPanel/index.mjs.map +1 -1
- package/dist/jsx/shared/focusManagement.mjs +3 -3
- package/dist/jsx/shared/focusManagement.mjs.map +1 -1
- package/dist/jsx/shared/focusManagement.native.js +3 -3
- package/dist/jsx/shared/focusManagement.native.js.map +1 -1
- package/dist/jsx/shared/useSheetEscapeDismiss.mjs +5 -4
- package/dist/jsx/shared/useSheetEscapeDismiss.mjs.map +1 -1
- package/dist/jsx/shared/useSheetEscapeDismiss.native.js +5 -3
- package/dist/jsx/shared/useSheetEscapeDismiss.native.js.map +1 -1
- package/package.json +8 -8
- package/src/FloatingPanel/FloatingPanel.coverTrigger.spec.tsx +184 -0
- package/src/FloatingPanel/FloatingPanel.spec.tsx +3 -5
- package/src/FloatingPanel/FloatingPanel.stories.tsx +26 -0
- package/src/FloatingPanel/index.tsx +255 -59
- package/src/FloatingPanel/useFloatingPanel.spec.tsx +4 -2
- package/src/shared/focusManagement.spec.tsx +11 -1
- package/src/shared/focusManagement.ts +16 -7
- package/src/shared/interactionHooks.spec.tsx +13 -0
- package/src/shared/useSheetEscapeDismiss.ts +9 -4
- package/types/FloatingPanel/index.d.ts +9 -1
- package/types/FloatingPanel/index.d.ts.map +1 -1
- package/types/shared/focusManagement.d.ts +10 -5
- package/types/shared/focusManagement.d.ts.map +1 -1
- package/types/shared/useSheetEscapeDismiss.d.ts +1 -1
- package/types/shared/useSheetEscapeDismiss.d.ts.map +1 -1
|
@@ -52,6 +52,32 @@ export const Basic: Story = {
|
|
|
52
52
|
},
|
|
53
53
|
};
|
|
54
54
|
|
|
55
|
+
export const CoverTriggerEdges: Story = {
|
|
56
|
+
render: () => {
|
|
57
|
+
const [open, setOpen] = useState(false);
|
|
58
|
+
return (
|
|
59
|
+
<YStack
|
|
60
|
+
height="100%"
|
|
61
|
+
minHeight="$20"
|
|
62
|
+
padding="$4"
|
|
63
|
+
justifyContent="flex-end"
|
|
64
|
+
alignItems="flex-end"
|
|
65
|
+
>
|
|
66
|
+
<FloatingPanel
|
|
67
|
+
open={open}
|
|
68
|
+
onOpenChange={setOpen}
|
|
69
|
+
anchorMode="cover-trigger"
|
|
70
|
+
triggerSizing="content"
|
|
71
|
+
trigger={<Button>Edge menu</Button>}
|
|
72
|
+
>
|
|
73
|
+
<Button onPress={() => setOpen(false)}>First action</Button>
|
|
74
|
+
<Button onPress={() => setOpen(false)}>Second action</Button>
|
|
75
|
+
</FloatingPanel>
|
|
76
|
+
</YStack>
|
|
77
|
+
);
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
55
81
|
export const ScrollableContent: Story = {
|
|
56
82
|
render: () => {
|
|
57
83
|
const [open, setOpen] = useState(false);
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { transitionProps } from "@multiplatform.one/theme";
|
|
2
2
|
import type { ReactNode, RefObject, UIEvent } from "react";
|
|
3
3
|
import {
|
|
4
|
+
cloneElement,
|
|
5
|
+
isValidElement,
|
|
4
6
|
useCallback,
|
|
5
7
|
useEffect,
|
|
6
8
|
useLayoutEffect,
|
|
@@ -16,6 +18,7 @@ import {
|
|
|
16
18
|
useResolvedKnobs,
|
|
17
19
|
wasKeyboardFocus,
|
|
18
20
|
ensureKeyboardModalityTracking,
|
|
21
|
+
motionDurationMs,
|
|
19
22
|
} from "@multiplatform.one/theme";
|
|
20
23
|
import { isWeb, Sheet, View, YStack } from "tamagui";
|
|
21
24
|
import { useClick, useDismiss, useInteractions } from "@floating-ui/react";
|
|
@@ -66,6 +69,9 @@ export interface FloatingPanelProps {
|
|
|
66
69
|
/** Suppress delayed trigger focus when a consumer handles close focus itself. Web only. */
|
|
67
70
|
skipReturnFocusRef?: RefObject<boolean>;
|
|
68
71
|
trigger: ReactNode;
|
|
72
|
+
anchorMode?: "cover-trigger" | "attach";
|
|
73
|
+
triggerRow?: ReactNode;
|
|
74
|
+
anchorRect?: { x: number; y: number; width: number; height: number };
|
|
69
75
|
triggerEcho?: boolean;
|
|
70
76
|
/**
|
|
71
77
|
* Optional control rendered beside the trigger but OUTSIDE the native press
|
|
@@ -168,7 +174,7 @@ export interface FloatingPanelProps {
|
|
|
168
174
|
* COVER-TRIGGER (LC-105): the panel's top edge sits on the trigger's TOP
|
|
169
175
|
* edge instead of attaching below it, so the trigger becomes the panel's
|
|
170
176
|
* first row by default. Set triggerEcho=false for a custom first row. The panel scales from
|
|
171
|
-
* the trigger's top start corner.
|
|
177
|
+
* the trigger's top start corner. Viewport collisions use the bottom sheet.
|
|
172
178
|
* Enabled by default. Web float only; the native and narrow-web sheet is unchanged.
|
|
173
179
|
*/
|
|
174
180
|
cover?: boolean;
|
|
@@ -201,7 +207,7 @@ function useKeyboardFocusIntoPanel(
|
|
|
201
207
|
if (!wasKeyboardFocus() && !keyboardOpenRef?.current) return;
|
|
202
208
|
const active = document.activeElement;
|
|
203
209
|
if (active instanceof HTMLElement && frame.contains(active) && active !== frame) return;
|
|
204
|
-
//
|
|
210
|
+
// Initial keyboard focus belongs to an action, not the repeated close row.
|
|
205
211
|
const first = Array.from(frame.querySelectorAll<HTMLElement>(PANEL_FOCUSABLE)).find(
|
|
206
212
|
(el) => !el.closest("[data-fp-trigger-row]"),
|
|
207
213
|
);
|
|
@@ -254,12 +260,12 @@ function fixedContainingBlockOrigin(element: HTMLElement): { top: number; left:
|
|
|
254
260
|
return { top: 0, left: 0 };
|
|
255
261
|
}
|
|
256
262
|
|
|
257
|
-
function isRtl(): boolean {
|
|
258
|
-
return
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
+
function isRtl(element?: HTMLElement | null): boolean {
|
|
264
|
+
if (!isWeb || typeof document === "undefined") return false;
|
|
265
|
+
const direction = element ? getComputedStyle(element).direction : "";
|
|
266
|
+
return direction
|
|
267
|
+
? direction === "rtl"
|
|
268
|
+
: (element?.closest("[dir]") ?? document.documentElement).getAttribute("dir") === "rtl";
|
|
263
269
|
}
|
|
264
270
|
|
|
265
271
|
function panelTransformOrigin(placement: string): string {
|
|
@@ -297,41 +303,94 @@ const ECHO_STRIPPED_ATTRIBUTES = [
|
|
|
297
303
|
function TriggerEchoRow({
|
|
298
304
|
sourceRef,
|
|
299
305
|
onPress,
|
|
306
|
+
children,
|
|
307
|
+
anchorRect,
|
|
308
|
+
source,
|
|
300
309
|
}: {
|
|
301
310
|
sourceRef: RefObject<HTMLElement | null>;
|
|
302
311
|
onPress: () => void;
|
|
312
|
+
children?: ReactNode;
|
|
313
|
+
anchorRect?: FloatingPanelProps["anchorRect"];
|
|
314
|
+
source: ReactNode;
|
|
303
315
|
}) {
|
|
304
316
|
const rowRef = useRef<HTMLElement | null>(null);
|
|
317
|
+
const visualRef = useRef<HTMLElement | null>(null);
|
|
305
318
|
useLayoutEffect(() => {
|
|
306
319
|
const row = rowRef.current;
|
|
307
320
|
const source = sourceRef.current;
|
|
308
321
|
if (!row || !source || typeof source.cloneNode !== "function") return;
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
322
|
+
const sync = () => {
|
|
323
|
+
const control = source.matches(PANEL_FOCUSABLE)
|
|
324
|
+
? source
|
|
325
|
+
: source.querySelector<HTMLElement>(PANEL_FOCUSABLE);
|
|
326
|
+
const labelledBy = control?.getAttribute("aria-labelledby");
|
|
327
|
+
const label =
|
|
328
|
+
control?.getAttribute("aria-label") ||
|
|
329
|
+
labelledBy
|
|
330
|
+
?.split(/\s+/)
|
|
331
|
+
.map((id) => document.getElementById(id)?.textContent || "")
|
|
332
|
+
.join(" ") ||
|
|
333
|
+
control?.textContent ||
|
|
334
|
+
source.textContent ||
|
|
335
|
+
"";
|
|
336
|
+
row.setAttribute("aria-label", label);
|
|
337
|
+
const rect = anchorRect ?? source.getBoundingClientRect();
|
|
338
|
+
row.style.width = `${rect.width}px`;
|
|
339
|
+
row.style.height = `${rect.height}px`;
|
|
340
|
+
const rtl = isRtl(source);
|
|
341
|
+
row.style.alignSelf = "flex-start";
|
|
342
|
+
const frameStyle = row.parentElement ? getComputedStyle(row.parentElement) : null;
|
|
343
|
+
row.style.marginTop = `${-(parseFloat(frameStyle?.borderTopWidth || "0") || 0)}px`;
|
|
344
|
+
row.style.position = "sticky";
|
|
345
|
+
row.style.top = row.style.marginTop;
|
|
346
|
+
row.style.zIndex = "1";
|
|
347
|
+
row.style.marginLeft = rtl
|
|
348
|
+
? ""
|
|
349
|
+
: `${-(parseFloat(frameStyle?.borderLeftWidth || "0") || 0)}px`;
|
|
350
|
+
row.style.marginRight = rtl
|
|
351
|
+
? `${-(parseFloat(frameStyle?.borderRightWidth || "0") || 0)}px`
|
|
352
|
+
: "";
|
|
353
|
+
const snapshot =
|
|
354
|
+
children == null ? (source.cloneNode(true) as HTMLElement) : visualRef.current;
|
|
355
|
+
if (!snapshot) return;
|
|
356
|
+
const sourceFields = source.querySelectorAll<HTMLInputElement>("input, textarea");
|
|
357
|
+
const snapshotFields = snapshot.querySelectorAll<HTMLInputElement>("input, textarea");
|
|
358
|
+
sourceFields.forEach((field, index) => {
|
|
359
|
+
const copy = snapshotFields[index];
|
|
360
|
+
if (copy) copy.value = field.value;
|
|
361
|
+
});
|
|
362
|
+
for (const node of [snapshot, ...Array.from(snapshot.querySelectorAll<HTMLElement>("*"))]) {
|
|
363
|
+
for (const name of ECHO_STRIPPED_ATTRIBUTES) node.removeAttribute(name);
|
|
364
|
+
if (node.hasAttribute("tabindex") || node.matches("input, textarea, button, a, select")) {
|
|
365
|
+
node.setAttribute("tabindex", "-1");
|
|
366
|
+
}
|
|
320
367
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
368
|
+
snapshot.setAttribute("inert", "");
|
|
369
|
+
snapshot.setAttribute("aria-hidden", "true");
|
|
370
|
+
if (children == null) visualRef.current?.replaceChildren(snapshot);
|
|
371
|
+
};
|
|
372
|
+
sync();
|
|
373
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
374
|
+
const observer = new ResizeObserver(sync);
|
|
375
|
+
observer.observe(source);
|
|
376
|
+
return () => observer.disconnect();
|
|
377
|
+
}, [sourceRef, source, children, anchorRect]);
|
|
325
378
|
return (
|
|
326
379
|
<View
|
|
327
380
|
ref={rowRef as any}
|
|
328
381
|
data-fp-trigger-row=""
|
|
329
|
-
|
|
382
|
+
role="button"
|
|
383
|
+
aria-expanded={true}
|
|
384
|
+
tabIndex={-1}
|
|
330
385
|
cursor="pointer"
|
|
331
386
|
onPress={onPress}
|
|
332
387
|
flexShrink={0}
|
|
333
388
|
pointerEvents="auto"
|
|
334
|
-
|
|
389
|
+
>
|
|
390
|
+
<View ref={visualRef as any} pointerEvents="none" aria-hidden>
|
|
391
|
+
{isValidElement(children) ? cloneElement(children, { ref: null } as any) : children}
|
|
392
|
+
</View>
|
|
393
|
+
</View>
|
|
335
394
|
);
|
|
336
395
|
}
|
|
337
396
|
|
|
@@ -373,7 +432,11 @@ export function FloatingPanel(props: FloatingPanelProps) {
|
|
|
373
432
|
const shouldUseSheet = props.sheet ?? (!isWeb || !viewportGtSm);
|
|
374
433
|
// The panel floats over the page: its content never sits on a painted
|
|
375
434
|
// ancestor, so a Card inside it paints as top-level.
|
|
376
|
-
const panelProps = {
|
|
435
|
+
const panelProps = {
|
|
436
|
+
...props,
|
|
437
|
+
cover: props.anchorMode ? props.anchorMode === "cover-trigger" : props.cover,
|
|
438
|
+
children: <Surface painted={false}>{props.children}</Surface>,
|
|
439
|
+
};
|
|
377
440
|
|
|
378
441
|
if (shouldUseSheet) return <FloatingPanelSheet {...panelProps} />;
|
|
379
442
|
return <FloatingPanelWeb {...panelProps} />;
|
|
@@ -416,7 +479,8 @@ function FloatingPanelSheet({
|
|
|
416
479
|
header,
|
|
417
480
|
scrollViewProps,
|
|
418
481
|
openOn = "press",
|
|
419
|
-
|
|
482
|
+
escapeTarget = "window",
|
|
483
|
+
}: FloatingPanelProps & { escapeTarget?: "window" | "document" }) {
|
|
420
484
|
const { knobProps } = useResolvedKnobs({ painted: false });
|
|
421
485
|
// LC-24 / A-ENTEREXIT: honor animation=none (undefined) — do not hardcode "medium".
|
|
422
486
|
const slideMotion = {
|
|
@@ -438,7 +502,7 @@ function FloatingPanelSheet({
|
|
|
438
502
|
};
|
|
439
503
|
// Tamagui Sheet has no key handling (unlike the web floating mode, which
|
|
440
504
|
// dismisses on Escape) — wire the dismissal intent here too.
|
|
441
|
-
useSheetEscapeDismiss(open, () => handleOpenChange(false));
|
|
505
|
+
useSheetEscapeDismiss(open, () => handleOpenChange(false), escapeTarget);
|
|
442
506
|
// DG-A11Y-04 / Axiom 7: Sheet has no focus scope — move focus into the
|
|
443
507
|
// frame on open, trap Tab while open, return focus to the trigger on close.
|
|
444
508
|
const sheetFrameRef = useRef<HTMLElement | null>(null);
|
|
@@ -484,23 +548,25 @@ function FloatingPanelSheet({
|
|
|
484
548
|
|
|
485
549
|
return (
|
|
486
550
|
<>
|
|
487
|
-
|
|
488
|
-
flexDirection="row"
|
|
489
|
-
alignItems="center"
|
|
490
|
-
width={triggerSizing === "content" ? "auto" : "100%"}
|
|
491
|
-
>
|
|
551
|
+
{trigger != null && (
|
|
492
552
|
<View
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
{
|
|
496
|
-
cursor={disabled ? "not-allowed" : "pointer"}
|
|
497
|
-
opacity={disabled ? 0.5 : 1}
|
|
498
|
-
{...triggerActivation}
|
|
553
|
+
flexDirection="row"
|
|
554
|
+
alignItems="center"
|
|
555
|
+
width={triggerSizing === "content" ? "auto" : "100%"}
|
|
499
556
|
>
|
|
500
|
-
|
|
557
|
+
<View
|
|
558
|
+
flex={triggerSizing === "content" ? undefined : 1}
|
|
559
|
+
minWidth={0}
|
|
560
|
+
{...(isContextMenu ? undefined : { onPress: openFromTrigger })}
|
|
561
|
+
cursor={disabled ? "not-allowed" : "pointer"}
|
|
562
|
+
opacity={disabled ? 0.5 : 1}
|
|
563
|
+
{...triggerActivation}
|
|
564
|
+
>
|
|
565
|
+
{trigger}
|
|
566
|
+
</View>
|
|
567
|
+
{triggerEnd}
|
|
501
568
|
</View>
|
|
502
|
-
|
|
503
|
-
</View>
|
|
569
|
+
)}
|
|
504
570
|
<Sheet
|
|
505
571
|
modal
|
|
506
572
|
open={open}
|
|
@@ -521,6 +587,7 @@ function FloatingPanelSheet({
|
|
|
521
587
|
/>
|
|
522
588
|
<Sheet.Handle />
|
|
523
589
|
<Sheet.Frame
|
|
590
|
+
data-testid="floating-panel-sheet"
|
|
524
591
|
// Sheet.Frame forwards its ref at runtime; the styled prop type
|
|
525
592
|
// just omits `ref`, hence the cast. tabIndex -1 makes the frame a
|
|
526
593
|
// programmatic focus target without joining the tab order.
|
|
@@ -562,7 +629,11 @@ function FloatingPanelWeb({
|
|
|
562
629
|
openOn = "press",
|
|
563
630
|
cover = true,
|
|
564
631
|
triggerEcho = true,
|
|
632
|
+
triggerRow,
|
|
633
|
+
anchorRect,
|
|
634
|
+
...sheetProps
|
|
565
635
|
}: FloatingPanelProps) {
|
|
636
|
+
const [collisionSheet, setCollisionSheet] = useState(false);
|
|
566
637
|
const isFill = sizing === "fill";
|
|
567
638
|
const isContextMenu = openOn === "contextmenu";
|
|
568
639
|
const overlayStyleRef = useRef<{ top: number; left?: number; maxHeight: number } | null>(null);
|
|
@@ -591,7 +662,7 @@ function FloatingPanelWeb({
|
|
|
591
662
|
};
|
|
592
663
|
|
|
593
664
|
const panel = useFloatingPanel({
|
|
594
|
-
open,
|
|
665
|
+
open: open && !collisionSheet,
|
|
595
666
|
onOpenChange,
|
|
596
667
|
disabled,
|
|
597
668
|
repositionRef: repositionFnRef,
|
|
@@ -607,14 +678,27 @@ function FloatingPanelWeb({
|
|
|
607
678
|
// Escape (or outside click on empty space) closes the panel while focus is
|
|
608
679
|
// inside it, focus falls to <body>. Return it to the pre-open element
|
|
609
680
|
// (the trigger's focusable). Deliberate focus moves elsewhere are kept.
|
|
610
|
-
useReturnFocusOnClose(open, skipReturnFocusRef);
|
|
611
|
-
useKeyboardFocusIntoPanel(
|
|
681
|
+
useReturnFocusOnClose(open, skipReturnFocusRef, panel.refs.floating);
|
|
682
|
+
useKeyboardFocusIntoPanel(
|
|
683
|
+
open && panel.mounted && !collisionSheet,
|
|
684
|
+
panel.refs.floating,
|
|
685
|
+
keyboardOpenRef,
|
|
686
|
+
);
|
|
612
687
|
useEffect(() => {
|
|
613
688
|
if (open) return;
|
|
614
689
|
keyboardOpenRef.current = false;
|
|
615
690
|
anchorPointRef.current = null;
|
|
616
691
|
}, [open]);
|
|
617
692
|
|
|
693
|
+
useEffect(() => {
|
|
694
|
+
if (open || !collisionSheet) return;
|
|
695
|
+
const timer = setTimeout(
|
|
696
|
+
() => setCollisionSheet(false),
|
|
697
|
+
motionDurationMs(panel.knobProps.motion.slide.transition),
|
|
698
|
+
);
|
|
699
|
+
return () => clearTimeout(timer);
|
|
700
|
+
}, [open, collisionSheet, panel.knobProps.motion.slide.transition]);
|
|
701
|
+
|
|
618
702
|
const repositionOverlay = useCallback(() => {
|
|
619
703
|
const dropdownElement = panel.refs.floating.current;
|
|
620
704
|
const trig = panel.triggerRef.current;
|
|
@@ -630,7 +714,16 @@ function FloatingPanelWeb({
|
|
|
630
714
|
const point = isContextMenu
|
|
631
715
|
? (anchorPointRef.current ?? referenceCornerPoint(trig, isRtl()))
|
|
632
716
|
: null;
|
|
633
|
-
const
|
|
717
|
+
const measured = anchorRect
|
|
718
|
+
? {
|
|
719
|
+
...anchorRect,
|
|
720
|
+
top: anchorRect.y,
|
|
721
|
+
left: anchorRect.x,
|
|
722
|
+
right: anchorRect.x + anchorRect.width,
|
|
723
|
+
bottom: anchorRect.y + anchorRect.height,
|
|
724
|
+
}
|
|
725
|
+
: trig.getBoundingClientRect();
|
|
726
|
+
const triggerBounds = point ? { top: point.y, bottom: point.y } : measured;
|
|
634
727
|
const viewportHeight = window.innerHeight;
|
|
635
728
|
const cs = getComputedStyle(dropdownElement);
|
|
636
729
|
const borderTopWidth = parseFloat(cs.borderTopWidth) || 0;
|
|
@@ -641,6 +734,70 @@ function FloatingPanelWeb({
|
|
|
641
734
|
const liveTransition = panel.panelTransition;
|
|
642
735
|
const containingBlock = fixedContainingBlockOrigin(dropdownElement);
|
|
643
736
|
|
|
737
|
+
if (cover && !point) {
|
|
738
|
+
const rtl = isRtl(trig);
|
|
739
|
+
const viewport = window.visualViewport;
|
|
740
|
+
const viewportLeft = viewport?.offsetLeft ?? 0;
|
|
741
|
+
const viewportTop = viewport?.offsetTop ?? 0;
|
|
742
|
+
const viewportRight =
|
|
743
|
+
viewportLeft +
|
|
744
|
+
(viewport?.width ?? (document.documentElement.clientWidth || window.innerWidth));
|
|
745
|
+
const viewportBottom = viewportTop + (viewport?.height ?? window.innerHeight);
|
|
746
|
+
dropdownElement.style.left = `${viewportLeft - containingBlock.left}px`;
|
|
747
|
+
const layoutStyle = getComputedStyle(dropdownElement);
|
|
748
|
+
const scale =
|
|
749
|
+
layoutStyle.transform &&
|
|
750
|
+
layoutStyle.transform !== "none" &&
|
|
751
|
+
typeof DOMMatrixReadOnly !== "undefined"
|
|
752
|
+
? Math.abs(new DOMMatrixReadOnly(layoutStyle.transform).a)
|
|
753
|
+
: 1;
|
|
754
|
+
const layoutWidth = dropdownElement.getBoundingClientRect().width / (scale || 1);
|
|
755
|
+
const width = Math.max(layoutWidth || dropdownElement.offsetWidth, measured.width);
|
|
756
|
+
const left = rtl ? measured.right - width : measured.left;
|
|
757
|
+
const firstActionHeight =
|
|
758
|
+
contentRef.current?.querySelector<HTMLElement>(PANEL_FOCUSABLE)?.getBoundingClientRect()
|
|
759
|
+
.height ||
|
|
760
|
+
Number(panel.knobProps.menuRow.minHeight) ||
|
|
761
|
+
totalContentHeight;
|
|
762
|
+
const bodyStyle = contentRef.current ? getComputedStyle(contentRef.current) : null;
|
|
763
|
+
const bodyPadding =
|
|
764
|
+
(parseFloat(bodyStyle?.paddingTop || "0") || 0) +
|
|
765
|
+
(parseFloat(bodyStyle?.paddingBottom || "0") || 0);
|
|
766
|
+
const neededHeight = sheetProps.scrollable
|
|
767
|
+
? Math.min(
|
|
768
|
+
totalContentHeight,
|
|
769
|
+
measured.height + firstActionHeight + bodyPadding + totalBorderWidth,
|
|
770
|
+
)
|
|
771
|
+
: totalContentHeight;
|
|
772
|
+
if (
|
|
773
|
+
measured.width > 0 &&
|
|
774
|
+
measured.height > 0 &&
|
|
775
|
+
(left < viewportLeft ||
|
|
776
|
+
left + width > Math.max(measured.right, viewportRight - panelViewportPadding) ||
|
|
777
|
+
measured.right > viewportRight ||
|
|
778
|
+
measured.top < viewportTop ||
|
|
779
|
+
measured.top + Math.max(neededHeight, measured.height) >
|
|
780
|
+
viewportBottom - panelViewportPadding)
|
|
781
|
+
) {
|
|
782
|
+
dropdownElement.style.visibility = "hidden";
|
|
783
|
+
setCollisionSheet(true);
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
const top = measured.top - containingBlock.top;
|
|
787
|
+
const cssLeft = left - containingBlock.left;
|
|
788
|
+
const maxHeight = Math.max(0, viewportBottom - panelViewportPadding - measured.top);
|
|
789
|
+
dropdownElement.style.top = `${top}px`;
|
|
790
|
+
dropdownElement.style.left = `${cssLeft}px`;
|
|
791
|
+
dropdownElement.style.maxHeight = `${maxHeight}px`;
|
|
792
|
+
dropdownElement.dir = rtl ? "rtl" : "ltr";
|
|
793
|
+
dropdownElement.style.transformOrigin = rtl ? "top right" : "top left";
|
|
794
|
+
dropdownElement.style.visibility = "visible";
|
|
795
|
+
overlayStyleRef.current = { top, left: cssLeft, maxHeight };
|
|
796
|
+
panel.initializingRef.current = false;
|
|
797
|
+
if (isInitial) dropdownElement.style.transition = liveTransition;
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
|
|
644
801
|
let isDropup = panel.placement.startsWith("top");
|
|
645
802
|
let cssLeft: number | undefined;
|
|
646
803
|
if (point) {
|
|
@@ -794,6 +951,8 @@ function FloatingPanelWeb({
|
|
|
794
951
|
isFill,
|
|
795
952
|
isContextMenu,
|
|
796
953
|
cover,
|
|
954
|
+
anchorRect,
|
|
955
|
+
sheetProps.scrollable,
|
|
797
956
|
panel.placement,
|
|
798
957
|
panel.refs,
|
|
799
958
|
panel.triggerRef,
|
|
@@ -805,9 +964,10 @@ function FloatingPanelWeb({
|
|
|
805
964
|
|
|
806
965
|
const click = useClick(panel.context, {
|
|
807
966
|
event: "mousedown",
|
|
808
|
-
enabled: !isContextMenu,
|
|
967
|
+
enabled: !isContextMenu && !collisionSheet,
|
|
809
968
|
});
|
|
810
969
|
const dismiss = useDismiss(panel.context, {
|
|
970
|
+
enabled: !collisionSheet,
|
|
811
971
|
// Use 'click' instead of default 'pointerdown' so scrolling to the bottom
|
|
812
972
|
// (e.g. via wheel/trackpad or scroll arrow hover) doesn't trigger outside-press.
|
|
813
973
|
outsidePressEvent: "click",
|
|
@@ -820,11 +980,17 @@ function FloatingPanelWeb({
|
|
|
820
980
|
useLayoutEffect(() => {
|
|
821
981
|
if (!open || !panel.mounted) return;
|
|
822
982
|
repositionOverlay();
|
|
823
|
-
}, [open, panel.mounted, repositionOverlay, panel.y]);
|
|
983
|
+
}, [open, panel.mounted, repositionOverlay, panel.y, panel.x]);
|
|
824
984
|
|
|
825
985
|
useLayoutEffect(() => {
|
|
826
986
|
const content = contentRef.current;
|
|
827
|
-
if (
|
|
987
|
+
if (
|
|
988
|
+
!open ||
|
|
989
|
+
!panel.mounted ||
|
|
990
|
+
(!isFill && !cover) ||
|
|
991
|
+
!content ||
|
|
992
|
+
typeof ResizeObserver === "undefined"
|
|
993
|
+
) {
|
|
828
994
|
return;
|
|
829
995
|
}
|
|
830
996
|
// Matching the trigger width can rewrap content after the panel's first
|
|
@@ -839,17 +1005,18 @@ function FloatingPanelWeb({
|
|
|
839
1005
|
});
|
|
840
1006
|
});
|
|
841
1007
|
observer.observe(content);
|
|
1008
|
+
if (panel.triggerRef.current) observer.observe(panel.triggerRef.current);
|
|
842
1009
|
return () => {
|
|
843
1010
|
observer.disconnect();
|
|
844
1011
|
if (frame) cancelAnimationFrame(frame);
|
|
845
1012
|
};
|
|
846
|
-
}, [open, panel.mounted, isFill, repositionOverlay]);
|
|
1013
|
+
}, [open, panel.mounted, isFill, cover, repositionOverlay, panel.triggerRef]);
|
|
847
1014
|
|
|
848
1015
|
const handleScroll = useCallback(
|
|
849
1016
|
(event: UIEvent) => {
|
|
850
1017
|
const el = event.target as HTMLElement;
|
|
851
1018
|
panel.updateArrows(el);
|
|
852
|
-
if (isFill) return;
|
|
1019
|
+
if (isFill || (cover && !isContextMenu)) return;
|
|
853
1020
|
if (panel.initializingRef.current) {
|
|
854
1021
|
lastScrollTopRef.current = el.scrollTop;
|
|
855
1022
|
return;
|
|
@@ -873,7 +1040,7 @@ function FloatingPanelWeb({
|
|
|
873
1040
|
repositionFnRef.current?.();
|
|
874
1041
|
}
|
|
875
1042
|
},
|
|
876
|
-
[isFill, panel.updateArrows],
|
|
1043
|
+
[isFill, cover, isContextMenu, panel.updateArrows],
|
|
877
1044
|
);
|
|
878
1045
|
|
|
879
1046
|
const webTransition = panel.panelTransition;
|
|
@@ -976,6 +1143,7 @@ function FloatingPanelWeb({
|
|
|
976
1143
|
<>
|
|
977
1144
|
<View
|
|
978
1145
|
{...referenceProps}
|
|
1146
|
+
alignSelf={sheetProps.triggerSizing === "content" ? "flex-start" : undefined}
|
|
979
1147
|
cursor={disabled ? "not-allowed" : "pointer"}
|
|
980
1148
|
opacity={disabled ? 0.5 : 1}
|
|
981
1149
|
// The cover contract is stated as a relation between the panel's top
|
|
@@ -989,7 +1157,22 @@ function FloatingPanelWeb({
|
|
|
989
1157
|
{trigger}
|
|
990
1158
|
</View>
|
|
991
1159
|
|
|
992
|
-
{
|
|
1160
|
+
{collisionSheet && (
|
|
1161
|
+
<FloatingPanelSheet
|
|
1162
|
+
{...sheetProps}
|
|
1163
|
+
open={open}
|
|
1164
|
+
onOpenChange={onOpenChange}
|
|
1165
|
+
trigger={null}
|
|
1166
|
+
// The trigger's Tooltip stops Escape at document. The float's
|
|
1167
|
+
// useDismiss listens there too, so the fallback must as well.
|
|
1168
|
+
escapeTarget="document"
|
|
1169
|
+
skipReturnFocusRef={skipReturnFocusRef}
|
|
1170
|
+
contentPadding={contentPadding}
|
|
1171
|
+
>
|
|
1172
|
+
{children}
|
|
1173
|
+
</FloatingPanelSheet>
|
|
1174
|
+
)}
|
|
1175
|
+
{panel.mounted && !collisionSheet && (
|
|
993
1176
|
<PanelPortal anchor={panel.triggerRef.current}>
|
|
994
1177
|
<YStack
|
|
995
1178
|
{...getFloatingProps({
|
|
@@ -1002,6 +1185,7 @@ function FloatingPanelWeb({
|
|
|
1002
1185
|
scrollbarWidth: "none" as any,
|
|
1003
1186
|
zIndex: zIndex.dropdown,
|
|
1004
1187
|
overflow: "auto",
|
|
1188
|
+
...(cover && !isContextMenu ? { overflowX: "hidden" as const } : {}),
|
|
1005
1189
|
...panel.floatingStyleRef.current,
|
|
1006
1190
|
...(overlayStyleRef.current
|
|
1007
1191
|
? { maxHeight: overlayStyleRef.current.maxHeight }
|
|
@@ -1012,11 +1196,16 @@ function FloatingPanelWeb({
|
|
|
1012
1196
|
// then positions against this box and is clipped by its scroll.
|
|
1013
1197
|
transform: panel.visible ? "none" : "scale(0.96)",
|
|
1014
1198
|
transition: webTransition,
|
|
1015
|
-
transformOrigin:
|
|
1016
|
-
|
|
1017
|
-
?
|
|
1018
|
-
|
|
1019
|
-
|
|
1199
|
+
transformOrigin:
|
|
1200
|
+
cover && !isContextMenu
|
|
1201
|
+
? isRtl(panel.triggerRef.current)
|
|
1202
|
+
? "top right"
|
|
1203
|
+
: "top left"
|
|
1204
|
+
: panelTransformOrigin(
|
|
1205
|
+
pointPlacementRef.current
|
|
1206
|
+
? `${pointPlacementRef.current.dropup ? "top" : "bottom"}-${pointPlacementRef.current.alignEnd ? "end" : "start"}`
|
|
1207
|
+
: panel.placement,
|
|
1208
|
+
),
|
|
1020
1209
|
},
|
|
1021
1210
|
onScroll: handleScroll,
|
|
1022
1211
|
onTransitionEnd: (event) => {
|
|
@@ -1042,7 +1231,14 @@ function FloatingPanelWeb({
|
|
|
1042
1231
|
paddingVertical={0}
|
|
1043
1232
|
>
|
|
1044
1233
|
{isContextMenu || !cover || !triggerEcho ? null : (
|
|
1045
|
-
<TriggerEchoRow
|
|
1234
|
+
<TriggerEchoRow
|
|
1235
|
+
sourceRef={panel.triggerRef}
|
|
1236
|
+
source={trigger}
|
|
1237
|
+
anchorRect={anchorRect}
|
|
1238
|
+
onPress={() => onOpenChange(false)}
|
|
1239
|
+
>
|
|
1240
|
+
{triggerRow}
|
|
1241
|
+
</TriggerEchoRow>
|
|
1046
1242
|
)}
|
|
1047
1243
|
<ScrollArrow
|
|
1048
1244
|
direction="up"
|
|
@@ -158,12 +158,14 @@ describe("FloatingPanel and DropdownMenu open over their trigger (MPO-38 cover r
|
|
|
158
158
|
expect(viewport.style.transformOrigin.startsWith("top")).toBe(true);
|
|
159
159
|
});
|
|
160
160
|
|
|
161
|
-
it("the trigger's own label is the panel's first row
|
|
161
|
+
it("the trigger's own label is the panel's first close row", async () => {
|
|
162
162
|
const viewport = await openPanel({});
|
|
163
163
|
const echo = viewport.querySelector("[data-fp-trigger-row]") as HTMLElement;
|
|
164
164
|
expect(echo).toBeTruthy();
|
|
165
165
|
expect(echo.textContent).toContain("Actions");
|
|
166
|
-
expect(echo.getAttribute("aria-hidden")).
|
|
166
|
+
expect(echo.getAttribute("aria-hidden")).toBeNull();
|
|
167
|
+
expect(echo.getAttribute("role")).toBe("button");
|
|
168
|
+
expect(echo.getAttribute("aria-label")).toBe("Actions");
|
|
167
169
|
const firstRow = Array.from(viewport.children).find((el) =>
|
|
168
170
|
(el as HTMLElement).textContent?.trim(),
|
|
169
171
|
);
|
|
@@ -33,7 +33,7 @@ for (const mode of ["floating", "sheet"] as const) {
|
|
|
33
33
|
const hook = renderHook(
|
|
34
34
|
({ open }) => {
|
|
35
35
|
if (mode === "sheet") useSheetModalFocus(open, frameRef, skipReturnFocusRef);
|
|
36
|
-
else useReturnFocusOnClose(open, skipReturnFocusRef);
|
|
36
|
+
else useReturnFocusOnClose(open, skipReturnFocusRef, frameRef);
|
|
37
37
|
},
|
|
38
38
|
{ initialProps: { open: true }, wrapper: Wrapper },
|
|
39
39
|
);
|
|
@@ -76,6 +76,16 @@ for (const mode of ["floating", "sheet"] as const) {
|
|
|
76
76
|
expect(document.activeElement).toBe(h.trigger);
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
+
it("returns focus left inside the closing frame before the frame unmounts", async () => {
|
|
80
|
+
const h = setup();
|
|
81
|
+
h.setAnimation("none");
|
|
82
|
+
h.rerender({ open: true });
|
|
83
|
+
h.item.focus();
|
|
84
|
+
h.rerender({ open: false });
|
|
85
|
+
await act(() => vi.advanceTimersByTimeAsync(50));
|
|
86
|
+
expect(document.activeElement).toBe(h.trigger);
|
|
87
|
+
});
|
|
88
|
+
|
|
79
89
|
it("honors suppression set after close, before the delayed restore", async () => {
|
|
80
90
|
const h = setup();
|
|
81
91
|
h.trigger.blur();
|
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
* only remembers a `Dialog.Trigger` mounted inside the Dialog. These hooks
|
|
7
7
|
* close that gap:
|
|
8
8
|
*
|
|
9
|
-
* - `useReturnFocusOnClose(open)` — capture the focused
|
|
10
|
-
* flips true; when it flips false, return focus to it
|
|
11
|
-
* (on body, or stranded inside
|
|
9
|
+
* - `useReturnFocusOnClose(open, skip?, insideRef?)` — capture the focused
|
|
10
|
+
* element when `open` flips true; when it flips false, return focus to it
|
|
11
|
+
* IF focus was dropped (on body, or stranded inside the closing overlay). Never steals
|
|
12
12
|
* focus the user deliberately moved elsewhere (outside-click onto another
|
|
13
13
|
* control).
|
|
14
14
|
* - `useSheetModalFocus(open, frameRef)` — sheet-mode complement: also moves
|
|
@@ -96,8 +96,17 @@ function restoreLater(
|
|
|
96
96
|
setTimeout(restore, delayMs);
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
/**
|
|
100
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Restore focus to the element focused at open time, on close (web only).
|
|
101
|
+
* `insideRef` is the closing overlay's frame: focus still inside it counts as
|
|
102
|
+
* dropped, because it falls to <body> when the frame unmounts after the exit
|
|
103
|
+
* and no retry runs that late.
|
|
104
|
+
*/
|
|
105
|
+
export function useReturnFocusOnClose(
|
|
106
|
+
open: boolean,
|
|
107
|
+
skipReturnFocusRef?: RefObject<boolean>,
|
|
108
|
+
insideRef?: RefObject<HTMLElement | null>,
|
|
109
|
+
) {
|
|
101
110
|
const { prevRef, generationRef } = useCaptureOnOpen(open);
|
|
102
111
|
const { knobProps } = useResolvedKnobs();
|
|
103
112
|
const restoreDelayMsRef = useRef(0);
|
|
@@ -109,10 +118,10 @@ export function useReturnFocusOnClose(open: boolean, skipReturnFocusRef?: RefObj
|
|
|
109
118
|
prevRef,
|
|
110
119
|
generationRef,
|
|
111
120
|
restoreDelayMsRef.current,
|
|
112
|
-
|
|
121
|
+
insideRef,
|
|
113
122
|
skipReturnFocusRef,
|
|
114
123
|
);
|
|
115
|
-
}, [open, prevRef, generationRef, skipReturnFocusRef]);
|
|
124
|
+
}, [open, prevRef, generationRef, skipReturnFocusRef, insideRef]);
|
|
116
125
|
}
|
|
117
126
|
|
|
118
127
|
/**
|