Dropdowns, select boxes and inline dialogs in iframes with limited height

Most of us who are using AtlasKit have probably at some point come across problems with its lack of support for small viewport heights. Components like AtlasKit select, date/time picker, dropdown menu, inline dialog and probably others are becoming partly unusable when the height of their content exceeds the height of the viewport. This problem becomes particularly apparent for Cloud apps that run inside iframes where the height is even more limited, for example macros whose iframe height adjusts to the height of their content.

In this post I want to share with you the code for a workaround that I have recently built. While a dropdown menu, select box (including date/time picker) or inline dialog is open, the iframe is temporarily enlarged by setting a bottom padding of 350px, making sure that there is enough space to position the menu properly. This is not a very nice or user-friendly way to do it, but it is a way that works.

The code detects open menus in 3 different ways:

  • Select boxes (including date/time pickers) are configured to use a custom menuPortalTarget. A MutationObserver observes whether this menu portal target has any child nodes. If it does, it means that a menu is currently open.
  • Dropdown menus internally use AtlasKit popup, which uses AtlasKit portal to render the popup. AtlasKit portal fires akPortalMount and akPortalUnmount events on the window when they are created/destroyed, and for dropdown menus in particular their detail.layer property is modal.
  • For inline dialogs and other types of menus, their open state is manually passed to a custom hook that keeps track of it.

Implementation

A React hook useHasOpenMenu() is defined that returns true if any menu is currently open. This is its code:

import { useEffect, useReducer, useState } from 'react';
import { PORTAL_MOUNT_EVENT, PORTAL_UNMOUNT_EVENT } from '@atlaskit/portal';

/**
 * A DOM element that can be used as the menuPortalTarget for react-select components and has the right z-index
 * to make sure that they are visible within dialogs.
 */
export const menuPortalTarget = document.createElement('div');
menuPortalTarget.style.zIndex = '750';
document.body.appendChild(menuPortalTarget);

const customMenuListeners = new Set<(isOpen: boolean) => void>();

/**
 * Returns true if any select (using menuPortalTarget), dropdown or custom menu (using useCustomMenuIsOpen())
 * is currently open.
 */
export function useHasOpenMenu(): boolean {
    // Check for open selects by subscribing to the childList of menuPortalTarget. This obviously only works
    // for selects that use the menuPortalTarget.
    const [, forceUpdate] = useReducer(x => x + 1, 0);
    useEffect(() => {
        const observer = new MutationObserver(() => {
            forceUpdate();
        });
        observer.observe(menuPortalTarget, { childList: true });
        return () => {
            observer.disconnect();
        };
    }, []);
    const selects = menuPortalTarget.childNodes.length;

    // Check for dropdowns by listening to the PORTAL_MOUNT_EVENT. This is fired on window by @atlaskit/portal,
    // which is used by @atlaskit/popup, which is used by @atlaskit/dropdown-menu. Dropdown menus set e.detail.layer
    // to 'modal'. It is important to check for this in order to not detect other types of portal users such as
    // tooltips.
    const [dropdowns, setDropdowns] = useState(0);
    useEffect(() => {
        const mountListener = (e: any) => {
            if (e.detail?.layer === 'modal') {
                setDropdowns(dropdowns + 1);
            }
        };
        const unmountListener = (e: any) => {
            if (e.detail?.layer === 'modal') {
                setDropdowns(dropdowns - 1);
            }
        };
        window.addEventListener(PORTAL_MOUNT_EVENT, mountListener);
        window.addEventListener(PORTAL_UNMOUNT_EVENT, unmountListener);
        return () => {
            window.removeEventListener(PORTAL_MOUNT_EVENT, mountListener);
            window.removeEventListener(PORTAL_UNMOUNT_EVENT, unmountListener);
        }
    }, [dropdowns]);

    // Check for custom menus. These are manually configured using the useCustomMenuIsOpen() hook
    const [customMenus, dispatchCustomMenus] = useReducer((s: number, isOpen: boolean) => s + (isOpen ? 1 : -1), 0);
    useEffect(() => {
        customMenuListeners.add(dispatchCustomMenus);
        return () => {
            customMenuListeners.delete(dispatchCustomMenus);
        };
    });

    return selects > 0 || dropdowns > 0 || customMenus > 0;
}

/**
 * Notify users of the useHasOpenMenu() hook about a custom menu. When this hook is called with
 * isOpen set to true, useHasOpenMenu() will return true for all consumers.
 */
export function useCustomMenuIsOpen(isOpen: boolean): void {
    useEffect(() => {
        for (const listener of customMenuListeners) {
            listener(isOpen);
        }
    }, [isOpen]);
}

The open state can then be consumed by the main component of the React app like so:

import React, { FunctionComponent } from "react";
import styled from 'styled-components';

const MainContainer = styled.div<{ hasOpenMenu: boolean }>`
    padding-bottom: ${(props) => props.hasOpenMenu ? '350px' : '0'};
`;

const Main: FunctionComponent = () => {
    const hasOpenMenu = useHasOpenMenu();
    return <MainContainer hasOpenMenu={hasOpenMenu}>(content)</MainContainer>;
};

Select

In order for open select boxes to be properly detected by the open menu hook, the prop menuPortalTarget={menuPortalTarget} (defined in the code above) has to be passed to all select boxes.

For date/time pickers, the prop selectProps={{ menuPortalTarget }} has to be passed.

Dropdown menu

Dropdown menus are detected automatically by the open menu hook, no particular code is needed.

Inline dialog and others

Inline dialogs and other components can use the useCustomMenuIsOpen hook specified above to pass their open state to the open menu hook. The stateless variant of these components needs to be used, so that their open state can be access and passed to the hook. Here is an example:

import React, { FunctionComponent, useCallback, useState } from 'react';
import InlineDialog from '@atlaskit/inline-dialog';
import Button from '@atlaskit/button';
import ChevronDownIcon from '@atlaskit/icon/glyph/chevron-down';

const MyInlineDialog: FunctionComponent = () => {
    const [isOpen, setIsOpen] = useState(false);

    const handleToggle = useCallback(() => {
        setIsOpen(!isOpen);
    }, [isOpen]);

    const handleClose = useCallback(() => {
        setIsOpen(false);
    }, []);

    useCustomMenuIsOpen(isOpen);

    return (
        <InlineDialog
            onClose={handleClose}
            isOpen={isOpen}
            content={(
                <>(content)</>
            )}
        >
            <Button
                isSelected={isOpen}
                onClick={handleToggle}
                iconAfter={<ChevronDownIcon label=""/>}
            >Toggle</Button>
        </InlineDialog>
    );
};
8 Likes

Thanks for sharing Candid! That totally worked for me! :muscle: