ropav 0.14.0 → 0.15.1

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 (34) hide show
  1. package/dist/components/alert-dialog/alert-dialog-backdrop.js +3 -1
  2. package/dist/components/drawer/drawer-backdrop.js +3 -1
  3. package/dist/components/index.d.ts +1 -0
  4. package/dist/components/meter/meter-root.js +1 -1
  5. package/dist/components/modal/modal-backdrop.js +3 -1
  6. package/dist/components/overlay/overlay-popover.js +14 -5
  7. package/dist/components/portal-provider/index.d.ts +3 -0
  8. package/dist/components/portal-provider/index.js +2 -0
  9. package/dist/components/portal-provider/portal-provider.d.ts +21 -0
  10. package/dist/components/portal-provider/portal-provider.js +20 -0
  11. package/dist/components/portal-provider/portal-provider.types.d.ts +22 -0
  12. package/dist/components/toast/toast-provider.js +3 -1
  13. package/dist/components/tooltip/tooltip-content.js +4 -1
  14. package/dist/composables/focus-scope-registry.d.ts +9 -1
  15. package/dist/composables/focus-scope-registry.js +9 -2
  16. package/dist/composables/index.d.ts +1 -0
  17. package/dist/composables/use-combo-box.js +6 -3
  18. package/dist/composables/use-dismissable.d.ts +6 -0
  19. package/dist/composables/use-dismissable.js +8 -10
  20. package/dist/composables/use-focus-scope.d.ts +6 -0
  21. package/dist/composables/use-focus-scope.js +10 -2
  22. package/dist/composables/use-modal-overlay.js +14 -2
  23. package/dist/composables/use-portal.d.ts +35 -0
  24. package/dist/composables/use-portal.js +47 -0
  25. package/dist/index.js +3 -1
  26. package/dist/ropav.min.css +1 -1
  27. package/dist/utils/aria-hide-outside.d.ts +4 -1
  28. package/dist/utils/aria-hide-outside.js +17 -5
  29. package/dist/utils/position.d.ts +1 -1
  30. package/dist/utils/position.js +66 -52
  31. package/dist/utils/region.d.ts +10 -0
  32. package/dist/utils/region.js +13 -0
  33. package/dist/version.js +1 -1
  34. package/package.json +6 -2
@@ -1,6 +1,7 @@
1
1
  import { dataAttr } from "../../utils/assertion.js";
2
2
  import { provideFocusResponder } from "../../composables/focus-responder.js";
3
3
  import { providePressResponder } from "../../composables/press-responder.js";
4
+ import { usePortal } from "../../composables/use-portal.js";
4
5
  import { useModalOverlay } from "../../composables/use-modal-overlay.js";
5
6
  import { useModalTransition } from "../../composables/use-modal-transition.js";
6
7
  import { provideAlertDialogContext, provideAlertDialogOverlayContext, useAlertDialogContext } from "./alert-dialog.context.js";
@@ -101,7 +102,8 @@ var alert_dialog_backdrop_vue_vue_type_script_setup_true_vapor_true_lang_default
101
102
  }
102
103
  });
103
104
  const styles = computed(() => slots.value.backdrop({ class: props.class }));
104
- const target = computed(() => props.portalContainer ?? "body");
105
+ const portal = usePortal();
106
+ const target = computed(() => props.portalContainer ?? portal.container.value);
105
107
  const setElement = (next) => {
106
108
  backdropElement.value = next ?? null;
107
109
  };
@@ -1,6 +1,7 @@
1
1
  import { dataAttr } from "../../utils/assertion.js";
2
2
  import { provideFocusResponder } from "../../composables/focus-responder.js";
3
3
  import { providePressResponder } from "../../composables/press-responder.js";
4
+ import { usePortal } from "../../composables/use-portal.js";
4
5
  import { useModalOverlay } from "../../composables/use-modal-overlay.js";
5
6
  import { useModalTransition } from "../../composables/use-modal-transition.js";
6
7
  import { provideDrawerContext, provideDrawerOverlayContext, useDrawerContext } from "./drawer.context.js";
@@ -98,7 +99,8 @@ var drawer_backdrop_vue_vue_type_script_setup_true_vapor_true_lang_default = /*@
98
99
  }
99
100
  });
100
101
  const styles = computed(() => slots.value.backdrop({ class: props.class }));
101
- const target = computed(() => props.portalContainer ?? "body");
102
+ const portal = usePortal();
103
+ const target = computed(() => props.portalContainer ?? portal.container.value);
102
104
  const setElement = (next) => {
103
105
  backdropElement.value = next ?? null;
104
106
  };
@@ -58,6 +58,7 @@ export * from "./modal/index.js";
58
58
  export * from "./number-field/index.js";
59
59
  export * from "./pagination/index.js";
60
60
  export * from "./popover/index.js";
61
+ export * from "./portal-provider/index.js";
61
62
  export * from "./progress-bar/index.js";
62
63
  export * from "./progress-circle/index.js";
63
64
  export * from "./radio/index.js";
@@ -5,7 +5,7 @@ import { useProgressValue } from "../../composables/use-progress-value.js";
5
5
  import { computed, createSlot, defineVaporComponent, renderEffect, setAttr, setClass, setInsertionState, setProp, template, unref } from "vue";
6
6
  import { meterVariants } from "@ropav/styles";
7
7
  //#region src/components/meter/meter-root.vue?vue&type=script&setup=true&vapor=true&lang.ts
8
- var t0 = template("<div data-slot=meter role=\"meter progressbar\">", 1);
8
+ var t0 = template("<div data-slot=meter role=meter>", 1);
9
9
  var meter_root_vue_vue_type_script_setup_true_vapor_true_lang_default = /*@__PURE__*/ defineVaporComponent({
10
10
  __name: "meter-root",
11
11
  props: {
@@ -1,6 +1,7 @@
1
1
  import { dataAttr } from "../../utils/assertion.js";
2
2
  import { provideFocusResponder } from "../../composables/focus-responder.js";
3
3
  import { providePressResponder } from "../../composables/press-responder.js";
4
+ import { usePortal } from "../../composables/use-portal.js";
4
5
  import { useModalOverlay } from "../../composables/use-modal-overlay.js";
5
6
  import { useModalTransition } from "../../composables/use-modal-transition.js";
6
7
  import { provideModalContext, provideModalOverlayContext, useModalContext } from "./modal.context.js";
@@ -95,7 +96,8 @@ var modal_backdrop_vue_vue_type_script_setup_true_vapor_true_lang_default = /*@_
95
96
  }
96
97
  });
97
98
  const styles = computed(() => slots.value.backdrop({ class: props.class }));
98
- const target = computed(() => props.portalContainer ?? "body");
99
+ const portal = usePortal();
100
+ const target = computed(() => props.portalContainer ?? portal.container.value);
99
101
  const setElement = (next) => {
100
102
  backdropElement.value = next ?? null;
101
103
  };
@@ -3,6 +3,7 @@ import { provideSurfaceContext } from "../surface/surface.context.js";
3
3
  import { ariaHideOutside, keepVisible } from "../../utils/aria-hide-outside.js";
4
4
  import { useDismissable } from "../../composables/use-dismissable.js";
5
5
  import { useFocusScope } from "../../composables/use-focus-scope.js";
6
+ import { usePortal } from "../../composables/use-portal.js";
6
7
  import { usePreventScroll } from "../../composables/use-prevent-scroll.js";
7
8
  import { useEnterExit } from "../../composables/use-enter-exit.js";
8
9
  import { provideOverlayGroupContext, useOverlayGroupContext, useOverlayTargetContext } from "./overlay.context.js";
@@ -54,6 +55,7 @@ var overlay_popover_vue_vue_type_script_setup_true_vapor_true_lang_default = /*@
54
55
  const props = __props;
55
56
  const target = useOverlayTargetContext();
56
57
  const group = useOverlayGroupContext();
58
+ const portal = usePortal();
57
59
  /**
58
60
  * Modality belongs to the overlay, not to the thing that opened it.
59
61
  *
@@ -88,6 +90,7 @@ var overlay_popover_vue_vue_type_script_setup_true_vapor_true_lang_default = /*@
88
90
  const { arrowStyle, overlayStyle, placement } = useOverlayPosition({
89
91
  arrowBoundaryOffset: () => props.arrowBoundaryOffset,
90
92
  arrowRef: arrow,
93
+ boundaryElement: () => portal.root.value,
91
94
  containerPadding: () => props.containerPadding,
92
95
  crossOffset: () => props.crossOffset,
93
96
  isOpen: () => target.state.isOpen.value,
@@ -138,6 +141,7 @@ var overlay_popover_vue_vue_type_script_setup_true_vapor_true_lang_default = /*@
138
141
  isOpen: () => target.state.isOpen.value,
139
142
  onClose: target.state.close,
140
143
  overlayRef: element,
144
+ region: () => portal.root.value,
141
145
  shouldCloseOnBlur: true,
142
146
  shouldCloseOnInteractOutside
143
147
  });
@@ -157,14 +161,18 @@ var overlay_popover_vue_vue_type_script_setup_true_vapor_true_lang_default = /*@
157
161
  () => target.state.isOpen.value,
158
162
  element,
159
163
  ownContainer,
160
- isNonModal
161
- ], ([isOpen, popover, container], _previous, onCleanup) => {
164
+ isNonModal,
165
+ () => portal.root.value
166
+ ], ([isOpen, popover, container, nonModal, root], _previous, onCleanup) => {
162
167
  if (!isOpen || !popover) return;
163
- if (isNonModal.value) {
168
+ if (nonModal) {
164
169
  onCleanup(keepVisible(popover) ?? (() => {}));
165
170
  return;
166
171
  }
167
- onCleanup(ariaHideOutside([container ?? popover], { shouldUseInert: true }));
172
+ onCleanup(ariaHideOutside([container ?? popover], {
173
+ root: root ?? void 0,
174
+ shouldUseInert: true
175
+ }));
168
176
  }, {
169
177
  flush: "post",
170
178
  immediate: true
@@ -172,6 +180,7 @@ var overlay_popover_vue_vue_type_script_setup_true_vapor_true_lang_default = /*@
172
180
  useFocusScope({
173
181
  contain: () => isDialog.value || focusContainRequests.value > 0,
174
182
  isActive: () => target.state.isOpen.value,
183
+ region: () => portal.root.value,
175
184
  restoreFocus: true,
176
185
  scopeRef: element
177
186
  });
@@ -215,7 +224,7 @@ var overlay_popover_vue_vue_type_script_setup_true_vapor_true_lang_default = /*@
215
224
  ownContainer.value = next ?? null;
216
225
  };
217
226
  return [createIf(() => !unref(isSubOverlay) && isPresent.value, () => {
218
- return createComponent(VaporTeleport, { to: "body" }, () => {
227
+ return createComponent(VaporTeleport, { to: () => unref(portal).container.value }, () => {
219
228
  const n2 = t0();
220
229
  setStyle(n2, CONTAINER_STYLE);
221
230
  setTemplateRefBinding(n2, () => setContainer);
@@ -0,0 +1,3 @@
1
+ import PortalProviderRoot from "./portal-provider.js";
2
+ export { PortalProviderRoot as PortalProvider };
3
+ export type { PortalProviderRootProps as PortalProviderProps } from "./portal-provider.types.js";
@@ -0,0 +1,2 @@
1
+ import portal_provider_default from "./portal-provider.js";
2
+ export { portal_provider_default as PortalProvider };
@@ -0,0 +1,21 @@
1
+ import type { PortalProviderRootProps } from "./portal-provider.types.js";
2
+ declare const __VLS_export: (__VLS_props: NonNullable<Awaited<typeof __VLS_setup>>['props'], __VLS_ctx?: __VLS_PrettifyLocal<Pick<NonNullable<Awaited<typeof __VLS_setup>>, 'attrs' | 'emit' | 'slots'>>, __VLS_exposed?: NonNullable<Awaited<typeof __VLS_setup>>['expose'], __VLS_setup?: Promise<{
3
+ props: import('vue').PublicProps & __VLS_PrettifyLocal<PortalProviderRootProps> & (typeof globalThis extends {
4
+ __VLS_PROPS_FALLBACK: infer P;
5
+ } ? P : {});
6
+ expose: (exposed: {}) => void;
7
+ attrs: any;
8
+ slots: {
9
+ default?: () => unknown;
10
+ };
11
+ emit: {};
12
+ }>) => import('vue').VNode & {
13
+ __ctx?: NonNullable<Awaited<typeof __VLS_setup>>;
14
+ };
15
+ declare const _default: typeof __VLS_export;
16
+ export default _default;
17
+ type __VLS_PrettifyLocal<T> = (T extends any ? {
18
+ [K in keyof T]: T[K];
19
+ } : {
20
+ [K in keyof T as K]: T[K];
21
+ }) & {};
@@ -0,0 +1,20 @@
1
+ import { providePortal } from "../../composables/use-portal.js";
2
+ import { createSlot, defineVaporComponent } from "vue";
3
+ //#region src/components/portal-provider/portal-provider.vue?vue&type=script&setup=true&vapor=true&lang.ts
4
+ var portal_provider_vue_vue_type_script_setup_true_vapor_true_lang_default = /*@__PURE__*/ defineVaporComponent({
5
+ __name: "portal-provider",
6
+ props: {
7
+ container: {},
8
+ root: {}
9
+ },
10
+ setup(__props) {
11
+ const props = __props;
12
+ providePortal({
13
+ container: () => props.container,
14
+ root: () => props.root
15
+ });
16
+ return createSlot();
17
+ }
18
+ });
19
+ //#endregion
20
+ export { portal_provider_vue_vue_type_script_setup_true_vapor_true_lang_default as default };
@@ -0,0 +1,22 @@
1
+ export interface PortalProviderRootProps {
2
+ /**
3
+ * Where every overlay below renders: modals, alert dialogs, drawers, popovers, menus, the
4
+ * listboxes of pickers, tooltips and toast regions. A component's own `portalContainer` still
5
+ * wins.
6
+ *
7
+ * Omitted, it inherits the enclosing provider's container; `null` goes back to the document's
8
+ * body. An element other than the body has to be a containing block for `position: fixed` (a
9
+ * `transform`, or `contain: layout` or `paint`) sized to the area the overlays cover: backdrops
10
+ * fill it, and modals size to it rather than to the visual viewport.
11
+ */
12
+ container?: HTMLElement | string | null;
13
+ /**
14
+ * The part of the page the overlays below belong to, usually an element holding both the content
15
+ * and the `container`. A modal makes only this element `inert` and holds focus and outside
16
+ * presses only within it, and a placed overlay flips and shifts to stay inside it — so an app
17
+ * rendered in a window of a larger page leaves the rest of that page alone.
18
+ *
19
+ * Omitted, it inherits the enclosing provider's root; `null` goes back to the whole document.
20
+ */
21
+ root?: HTMLElement | null;
22
+ }
@@ -1,5 +1,6 @@
1
1
  import { dataAttr } from "../../utils/assertion.js";
2
2
  import { composeSlotClassName } from "../../utils/compose.js";
3
+ import { usePortal } from "../../composables/use-portal.js";
3
4
  import { useLocale } from "../../composables/use-locale.js";
4
5
  import { provideToastRegionContext } from "./toast.context.js";
5
6
  import { DEFAULT_SCALE_FACTOR, EXIT_FALLBACK } from "./toast.constants.js";
@@ -115,7 +116,8 @@ var toast_provider_vue_vue_type_script_setup_true_vapor_true_lang_default = /*@_
115
116
  slots,
116
117
  visibleToasts
117
118
  });
118
- const target = computed(() => props.portalContainer ?? "body");
119
+ const portal = usePortal();
120
+ const target = computed(() => props.portalContainer ?? portal.container.value);
119
121
  const styles = computed(() => composeSlotClassName(slots.value.region, props.class));
120
122
  const regionStyle = computed(() => ({
121
123
  "--gap": `${props.gap}px`,
@@ -1,4 +1,5 @@
1
1
  import { dataAttr } from "../../utils/assertion.js";
2
+ import { usePortal } from "../../composables/use-portal.js";
2
3
  import { useEnterExit } from "../../composables/use-enter-exit.js";
3
4
  import { provideOverlayArrowContext } from "../overlay/overlay.context.js";
4
5
  import { useOverlayPosition } from "../../composables/use-overlay-position.js";
@@ -38,6 +39,7 @@ var tooltip_content_vue_vue_type_script_setup_true_vapor_true_lang_default = /*@
38
39
  const { shouldSkipAnimation, slots, state, tooltipId, triggerElement } = useTooltipContext();
39
40
  const element = shallowRef(null);
40
41
  const arrow = shallowRef(null);
42
+ const portal = usePortal();
41
43
  /**
42
44
  * Built straight on the positioner rather than on the shared overlay primitive.
43
45
  *
@@ -50,6 +52,7 @@ var tooltip_content_vue_vue_type_script_setup_true_vapor_true_lang_default = /*@
50
52
  const { arrowStyle, overlayStyle, placement } = useOverlayPosition({
51
53
  arrowBoundaryOffset: () => props.arrowBoundaryOffset,
52
54
  arrowRef: arrow,
55
+ boundaryElement: () => portal.root.value,
53
56
  containerPadding: () => props.containerPadding,
54
57
  crossOffset: () => props.crossOffset,
55
58
  isOpen: () => state.isOpen.value,
@@ -76,7 +79,7 @@ var tooltip_content_vue_vue_type_script_setup_true_vapor_true_lang_default = /*@
76
79
  const isExiting = computed(() => props.isExiting ?? (!shouldSkipAnimation.value && enterExit.isExiting.value));
77
80
  const isPresent = computed(() => state.isOpen.value || enterExit.isExiting.value || props.isExiting === true);
78
81
  const styles = computed(() => slots.value.base({ class: props.class }));
79
- const target = computed(() => props.portalContainer ?? "body");
82
+ const target = computed(() => props.portalContainer ?? portal.container.value);
80
83
  const setElement = (next) => {
81
84
  element.value = next ?? null;
82
85
  };
@@ -1,8 +1,16 @@
1
1
  export interface RegisteredScope {
2
2
  root: () => HTMLElement | null;
3
3
  contain: () => boolean;
4
+ /** The part of the page the scope belongs to, `null` for the whole document. */
5
+ region: () => Element | null;
4
6
  }
5
- export declare const innermostContainingScope: () => RegisteredScope | null;
7
+ /**
8
+ * The innermost scope holding focus in a region, among the scopes whose region overlaps it.
9
+ *
10
+ * Without a region every scope overlaps every other, which is the page-wide question. With one, a
11
+ * modal in one app's window is not shadowed by a modal another app opened later in its own.
12
+ */
13
+ export declare const innermostContainingScope: (region?: Element | null) => RegisteredScope | null;
6
14
  /**
7
15
  * Whether the element sits inside any registered scope.
8
16
  *
@@ -1,3 +1,4 @@
1
+ import { regionsOverlap } from "../utils/region.js";
1
2
  //#region src/composables/focus-scope-registry.ts
2
3
  /**
3
4
  * Registered scopes, outermost first.
@@ -7,10 +8,16 @@
7
8
  * inside it has to be allowed through rather than pulled back.
8
9
  */
9
10
  var scopes = [];
10
- var innermostContainingScope = () => {
11
+ /**
12
+ * The innermost scope holding focus in a region, among the scopes whose region overlaps it.
13
+ *
14
+ * Without a region every scope overlaps every other, which is the page-wide question. With one, a
15
+ * modal in one app's window is not shadowed by a modal another app opened later in its own.
16
+ */
17
+ var innermostContainingScope = (region = null) => {
11
18
  for (let index = scopes.length - 1; index >= 0; index--) {
12
19
  const scope = scopes[index];
13
- if (scope.contain()) return scope;
20
+ if (scope.contain() && regionsOverlap(scope.region(), region)) return scope;
14
21
  }
15
22
  return null;
16
23
  };
@@ -26,6 +26,7 @@ export * from "./use-move.js";
26
26
  export * from "./use-number-formatter.js";
27
27
  export * from "./use-overlay-position.js";
28
28
  export * from "./use-overlay-trigger-state.js";
29
+ export * from "./use-portal.js";
29
30
  export * from "./use-press.js";
30
31
  export * from "./use-prevent-scroll.js";
31
32
  export * from "./use-selection-manager.js";
@@ -1,5 +1,6 @@
1
1
  import { useId as useId$1 } from "./use-id.js";
2
2
  import { ariaHideOutside } from "../utils/aria-hide-outside.js";
3
+ import { usePortal } from "./use-portal.js";
3
4
  import { isAppleDevice } from "../utils/platform.js";
4
5
  import { useOverlayTrigger } from "./use-overlay-trigger.js";
5
6
  import { usePress } from "./use-press.js";
@@ -221,6 +222,7 @@ var useComboBox = (options, state) => {
221
222
  flush: "post",
222
223
  immediate: true
223
224
  });
225
+ const portal = usePortal();
224
226
  /**
225
227
  * Everything but the field and its options is taken out of the accessibility tree while the
226
228
  * popover is open.
@@ -232,10 +234,11 @@ var useComboBox = (options, state) => {
232
234
  watch([
233
235
  () => state.isOpen.value,
234
236
  inputElement,
235
- () => toValue(options.popoverElement)
236
- ], ([isOpen, input, popover], _previous, onCleanup) => {
237
+ () => toValue(options.popoverElement),
238
+ () => portal.root.value
239
+ ], ([isOpen, input, popover, root], _previous, onCleanup) => {
237
240
  if (!isOpen || !popover) return;
238
- onCleanup(ariaHideOutside([input, popover].filter((element) => element != null)));
241
+ onCleanup(ariaHideOutside([input, popover].filter((element) => element != null), { root: root ?? void 0 }));
239
242
  }, {
240
243
  flush: "post",
241
244
  immediate: true
@@ -23,6 +23,12 @@ export interface UseDismissableOptions {
23
23
  * should be left alone — the trigger of an already-open submenu, for one.
24
24
  */
25
25
  shouldCloseOnInteractOutside?: (element: Element) => boolean;
26
+ /**
27
+ * The part of the page the overlay belongs to. A press outside it — in another app's window
28
+ * beside this one — is neither an outside interaction nor swallowed, and the overlay is only
29
+ * stacked with overlays whose region overlaps its own. `null` or absent: the whole document.
30
+ */
31
+ region?: MaybeRefOrGetter<Element | null | undefined>;
26
32
  }
27
33
  export interface UseDismissableReturn {
28
34
  /** Bind to the overlay so Escape dismisses it. */
@@ -1,14 +1,8 @@
1
+ import { regionsOverlap } from "../utils/region.js";
1
2
  import { isInTopLayer } from "../utils/top-layer.js";
2
3
  import { isElementInAnyFocusScope } from "./focus-scope-registry.js";
3
4
  import { computed, onScopeDispose, toValue, watch } from "vue";
4
5
  //#region src/composables/use-dismissable.ts
5
- /**
6
- * Open overlays, innermost last.
7
- *
8
- * Module-level on purpose: a click outside must dismiss exactly one overlay — the innermost —
9
- * and no single overlay can know whether another is nested inside it. React Aria keeps the same
10
- * list for the same reason.
11
- */
12
6
  var visibleOverlays = [];
13
7
  /**
14
8
  * Dismiss an overlay on Escape or on an interaction outside it, ported from React Aria's
@@ -37,11 +31,13 @@ var visibleOverlays = [];
37
31
  * ```
38
32
  */
39
33
  var useDismissable = (options) => {
40
- const layer = {};
34
+ const layer = { region: () => toValue(options.region) ?? null };
41
35
  const isOpen = computed(() => Boolean(toValue(options.isOpen)));
42
36
  const isDismissable = computed(() => Boolean(toValue(options.isDismissable)));
43
37
  const getOverlay = () => toValue(options.overlayRef) ?? null;
44
- const isTopMost = () => visibleOverlays.at(-1) === layer;
38
+ /** The innermost open overlay this one is stacked with — possibly itself. */
39
+ const innermost = () => visibleOverlays.findLast((other) => regionsOverlap(other.region(), layer.region()));
40
+ const isTopMost = () => innermost() === layer;
45
41
  const close = () => {
46
42
  if (isTopMost()) options.onClose?.();
47
43
  };
@@ -61,6 +57,8 @@ var useDismissable = (options) => {
61
57
  const target = event.target;
62
58
  if (!(target instanceof Element) || !target.ownerDocument.documentElement.contains(target)) return false;
63
59
  if (isInTopLayer(target)) return false;
60
+ const region = layer.region();
61
+ if (region && !region.contains(target)) return false;
64
62
  return !event.composedPath().includes(overlay);
65
63
  };
66
64
  /** Whether the pointerdown that began this interaction was outside. */
@@ -72,7 +70,7 @@ var useDismissable = (options) => {
72
70
  if (!isDismissable.value || !isOpen.value || typeof document === "undefined") return;
73
71
  const onPointerdown = (event) => {
74
72
  if (!isOutside(event)) return;
75
- layerAtPointerDown = visibleOverlays.at(-1);
73
+ layerAtPointerDown = innermost();
76
74
  if (options.shouldCloseOnInteractOutside && !options.shouldCloseOnInteractOutside(event.target)) return;
77
75
  pointerDownWasOutside = true;
78
76
  if (isTopMost()) event.stopPropagation();
@@ -23,6 +23,12 @@ export interface UseFocusScopeOptions {
23
23
  * @default false
24
24
  */
25
25
  autoFocus?: MaybeRefOrGetter<boolean | "first" | "last" | undefined>;
26
+ /**
27
+ * The part of the page containment holds focus within. Focus moving to something outside it —
28
+ * another app's window beside this one — is left alone rather than pulled back, and Tab pressed
29
+ * out there is not taken over. `null` or absent: the whole document.
30
+ */
31
+ region?: MaybeRefOrGetter<Element | null | undefined>;
26
32
  }
27
33
  /**
28
34
  * Keep focus inside an overlay and give it back when the overlay closes, ported from React
@@ -31,8 +31,14 @@ var useFocusScope = (options) => {
31
31
  const getRoot = () => toValue(options.scopeRef) ?? null;
32
32
  const scope = {
33
33
  contain: () => Boolean(toValue(options.contain)),
34
+ region: () => toValue(options.region) ?? null,
34
35
  root: getRoot
35
36
  };
37
+ /** Whether an element is somewhere this scope has any say over. */
38
+ const isInRegion = (element) => {
39
+ const region = scope.region();
40
+ return !region || !element || region.contains(element);
41
+ };
36
42
  const focusElement = (element) => {
37
43
  element?.focus({ preventScroll: true });
38
44
  };
@@ -49,7 +55,8 @@ var useFocusScope = (options) => {
49
55
  const ownerDocument = root.ownerDocument;
50
56
  const onKeydown = (event) => {
51
57
  if (event.key !== "Tab" || event.altKey || event.ctrlKey || event.metaKey) return;
52
- if (innermostContainingScope() !== scope) return;
58
+ if (innermostContainingScope(scope.region()) !== scope) return;
59
+ if (!isInRegion(ownerDocument.activeElement)) return;
53
60
  const focusable = tabbableIn(root);
54
61
  if (focusable.length === 0) {
55
62
  event.preventDefault();
@@ -62,9 +69,10 @@ var useFocusScope = (options) => {
62
69
  focusElement(next);
63
70
  };
64
71
  const onFocusin = (event) => {
65
- if (innermostContainingScope() !== scope) return;
72
+ if (innermostContainingScope(scope.region()) !== scope) return;
66
73
  const target = event.target;
67
74
  if (!(target instanceof Element)) return;
75
+ if (!isInRegion(target)) return;
68
76
  if (isElementInAnyFocusScope(target)) return;
69
77
  focusElement(root);
70
78
  };
@@ -2,6 +2,7 @@ import { ariaHideOutside } from "../utils/aria-hide-outside.js";
2
2
  import { useDismissable } from "./use-dismissable.js";
3
3
  import { useFocusScope } from "./use-focus-scope.js";
4
4
  import { usePageSize } from "./use-page-size.js";
5
+ import { usePortal } from "./use-portal.js";
5
6
  import { usePreventScroll } from "./use-prevent-scroll.js";
6
7
  import { useViewportSize } from "./use-viewport-size.js";
7
8
  import { computed, toValue, watch } from "vue";
@@ -35,17 +36,26 @@ import { computed, toValue, watch } from "vue";
35
36
  */
36
37
  var useModalOverlay = (options) => {
37
38
  const isOpen = () => toValue(options.isOpen);
39
+ const portal = usePortal();
38
40
  const dismissable = useDismissable({
39
41
  isDismissable: options.isDismissable,
40
42
  isKeyboardDismissDisabled: options.isKeyboardDismissDisabled,
41
43
  isOpen,
42
44
  onClose: options.onClose,
43
45
  overlayRef: options.modalRef,
46
+ region: () => portal.root.value,
44
47
  shouldCloseOnInteractOutside: options.shouldCloseOnInteractOutside
45
48
  });
46
- watch([isOpen, () => toValue(options.modalRef)], ([open, modal], _previous, onCleanup) => {
49
+ watch([
50
+ isOpen,
51
+ () => toValue(options.modalRef),
52
+ () => portal.root.value
53
+ ], ([open, modal, root], _previous, onCleanup) => {
47
54
  if (!open || !modal) return;
48
- onCleanup(ariaHideOutside([modal], { shouldUseInert: true }));
55
+ onCleanup(ariaHideOutside([modal], {
56
+ root: root ?? void 0,
57
+ shouldUseInert: true
58
+ }));
49
59
  }, {
50
60
  flush: "post",
51
61
  immediate: true
@@ -53,6 +63,7 @@ var useModalOverlay = (options) => {
53
63
  useFocusScope({
54
64
  contain: true,
55
65
  isActive: isOpen,
66
+ region: () => portal.root.value,
56
67
  restoreFocus: true,
57
68
  scopeRef: options.modalRef
58
69
  });
@@ -64,6 +75,7 @@ var useModalOverlay = (options) => {
64
75
  page: computed(() => page.value),
65
76
  viewport: computed(() => viewport.value),
66
77
  viewportStyle: computed(() => {
78
+ if (portal.isContained.value) return {};
67
79
  const style = {
68
80
  "--visual-viewport-height": `${viewport.value.height}px`,
69
81
  "--visual-viewport-width": `${viewport.value.width}px`
@@ -0,0 +1,35 @@
1
+ import type { ComputedRef, MaybeRefOrGetter } from "vue";
2
+ /** Where the overlays below a `PortalProvider` go, and what they treat as the page. */
3
+ export interface Portal {
4
+ /** Where every overlay is rendered: `"body"` without a provider. */
5
+ container: ComputedRef<HTMLElement | string>;
6
+ /**
7
+ * The part of the page an overlay belongs to: what a modal hides outside itself (`inert`), what
8
+ * it holds focus and outside presses within, and what placement keeps an overlay inside. `null`
9
+ * (the whole document) without a provider, or with one that names no root.
10
+ */
11
+ root: ComputedRef<HTMLElement | null>;
12
+ /** Whether overlays render into an element of their own rather than the document's body. */
13
+ isContained: ComputedRef<boolean>;
14
+ }
15
+ /**
16
+ * The portal that applies here: the nearest `PortalProvider`'s, or the document's body.
17
+ *
18
+ * Ported from React Aria's `UNSAFE_PortalProvider` (`getContainer`), with the root added: an
19
+ * overlay rendered into a region of the page, say an app's window, belongs to that region and has
20
+ * to leave the rest of the page alone — hidden, focused and pressed as it was.
21
+ */
22
+ export declare const usePortal: () => Portal;
23
+ export interface PortalOptions {
24
+ /** `undefined` inherits the enclosing portal's container, `null` goes back to the body. */
25
+ container: MaybeRefOrGetter<HTMLElement | string | null | undefined>;
26
+ /** `undefined` inherits the enclosing portal's root, `null` goes back to the whole document. */
27
+ root?: MaybeRefOrGetter<HTMLElement | null | undefined>;
28
+ }
29
+ /**
30
+ * Send every overlay below into `container`, belonging to `root`.
31
+ *
32
+ * Absent and `null` differ as they do in React Aria: a nested provider that names only a container
33
+ * keeps the root of the one around it, and an explicit `null` is the way back to the page.
34
+ */
35
+ export declare const providePortal: (options: PortalOptions) => Portal;
@@ -0,0 +1,47 @@
1
+ import { createContext } from "../utils/create-context.js";
2
+ import { computed, toValue } from "vue";
3
+ //#region src/composables/use-portal.ts
4
+ var [usePortalContext, providePortalContext] = createContext({
5
+ defaultValue: null,
6
+ name: "PortalContext",
7
+ strict: false
8
+ });
9
+ var BODY = {
10
+ container: computed(() => "body"),
11
+ isContained: computed(() => false),
12
+ root: computed(() => null)
13
+ };
14
+ /**
15
+ * The portal that applies here: the nearest `PortalProvider`'s, or the document's body.
16
+ *
17
+ * Ported from React Aria's `UNSAFE_PortalProvider` (`getContainer`), with the root added: an
18
+ * overlay rendered into a region of the page, say an app's window, belongs to that region and has
19
+ * to leave the rest of the page alone — hidden, focused and pressed as it was.
20
+ */
21
+ var usePortal = () => usePortalContext() ?? BODY;
22
+ /**
23
+ * Send every overlay below into `container`, belonging to `root`.
24
+ *
25
+ * Absent and `null` differ as they do in React Aria: a nested provider that names only a container
26
+ * keeps the root of the one around it, and an explicit `null` is the way back to the page.
27
+ */
28
+ var providePortal = (options) => {
29
+ const parent = usePortal();
30
+ const container = computed(() => {
31
+ const own = toValue(options.container);
32
+ return own === void 0 ? parent.container.value : own ?? "body";
33
+ });
34
+ const root = computed(() => {
35
+ const own = toValue(options.root);
36
+ return own === void 0 ? parent.root.value : own;
37
+ });
38
+ const portal = {
39
+ container,
40
+ isContained: computed(() => container.value !== "body" && (typeof document === "undefined" || container.value !== document.body)),
41
+ root
42
+ };
43
+ providePortalContext(portal);
44
+ return portal;
45
+ };
46
+ //#endregion
47
+ export { providePortal, usePortal };