skeletonlabs / skeleton

A complete design system and component solution, built on Tailwind.
https://skeleton.dev
MIT License
4.9k stars 307 forks source link

Investigate programatic popups #1484

Closed endigo9740 closed 1 year ago

endigo9740 commented 1 year ago

Describe what feature you'd like. Pseudo-code, mockups, or screenshots of similar solutions are encouraged!

We've heard a lot of requests for this and I believe there is a good use case for something like this. I'm not convinced that extending our current popup feature would be possible in this regard given the nature of Svelte Actions - they are typically tied to Dom manipulations. Rather this could be a brand new or sibling feature.

Idea use cases:

This will likely continue to use Floating UI as as a dependency. Long term our goal is to split features like Popups to their own dedicated library and NPM package. When this occurs we'll be able to provide tighter integration for Floating UI, it's features, and it's types.

What type of pull request would this be?

New Feature

Any links to similar examples or other references we should review?

No response

saturnonearth commented 1 year ago

Did someone say popups?

image

A few ideas...

1.) I think a top-level singleton component (that has the ability to pass settings) that catches all elements with the "title" (native tooltip) and will automatically display a simple tooltip would really fit in-line with Skeleton's overall no-nonsense, easy to start, approach.

It also aligns with Svelte's idea of "progressive enhancement" as it only applies to elements with the default title attribute which would be a native fallback for no JS users.

2.) I think that extending the existing popup feature for actions would not be a bad idea.

Something as simple as

<button
    class="btn"
    use:tooltip={{
    content: "Hello I am a tooltip!",
}}
>
    Hover me for 1 second
</button>

(with some pre-defined settings, or defaults)

Seems like a relatively simple way to allow people to provide a one-off tooltip for something they need, if they don't want to opt-in to all tooltips.

3.) Like the second option + the first,

<button
    class="btn"
    use:tooltip
    title="Hello I am a tooltip!"
>
    Hover me for 1 second
</button>

An action that uses the title as a reference for the content.

endigo9740 commented 1 year ago

Heck yeah, you know I love singletons :D Not a bad idea at face value. I'm not sure my ETA for being able to revisit this, but I'll noodle on this a bit.

jwatts777 commented 1 year ago

+1 Ex popup.close(), popup.open() Willing to do the legwork if you have a specific implementation that you want to follow.

endigo9740 commented 1 year ago

Let's include open/close features as send in this draft PR when we revisit this: https://github.com/skeletonlabs/skeleton/pull/1616

cycle4passion commented 1 year ago

I thought I would take a stab at it. I submit it for proof of concept/discussion. The general idea is tooltip action acts as HOF for standard Skeleton Popup.

Open in StackBlitz

// Svelte Action: Tooltip.svelte

/* eslint-disable prefer-const */
import { get } from 'svelte/store';
import type { Action } from 'svelte/action';
import { popup, storePopup, type PopupSettings } from '@skeletonlabs/skeleton';
// import { popup } from './popup';

// Progrmamtic Tooltip Discussion: https://github.com/skeletonlabs/skeleton/issues/1484
// Stackblitz: https://stackblitz.com/edit/skeletonlabs-repl-piksa3?file=src%2Froutes%2F%2Bpage.svelte
/* Can be called in 3 ways:

Cleanest:    <button use:tooltip title='This is tooltip text'>OK</button>
Alternative: <button use:tooltip aria-label="This is tooltip text">OK</button>
Dynamic:     <button use:tooltip={{content: 'This is tooltip text', placement:'bottom', event:'click' }}>OK</button>

Note: although the first 2 options are the cleanest,
they both define tooltip onMount and do not respond to subsequent changes.
The third one can be updated dynamically, and will respond to changes in the params object
The third one also allow passing Svelte components as content and setting component state with componentState
Alternatively, you can pass the unique identifer from explicity created element with data-popup attribute (see example)

use:tooltip={ {
  content?: string | HTML | data-popup identifier | Svelte Component,  // tooltip content
  includeArrow?: boolean, // include arrow, default is true
  tooltipClass?: string,  // default is 'variant-filled-primary rounded-xl px-2 py-1 text-sm shadow-xl'
  arrowClass?: string, // custom class for arrow. Note background auto-inherited from tooltip
    componentState?: object, // state to pass to Svelte component
  // Below are standard PopupSettings, see https://github.com/skeletonlabs/skeleton/tree/dev/packages/skeleton/src/lib/utilities/Popup for details
  event?:   'click' | 'hover' | 'focus-blur' | 'focus-click',  // default is 'hover'
  placement?: string, // default is 'top' 
  closeQuery?: string, 
  state?: ()=> {},
  middleware?: Middleware, 
} }
*/

// Alter PopupSettings Type by removing target (will come from node in svelte action), and make event optional
type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
type NewPopupSettings = Optional<Omit<PopupSettings, 'target'>, 'event'>;
// TODO: remove state

// add custom params from action to PopUpSettings
interface ParamsSettings extends NewPopupSettings {
    content?: any; // TODO: would like string | SvelteCompoent but his does not work
    includeArrow?: boolean;
    tooltipClass?: string;
    arrowClass?: string;
    componentState?: object;
    open?: boolean;
}

export const tooltip: Action<HTMLElement, ParamsSettings | undefined> = (node, params) => {
    const uuid = `tt${crypto.randomUUID().slice(0, 5)}`;
    let localOpen: boolean;

    const tooltipSettings: PopupSettings = {
        event: params?.event || 'hover',
        target: uuid,
        placement: params?.placement || 'top',
        closeQuery: params?.closeQuery,
        state: (e) => updateLocalState(e.state) /* status from Popup triggers in here */,
        middleware: params?.middleware
    };

    function updateLocalState(state: boolean) {
        localOpen = state;
        /* Toggling tooltips from outside. Requires a dissociation between intent and result.
                The intent is to show (or hide tooltip), but the result should not include using the trigger
                (ie. not button click to accomplish showing tooltip).
                I decided on triggering a native JS customEvent (required addition to Popup action)
                to handle changs from the outside. 
        */

        // inside out data flow:
        //   popup.ts => params.state (tooltip.ts) to customEvent => outside
        // outside in data flow:
        //   params.open from outside => customEvent (tooltip.ts) => popup.ts (modified)

        // update the inside (Popup)
        fireCustomEvent(node, 'changeState', { state: localOpen }); // infinite loop
        // update the outside (Svelte component)
        fireCustomEvent(node, 'toggle', { state: localOpen });
        fireCustomEvent(node, localOpen ? 'open' : 'close', { state: localOpen });
    }

    function createUpdateTooltip(params: ParamsSettings = {}) {
        let {
            content = '',
            includeArrow = true,
            tooltipClass = 'card z-99 !ring-1 !ring-primary-500 rounded-xl px-2 py-1 text-sm shadow-xl',
            arrowClass = '',
            componentState = {},
            // state: state
            open = false
        } = params || {};

        // params changes from outside, update into Popup
        if (open !== localOpen) updateLocalState(open); // updates but prevent infintie loop

        // content priority: params.content > title > aria-label
        content = params.content || node.title || node.getAttribute('aria-label') || '';
        if (!content) return;

        // Turn off pointer node events of children when event hover (anti-flicker)
        // see https://www.skeleton.dev/utilities/popups#hover
        if (tooltipSettings.event === 'hover') node.classList.add('[&>*]:pointer-events-none');

        // test for existing explicitly created tooltip in HTML template
        let explicitTT: HTMLElement | null = null;
        if (typeof content === 'string') {
            // remove any quotes from content to avoid selector error, not valid in CSS selector anyways
            explicitTT = document.querySelector(`[data-popup='${content.replace(/[`"']/g, '')}']`);
            if (explicitTT) tooltipSettings.target = content;
        }

        // Add aria-label for A11y
        if (typeof content === 'string' && !explicitTT && !node.getAttribute('aria-label'))
            node.setAttribute('aria-label', content);

        // Remove title, so no interference of native title w/ our tooltip
        node.removeAttribute('title');

        // Update or Create tooltip element
        let previousTT: HTMLElement | null = document.querySelector(`#tooltip-${uuid}`);

        let tt: HTMLElement = explicitTT ?? previousTT ?? document.createElement('div');
        tt.setAttribute('id', `tooltip-${uuid}`);
        if (!explicitTT) tt.setAttribute('data-popup', uuid);

        // Apply tooltip classes
        tt.className = 'tooltip';
        if (tooltipClass) tooltipClass.split(' ').forEach((c) => tt.classList.add(c));

        // Add tooltip to DOM
        if (!explicitTT && !previousTT) node.appendChild(tt);

        // Add content to tooltip
        if (typeof content === 'string' && !explicitTT) {
            tt.innerHTML = content;
        } else if (isSvelteComponent(content)) {
            // error comes from poor typing (function) of SvelteComponent on export let
            new content({
                target: document.querySelector(`#tooltip-${uuid}`) as HTMLElement,
                props: componentState
            });
        }

        if (!includeArrow) {
            // remove prior arrow
            document.querySelector(`#arrow-${uuid}`)?.remove();
        } else {
            const previousArrow: HTMLElement | null = document.querySelector(`#arrow-${uuid}`);
            const arrowSpan: HTMLElement = previousArrow ?? document.createElement('span');
            if (!previousArrow) arrowSpan.setAttribute('id', `arrow-${uuid}`);
            arrowSpan.style.setProperty('background-color', 'inherit');
            arrowSpan.style.setProperty('z-index', 'inherit');
            // box-shadow and border need modified, so can't simply be inherited
            let border = window.getComputedStyle(tt as HTMLElement, null).getPropertyValue('border');
            let ring = window.getComputedStyle(tt as HTMLElement, null).getPropertyValue('box-shadow');
            let { computePosition, flip } = get(storePopup);

            // tooltip placement is smart. If there is not room for arrow placement on side defined in tooltip settings,
            // floating-ui adjusts it to another side to fit. We need this info, so pre-calculate it.
            // All of this is for copying the ring and border from the popup to the arrow, and hiding the enclosed sides within the popup
            // see https://floating-ui.com/docs/flip#final-placement
            // 🐛 BUG: static side not working reliably
            computePosition(node, tt, {
                placement: params.placement ?? 'top',
                middleware: [flip()]
            }).then(({ placement: finalPlacement }: { placement: string }) => {

                if (ring) {
                    let rings = ring.split(/,\s(?![\d])/);
                    let parts = rings[1].trim().split(' ');
                    let rgb = parts.slice(0, 3).join(' ');
                    parts = parts.slice(3);
                    if (finalPlacement?.includes('top')) {
                        parts[0] = parts[1] = '-' + parts[3];
                    } else if (finalPlacement?.includes('right')) {
                        parts[0] = parts[3];
                        parts[1] = '-' + parts[3];
                    } else if (finalPlacement?.includes('bottom')) {
                        parts[0] = parts[1] = parts[3];
                    } else if (finalPlacement?.includes('left')) {
                        parts[0] = '-' + parts[3];
                        parts[1] = parts[3];
                    }
                    rings[1] = rgb + ' ' + parts.join(' ');
                    ring = rings.join(', ');
                    arrowSpan.style.setProperty('box-shadow', ring);
                }
                if (border) {
                    // apply borders only to arrow box outside popup
                    if (finalPlacement?.includes('top')) {
                        arrowSpan.style.setProperty('border-bottom', border);
                        arrowSpan.style.setProperty('border-right', border);
                    } else if (finalPlacement?.includes('right')) {
                        arrowSpan.style.setProperty('border-bottom', border);
                        arrowSpan.style.setProperty('border-left', border);
                    } else if (finalPlacement?.includes('bottom')) {
                        arrowSpan.style.setProperty('border-top', border);
                        arrowSpan.style.setProperty('border-left', border);
                    } else if (finalPlacement?.includes('left')) {
                        arrowSpan.style.setProperty('border-top', border);
                        arrowSpan.style.setProperty('border-right', border);
                    }
                }
            });
            // Apply arrow classes
            arrowSpan.className = 'arrow';
            if (arrowClass) arrowClass.split(' ').forEach((c) => arrowSpan.classList.add(c));
            // Add arrow to DOM
            if (!previousArrow) tt.appendChild(arrowSpan);
        }

        popup(node, tooltipSettings);
    }

    createUpdateTooltip(params);

    return {
        // If the svelte action params change, let's update the tooltip
        // this handles most common scenario of content changes, but also work for changes to styling, events, etc.
        update: (updatedParams: any) => createUpdateTooltip(updatedParams)
        //destroy: () => void /* nothing to cleanup on unmount */
    };
};

function isSvelteComponent(content: string | (() => void)): boolean {
    return typeof content === 'function';
    // failed with content instanceof SvelteComponent
    // Could not find export for ProxyComponent to test for
    // TODO: looking or better way
}

function fireCustomEvent(element: HTMLElement, eventName: string, props: object = {}) {
    element.dispatchEvent(
        new CustomEvent(eventName, {
            detail: { ...props }
        })
    );
}
endigo9740 commented 1 year ago

FYI, as we begin prepping for the new standalone popup package, we're consolidating all known issues for popups into this new thread:

Your post will now be closed, but has been referenced in the post linked above. Please note that by doing this, your request is being folded into this larger effort. Please feel free to monitor the linked issue if you wish to track progress on this going forward.