<!--
    #VUE3 Most done (need to finish testing all scenarios)
-->

<template>
    <teleport
        to="body"
        :disabled="!portalEnabled"
        v-if="keepInDom || visibleValue || transitionVisible"
    >
        <transition
            :name="calcTransitionName"
            @before-enter="transitionBeforeEnter"
            @after-leave="transitionAfterLeave"
        >
            <div
                :class="[rootClass, bem({ ...variantsMap, absolute: strategy == 'absolute' })]"
                :style="{ ...(rootStyle || {}), zIndex: zStart + currentLayerIndex }"
                v-show="visibleValueNext"
            >
                <!-- overlay element -->
                <slot name="overlay" v-if="overlayEnabled">
                    <div
                        :class="[bem('overlay'), overlayClass]"
                        @click="overlayClick"
                        v-bind="overlayAttributes"
                        ref="overlay"
                        :style="{
                            pointerEvents: visibleValue ? null : 'none',
                            userSelect: visibleValue ? null : 'none',
                        }"
                    >
                        <slot name="overlay-content"></slot>
                    </div>
                </slot>

                <!-- popup element -->
                <div
                    ref="popup"
                    :class="[bem('popup'), popupClass]"
                    :style="{
                        opacity: !popupPositioned ? 0 : null,
                        pointerEvents: visibleValue ? null : 'none',
                        userSelect: visibleValue ? null : 'none',
                        ...(positionCss || {}),
                        ...popupStyle,
                    }"
                    v-bind="popupAttributes"
                >
                    <slot />
                </div>
            </div>
        </transition>
    </teleport>
</template>

<script>
import PxBaseVariants from '../bases/PxBaseVariants.vue';
import { computePosition, autoUpdate, flip, shift, offset, size } from '@floating-ui/dom';
import { disableBodyScroll, enableBodyScroll } from 'body-scroll-lock';

const FOCUS_ELEMENTS = [
    'button',
    '[href]:not(.disabled)',
    'input',
    'select',
    'textarea',
    '[tabindex]',
    '[contenteditable]',
];

let globalLayerIndex = 0;
let globalClearLayerIndex = 0;

export default {
    name: 'px-float',
    components: {},
    mixins: [PxBaseVariants],
    emits: [
        'show',
        'hide',
        'beforeshow',
        'beforehide',
        'afterhide',
        'update:visible',
        'update:reference',
        'cancel',
    ],
    props: {
        /**
         * Can be combined to trigger one or more of following render variants:
         *
         * - default    - equivalent to 'dialog centered gray'
         * - none       - removes all styling and layout
         * - dialog     - places the content inside of a dialog container
         * - message    - same as dialog, but padding is adjusted to be more like a message box
         * - menu       - same as dialog, but padding is reduced to be menu-like
         * - centered   - places the content in the center of the screen (left:50%, top: 40%)
         * - gray       - gives the overlay a gray shade
         * - blur       - gives the overlay a blurred appearance
         * - sm|md|lg   - provides min/max sizes (expected to be used with dialog)
         *
         * @values default, none, dialog, message, menu, centered, gray, blur, sm, md, lg
         */
        variant: { type: String, default: 'default' },

        /**
         * Set to true when the popup should be showing / visible.
         */
        visible: { type: Boolean, default: false },

        /**
         * Whether to keep the popup element in the DOM even if not visible
         */
        keepInDom: { type: Boolean, default: false },

        /**
         * Whether to use a portal (often required for correct layout, esp. if using an overlay)
         */
        portalEnabled: { type: Boolean, default: true },

        /**
         * Automatically move focus to the contents of the modal when opened
         */
        autoFocusEnabled: { type: Boolean, default: true },

        /**
         * Automatically restore focus with then modal closes
         */
        restoreFocusEnabled: { type: Boolean, default: true },

        /**
         * Selector to find the first element that gets focus when focus is set on the dialog
         */
        autoFocusSelector: { type: String, default: '[autofocus]' },

        /**
         * Whether to disable scrolling when visible
         */
        disablesScroll: { type: Boolean, default: true },

        /**
         * When true, esc key will close the popup
         */
        escCloses: { type: Boolean, default: true },

        /**
         * The starting point for the z-index (note: incremented as more layers of popups are opened
         * to ensure that the last opened from the same starting z will be on top of each other)
         */
        zStart: { type: Number, default: 999999 },

        /**
         * Class set on the root element (equivalent of setting class if not using a portal)
         */
        rootClass: { type: String, default: null },

        /**
         * Style set on the root element (equivalent of setting style if not using a portal)
         */
        rootStyle: { default: null },

        /**
         * An additional class set on the popup element
         */
        popupClass: { type: String, default: '' },

        /**
         * Style object that gets merged onto the popup
         */
        popupStyle: { default: null },

        /**
         * Additional attributes that get spread onto the popup element. Useful
         * for setting ARIA attributes (role, aria-modal, aria-labelledby, etc.)
         * when this float is used as a dialog.
         */
        popupAttributes: { type: Object, default: null },

        /**
         * Whether to render an overlay element
         */
        overlayEnabled: { type: Boolean, default: true },

        /**
         * An additional class set on the overlay element
         */
        overlayClass: { type: [String, Array], default: '' },

        /**
         * Additional attributes that get set on the overlay (can be used for adding analytics attributes, etc.)
         */
        overlayAttributes: { type: Object, default: null },

        /**
         * Whether clicking the overlay element will close the popup
         */
        overlayClose: { type: Boolean, default: true },

        /**
         * The name of the transition (null will defaults to a basic fade but variants can override)
         */
        transitionName: { type: String, default: null },

        // floating ui options

        /**
         * The reference element when positioning. Can be an element, a virtual element (see docs) or a string.
         *
         * - previous - previous sibling element
         * - next - next sibling element
         * - cursor - show at cursor location
         * - css selector - will use first found element
         * @values previous, next, cursor
         */
        reference: { default: 'previous' },

        /**
         * The placement of the popup relative to the reference element (default is null which means
         * no placement or placement is handled manually or via css, etc.).
         *
         * @values top, top-start, top-end, right, right-start, right-end, bottom, bottom-start,
         * bottom-end, left, left-start, left-end
         */
        placement: { type: String, default: null },

        /**
         * Whether the position updates automatically when placement has been set
         */
        autoUpdate: { type: Boolean, default: true },

        /**
         * Whether to use fixed or absolute positioning
         * @values fixed, absolute
         */
        strategy: { type: String, default: 'fixed' },

        /**
         * Add spacing along the core axis, can be a number or an options object
         * (see https://floating-ui.com/docs/offset)
         */
        offset: { type: [Number, Object], default: 0 },

        /**
         * Flip to the other side of the reference element when space is limited
         */
        flip: { default: true },

        /**
         * Like flip but for the start/end direction
         */
        shift: { default: true },

        /**
         * When true, the popup will be sized to the reference elemement, allowed values are:
         *  - "width" - size the width to the width of the reference element
         *  - "minWidth" - size the minWidth to the width of the reference element
         *  - "height" - (not implemented) size the height to the height of the reference element
         *  - "minHeight" - (not implemented) size the height to the height of the reference element
         */
        sizeToReference: { type: String, default: null },

        // uses the size middleware to keep the float on screen, resizing when necessary
        sizeToView: { default: false },
        sizeToViewPadding: { type: Number, default: 10 },

        // NOT IMPLEMENTED
        // hiddenClass: { type: String, default: null },

        // NOT IMPLEMENTED
        // inline: { type: Boolean, default: false },

        /**
         * Use translate instead of left/top css properties (may increase performance in some cases)
         */
        useTranslate: { type: Boolean, default: false },
    },
    data() {
        return {
            // mirrors visible
            visibleValue: false,

            // mirrors visibleValue but one tick behind (so that we can trigger actions that require the popup to be in the DOM)
            visibleValueNext: false,

            // whether the popup has been finally positioned (used to trigger opacity)
            popupPositioned: false,

            // mirrors reference
            referenceValue: null,

            // css for positioning the element with floating-ui
            positionCss: null,

            // true while the element remains visible during a transition (even if visible/visibleValue are false)
            transitionVisible: false,

            // z offset of the current layer
            currentLayerIndex: 0,
        };
    },
    computed: {
        // the name of the transition to use ()
        calcTransitionName() {
            if (this.transitionName !== null) {
                return this.transitionName;
            }

            return this.isVariant('blur') ? 'px-float-fade-blur' : 'px-float-fade';
        },
    },

    watch: {
        reference(nv) {
            this.referenceValue = nv;
        },

        referenceValue(nv) {
            this.$emit('update:reference', nv);
        },

        visible(nv) {
            this.visibleValue = nv;
        },

        visibleValue(nv, ov) {
            // emit update when visibleValue changes (enables .sync on parent)
            this.$emit('update:visible', nv);

            if (nv) {
                this.$emit('beforeshow');
            }
            if (!nv) {
                this.$emit('beforehide');
            }

            if (this.isServer()) {
                return;
            }

            // layer management

            // show
            if (ov === false && nv === true) {
                globalLayerIndex += 1;
                this.currentLayerIndex = globalLayerIndex;
            }

            // hide
            if (ov === true && nv === false) {
                setTimeout(() => {
                    globalLayerIndex -= 1;
                }, 30);
            }

            this.$nextTick(() => {
                this.visibleValueNext = nv;
            });
        },

        visibleValueNext(nv) {
            // key handler

            if (nv) {
                document.addEventListener('keydown', this.onKey);
            } else {
                document.removeEventListener('keydown', this.onKey);
            }

            // delayed actions (settimeout seems to work but $nextTick doesn't)

            if (nv) {
                this.popupPositioned = false;
            }

            setTimeout(() => {
                // floating-ui

                this.popupPositioned = true;

                if (nv) {
                    let reference = this.getReferenceElement();
                    let popup = this.$refs.popup;

                    this.updatePosition(reference, popup);

                    if (this.autoUpdate && reference && popup) {
                        this.cleanup = autoUpdate(reference, popup, () =>
                            this.updatePosition(reference, popup),
                        );
                    }
                } else {
                    if (this.cleanup) {
                        this.cleanup();
                        this.cleanup = null;
                    }
                }

                // focus

                if (nv) {
                    // track the element that was focused on open
                    this.previousActiveElement = document.activeElement;

                    // focus the first focusable element (or autofocus element if set)
                    if (this.autoFocusEnabled) {
                        this.setAutoFocus();
                    }
                } else {
                    // restore focus
                    if (this.previousActiveElement && this.restoreFocusEnabled) {
                        this.previousActiveElement.focus({ preventScroll: true });
                        this.previousActiveElement = null;
                    }
                }

                // disable scrolling

                if (nv) {
                    if (this.disablesScroll) {
                        if (this.$refs.popup) {
                            disableBodyScroll(this.$refs.popup, { reserveScrollBarGap: true });
                        }

                        if (!globalClearLayerIndex) {
                            globalClearLayerIndex = globalLayerIndex;
                        }
                    }
                } else {
                    // see watcher for transitionVisible
                }

                // show and hide events (also see beforeshow/beforehide in visibleValue watcher)

                if (nv) {
                    this.$emit('show');
                }
                if (!nv) {
                    this.$emit('hide');
                }
            });
        },

        transitionVisible() {
            if (!this.transitionVisible) {
                this.$emit('afterhide');

                if (this.$refs.popup) {
                    enableBodyScroll(this.$refs.popup, { reserveScrollBarGap: true });
                }
            }
        },
    },
    mounted() {
        this.visibleValue = this.visible;
        this.transitionVisible = this.visible;
        this.referenceValue = this.reference;
    },
    methods: {
        show(reference) {
            if (reference) {
                this.referenceValue = reference;
            }
            this.hideSource = null;
            this.visibleValue = true;
        },

        showAtCursor(mouseEvent) {
            this.show({
                getBoundingClientRect() {
                    return {
                        width: 0,
                        height: 0,
                        x: mouseEvent.clientX,
                        y: mouseEvent.clientY,
                        top: mouseEvent.clientY,
                        left: mouseEvent.clientX,
                        right: mouseEvent.clientX,
                        bottom: mouseEvent.clientY,
                    };
                },
            });
        },

        hide(hideSource) {
            this.hideSource = hideSource;
            this.visibleValue = false;
        },

        overlayClick() {
            if (this.overlayClose) {
                this.visibleValue = false;
            }
        },

        onKey(e) {
            if (this.visibleValue) {
                if (e.keyCode === 27 && this.escCloses) {
                    this.onEsc(e);
                } else if (e.keyCode === 9) {
                    this.onTab(e);
                }
            }
        },

        onEsc() {
            if (this.currentLayerIndex == globalLayerIndex) {
                this.hide('esc');
                this.$emit('cancel');
            }
        },

        onTab(e) {
            // Refresh this on every tab press in case visibility of something has changed
            let focusables = this.getFocusableChildren();
            let focusedIndex = focusables.indexOf(document.activeElement);

            // Trap tab focus
            if (e.shiftKey && focusedIndex <= 0) {
                focusables[focusables.length - 1].focus();
                e.preventDefault();
            } else if (!e.shiftKey && focusedIndex === focusables.length - 1) {
                focusables[0].focus();
                event.preventDefault();
            }
        },

        setAutoFocus() {
            let focusables = this.getFocusableChildren();
            let dialogFocusable = this.$refs.popup?.querySelector(this.autoFocusSelector);
            let focused = dialogFocusable || focusables[0];
            if (focused) {
                focused.focus({ preventScroll: true });
            }
        },

        getFocusableChildren() {
            let f = [...(this.$refs.popup?.querySelectorAll(FOCUS_ELEMENTS.join(',')) || [])];

            // filter to visible only
            f = f.filter(child => {
                return (
                    child.tabIndex >= 0 &&
                    !!(child.offsetWidth || child.offsetHeight || child.getClientRects().length)
                );
            });

            return f.sort((a, b) => {
                if (a.tabIndex > b.tabIndex) {
                    return 1;
                }
                if (a.tabIndex < b.tabIndex) {
                    return -1;
                }
                return 0;
            });
        },

        updatePosition(reference) {
            if (this.isServer()) {
                return;
            }

            const middleware = [];

            if (this.offset) {
                middleware.push(offset(this.offset));
            }

            if (this.shift) {
                middleware.push(shift(this.shift));
            }

            if (this.flip) {
                middleware.push(flip(this.flip));
            }

            let maxHeight = null;
            if (this.sizeToView) {
                let padding = this.sizeToViewPadding || 0;
                middleware.push(
                    size({
                        apply({ availableHeight }) {
                            maxHeight = availableHeight - padding;
                        },
                    }),
                );
            }

            if (this.sizeToReference) {
                const elementProp = this.sizeToReference;
                const referenceProp = 'width';

                middleware.push(
                    size({
                        apply({ rects, elements }) {
                            const assignValue = {};
                            assignValue[elementProp] = `${rects.reference[referenceProp]}px`;
                            Object.assign(elements.floating.style, assignValue);
                        },
                    }),
                );
            }

            if (this.placement && this.$refs.popup && reference) {
                computePosition(reference, this.$refs.popup, {
                    placement: this.placement,
                    strategy: this.strategy,
                    middleware: middleware,
                }).then(({ x, y }) => {
                    if (this.useTranslate) {
                        this.positionCss = {
                            transform: `translate(${this.cssPx(x)},${this.cssPx(y)})`,
                            maxHeight: this.cssPx(maxHeight),
                        };
                    } else {
                        this.positionCss = {
                            left: this.cssPx(x),
                            top: this.cssPx(y),
                            maxHeight: this.cssPx(maxHeight),
                        };
                    }
                });
            } else {
                this.positionCss = null;
            }
        },

        getReferenceElement() {
            let reference = this.referenceValue;

            if (typeof reference == 'string') {
                // previous sibling
                if (reference == 'previous') {
                    reference = this.$el.previousElementSibling;
                }

                // next sibling
                else if (reference == 'next') {
                    reference = this.$el.nextElementSibling;
                }

                // selector
                else {
                    reference = document.querySelector(reference);
                }
            }

            return reference;
        },

        // returns the default overlay element (will be null if the overlay slot has been overriden)
        getOverlayElement() {
            return this.$refs.overlay;
        },

        getPopupElement() {
            return this.$refs.popup;
        },

        transitionBeforeEnter() {
            this.transitionVisible = true;
        },

        transitionAfterLeave() {
            this.transitionVisible = false;
        },
    },
};
</script>

<style lang="scss">
@use '../styles/px.scss' as *;
@use '@thinkpixellab-public/px-styles/src/modules/controls' as *;

$d: 0.4s;

.px-float {
    position: relative;
    top: 0;
    left: 0;

    &__overlay {
        position: fixed;
        top: 0;
        left: 0;
        width: 100vw;
        height: 100vh;
    }

    &__popup {
        position: fixed;
        top: 0;
        left: 0;
    }

    &--absolute {
        position: absolute;
    }

    // variants

    // styles

    &--none {
        // reset (do nothing)
    }

    &--default &,
    &--centered & {
        &__popup {
            top: 40%;
            left: 50%;
            transform: translate(-50%, -40%);
        }
    }

    &--default &,
    &--dialog & {
        &__popup {
            @include popup(
                (
                    padding: 1.5em,
                )
            );
        }
    }

    &--message & {
        // dialog adds basic popup styling
        &__popup {
            @include popup(
                (
                    padding: 1.5em 2em,
                )
            );
        }
    }

    &--menu & {
        &__popup {
            @include popup(
                (
                    padding: 0,
                    display: flex,
                    flex-direction: column,
                )
            );
        }
    }

    // sizes

    &--sm & {
        &__popup {
            max-width: min(94vw, 320px);
        }
    }

    &--md & {
        &__popup {
            max-width: min(94vw, 600px);
        }
    }

    &--lg & {
        &__popup {
            max-width: min(94vw, 800px);
        }
    }

    &--lx & {
        &__popup {
            max-width: min(94vw, 1280px);
        }
    }

    &--container {
        $container-width: get('container.width');
        max-width: 'min(94vw, #{$container-width})';
    }

    // overlays

    &--default &,
    &--gray & {
        &__overlay {
            background-color: rgba(gray(-4), 0.33);
        }
    }

    &--blur & {
        &__overlay {
            backdrop-filter: blur(4px);
        }
    }
}

// ----------------------------------------------------------------------------
// default 'fade' transition
// ----------------------------------------------------------------------------

@include vue-transition-fade(px-float-fade, $dur: $d, $leave-pointer-events: none);

// ----------------------------------------------------------------------------
// blur variant transition (uses filter for opacity and animates backdrop-filter)
// ----------------------------------------------------------------------------

@include vue-transition(px-float-fade-blur) {
    @include vt-transition {
        transition-delay: $d;

        .px-float__overlay {
            transition:
                filter $d,
                backdrop-filter $d;
        }
        .px-float__popup {
            transition: filter $d;
        }
    }

    // define the hidden state (the non-hidden state is implicty -- the normal state)
    @include vt-hidden {
        .px-float__overlay {
            filter: opacity(0);
            backdrop-filter: blur(0px);
        }
        .px-float__popup {
            filter: opacity(0);
        }
    }

    @include vt-hidden-leave {
        .px-float__overlay {
            pointer-events: none;
        }
    }
}
</style>
