Skip to content

Commit

Permalink
fix(dropdown): prevent background scrolling on mobile (#1022)
Browse files Browse the repository at this point in the history
  • Loading branch information
mlmoravek committed Jul 17, 2024
1 parent eb856b4 commit bc1b497
Show file tree
Hide file tree
Showing 7 changed files with 86 additions and 71 deletions.
2 changes: 1 addition & 1 deletion packages/docs/components/Modal.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
| overlay | Show an overlay | boolean | - | <div><small>From <b>config</b>:</small></div><code style='white-space: nowrap; padding: 0;'>modal: {<br>&nbsp;&nbsp;overlay: true<br>}</code> |
| override | Override existing theme classes completely | boolean | - | |
| props | Props to be binded to the injected component | Record&lt;string, any&gt; | - | |
| scroll | Use `clip` to remove the body scrollbar, `keep` to have a non scrollable scrollbar to avoid shifting background,<br/>but will set body to position fixed, might break some layouts. | string | `keep`, `clip` | <div><small>From <b>config</b>:</small></div><code style='white-space: nowrap; padding: 0;'>modal: {<br>&nbsp;&nbsp;scroll: "keep"<br>}</code> |
| scroll | Use `clip` to remove the body scrollbar, `keep` to have a non scrollable scrollbar to avoid shifting background,<br/>but will set body to position fixed, might break some layouts. | "keep" \| "clip" | `keep`, `clip` | <div><small>From <b>config</b>:</small></div><code style='white-space: nowrap; padding: 0;'>modal: {<br>&nbsp;&nbsp;scroll: "keep"<br>}</code> |
| teleport | Append the component to another part of the DOM.<br/>Set `true` to append the component to the body.<br/>In addition, any CSS selector string or an actual DOM node can be used. | string \| boolean \| Record&lt;string, any&gt; | - | <div><small>From <b>config</b>:</small></div><code style='white-space: nowrap; padding: 0;'>modal: {<br>&nbsp;&nbsp;teleport: false<br>}</code> |
| trapFocus | Trap focus inside the modal | boolean | - | <div><small>From <b>config</b>:</small></div><code style='white-space: nowrap; padding: 0;'>modal: {<br>&nbsp;&nbsp;trapFocus: true<br>}</code> |
| width | Width of the Modal | string \| number | - | <div><small>From <b>config</b>:</small></div><code style='white-space: nowrap; padding: 0;'>modal: {<br>&nbsp;&nbsp;width: 960<br>}</code> |
Expand Down
4 changes: 4 additions & 0 deletions packages/oruga/src/components/dropdown/Dropdown.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
useEventListener,
useClickOutside,
useVModel,
usePreventScrolling,
} from "@/composables";
import type { DynamicComponent } from "@/types";
Expand Down Expand Up @@ -137,6 +138,8 @@ const menuStyle = computed(() => ({
const hoverable = computed(() => props.triggers.indexOf("hover") >= 0);
const toggleScroll = usePreventScrolling();
// --- Event Handler ---
const contentRef = ref<HTMLElement | Component>();
Expand Down Expand Up @@ -182,6 +185,7 @@ watch(
eventCleanups.forEach((fn) => fn());
eventCleanups.length = 0;
}
if (isMobile.value) toggleScroll(value);
},
{ immediate: true, flush: "post" },
);
Expand Down
4 changes: 4 additions & 0 deletions packages/oruga/src/components/dropdown/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,4 +114,8 @@ type DropdownClasses = Partial<{
activeClass: ComponentClass;
/** Class for the root element when the dropdown is hoverable */
hoverableClass: ComponentClass;
/** Class of the body when dropdown is open and scroll is clip */
scrollClipClass: ComponentClass;
/** Class of the body when dropdown is open and scroll is not clip */
noScrollClass: ComponentClass;
}>;
71 changes: 7 additions & 64 deletions packages/oruga/src/components/modal/Modal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
computed,
watch,
nextTick,
onBeforeUnmount,
onMounted,
type Component,
type PropType,
Expand All @@ -18,10 +17,10 @@ import { removeElement, toCssDimension } from "@/utils/helpers";
import { isClient } from "@/utils/ssr";
import {
defineClasses,
getActiveClasses,
useClickOutside,
useEventListener,
useMatchMedia,
usePreventScrolling,
useProgrammaticComponent,
} from "@/composables";
Expand Down Expand Up @@ -80,7 +79,7 @@ const props = defineProps({
* @values keep, clip
*/
scroll: {
type: String,
type: String as PropType<"keep" | "clip">,
default: () => getOption("modal.scroll", "keep"),
validator: (value: string) => ["keep", "clip"].indexOf(value) >= 0,
},
Expand Down Expand Up @@ -207,12 +206,12 @@ const props = defineProps({
type: [String, Array, Function] as PropType<ComponentClass>,
default: undefined,
},
/** Class of the modal when scroll is clip */
/** Class of the body when modal is open and scroll is clip */
scrollClipClass: {
type: [String, Array, Function] as PropType<ComponentClass>,
default: undefined,
},
/** Class of the modal when scroll is not clip */
/** Class of the body when modal is open and scroll is not clip */
noScrollClass: {
type: [String, Array, Function] as PropType<ComponentClass>,
default: undefined,
Expand Down Expand Up @@ -271,10 +270,10 @@ const customStyle = computed(() =>
!props.fullScreen ? { maxWidth: toCssDimension(props.width) } : null,
);
const savedScrollTop = ref(null);
const toggleScroll = usePreventScrolling(props.scroll === "keep");
watch(isActive, (value) => {
if (props.overlay) handleScroll();
if (props.overlay) toggleScroll(isActive.value);
// if autoFocus focus the element
if (value && rootRef.value && props.autoFocus)
nextTick(() => rootRef.value.focus());
Expand All @@ -285,22 +284,7 @@ watch(isActive, (value) => {
});
onMounted(() => {
if (isActive.value && props.overlay) handleScroll();
});
onBeforeUnmount(() => {
if (isClient && props.overlay) {
// reset scroll
const scrollto = savedScrollTop.value
? savedScrollTop.value
: document.documentElement.scrollTop;
if (scrollClass.value) {
document.body.classList.remove(...scrollClass.value);
document.documentElement.classList.remove(...scrollClass.value);
}
document.documentElement.scrollTop = scrollto;
document.body.style.top = null;
}
if (isActive.value && props.overlay) toggleScroll(isActive.value);
});
// --- Events Feature ---
Expand Down Expand Up @@ -330,38 +314,6 @@ function clickedOutside(event: Event): void {
cancel("outside");
}
function handleScroll(): void {
if (!isClient) return;
if (props.scroll === "clip") {
if (scrollClass.value) {
if (isActive.value)
document.documentElement.classList.add(...scrollClass.value);
else
document.documentElement.classList.remove(...scrollClass.value);
}
return;
}
savedScrollTop.value = savedScrollTop.value
? savedScrollTop.value
: document.documentElement.scrollTop;
if (scrollClass.value) {
if (isActive.value) document.body.classList.add(...scrollClass.value);
else document.body.classList.remove(...scrollClass.value);
}
if (isActive.value) {
document.body.style.top = `-${savedScrollTop.value}px`;
return;
}
document.documentElement.scrollTop = savedScrollTop.value;
document.body.style.top = null;
savedScrollTop.value = null;
}
// --- Animation Feature ---
const isAnimating = ref(!props.active);
Expand Down Expand Up @@ -398,15 +350,6 @@ const contentClasses = defineClasses(
const closeClasses = defineClasses(["closeClass", "o-modal__close"]);
const scrollClasses = defineClasses(["scrollClipClass", "o-clipped"]);
const noScrollClasses = defineClasses(["noScrollClass", "o-noscroll"]);
const scrollClass = computed(() =>
getActiveClasses(
props.scroll === "clip" ? scrollClasses.value : noScrollClasses.value,
),
);
// --- Expose Public Functionalities ---
/** expose functionalities for programmatic usage */
Expand Down
16 changes: 10 additions & 6 deletions packages/oruga/src/components/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,10 @@ In addition, any CSS selector string or an actual DOM node can be used. */
expandedClass: ClassDefinition;
/** Class of dropdown when on mobile */
mobileClass: ClassDefinition;
/** Class of the body when dropdown is open and scroll is clip */
scrollClipClass: ClassDefinition;
/** Class of the body when dropdown is open and scroll is not clip */
noScrollClass: ClassDefinition;
/** Class of the dropdown item */
itemClass: ClassDefinition;
/** Class of the dropdown item when active */
Expand Down Expand Up @@ -665,6 +669,10 @@ In addition, any CSS selector string or an actual DOM node can be used. */
activeClass: ClassDefinition;
/** Class of modal component when on mobile */
mobileClass: ClassDefinition;
/** Class of the body when modal is open and scroll is clip */
scrollClipClass: ClassDefinition;
/** Class of the body when modal is open and scroll is not clip */
noScrollClass: ClassDefinition;
/** Class of the close button */
closeClass: ClassDefinition;
/** Class of the modal content */
Expand All @@ -673,10 +681,6 @@ In addition, any CSS selector string or an actual DOM node can be used. */
overlayClass: ClassDefinition;
/** Class of the modal when fullscreen */
fullScreenClass: ClassDefinition;
/** Class of the modal when scroll is clip */
scrollClipClass: ClassDefinition;
/** Class of the modal when scroll is not clip */
noScrollClass: ClassDefinition;
/** Class of the root element */
rootClass: ClassDefinition;
/** Close icon name */
Expand All @@ -701,7 +705,7 @@ In addition, any CSS selector string or an actual DOM node can be used. */
trapFocus: boolean;
/** Use `clip` to remove the body scrollbar, `keep` to have a non scrollable scrollbar to avoid shifting background,
but will set body to position fixed, might break some layouts. */
scroll: string;
scroll: "keep" | "clip";
/** Width of the Modal */
width: string | number;
}>;
Expand Down Expand Up @@ -1012,7 +1016,7 @@ but will set body to position fixed, might break some layouts. */
/** Color of the tooltip */
tooltipVariant: string;
/** Define v-model format */
format: string;
format: "raw" | "percent";
/** Rounded thumb */
rounded: boolean;
/** Show tooltip when thumb is being dragged */
Expand Down
1 change: 1 addition & 0 deletions packages/oruga/src/composables/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ export * from "./useClickOutside";
export * from "./useScrollingParent";
export * from "./useObjectMap";
export * from "./useVModel";
export * from "./usePreventScrolling";
59 changes: 59 additions & 0 deletions packages/oruga/src/composables/usePreventScrolling.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import {
computed,
onBeforeUnmount,
ref,
toValue,
type MaybeRefOrGetter,
} from "vue";

import { isClient } from "@/utils/ssr";
import { defineClasses, getActiveClasses } from "./defineClasses";

/**
* Prevent the background from scrolling if toggled.
* Adds `clippled` or `noscroll` class to the body.
* `clip` removes the body scrollbar.
* `keep` makes a non scrollable scrollbar to avoid shifting background, but will set body to position fixed, might break some layouts.
* @param noScroll keep scrollbar or not
*/
export function usePreventScrolling(
noScroll: MaybeRefOrGetter<boolean> = false,
): (active: boolean) => void {
const withScrollClasses = defineClasses(["scrollClipClass", "o-clipped"]);
const noScrollClasses = defineClasses(["noScrollClass", "o-noscroll"]);

const scrollClass = computed(() =>
getActiveClasses(
toValue(noScroll) ? noScrollClasses.value : withScrollClasses.value,
),
);

const savedScrollTop = ref(null);

// reset scroll
onBeforeUnmount(() => toggleScroll(false));

function toggleScroll(active: boolean): void {
if (!isClient) return;
if (!scrollClass.value) return;

savedScrollTop.value = savedScrollTop.value
? savedScrollTop.value
: document.documentElement.scrollTop;

if (active) document.body.classList.add(...scrollClass.value);
else document.body.classList.remove(...scrollClass.value);

if (toValue(noScroll)) {
if (active) {
document.body.style.top = `-${savedScrollTop.value}px`;
} else {
document.documentElement.scrollTop = savedScrollTop.value;
document.body.style.top = null;
savedScrollTop.value = null;
}
}
}

return toggleScroll;
}

0 comments on commit bc1b497

Please sign in to comment.