Modal Dialog

The complete modal pattern: backdrop click-to-close, Esc-to-close, focus on open, and copy-to-clipboard.

Live Demo

Share link

Anyone with this URL can view the document.

Source

HTML + JavaScript
<div data-component="share-dialog">
    <button data-action="openDialog">Share this document</button>
    <span data-show="lastAction" data-bind="lastAction"></span>

    <!-- data-event-self: fires only when the click target IS the scrim,
         not a click that bubbled up from the dialog box, so no inner
         click-stop handler is needed.
         data-cloak hides the element until bindings are processed.
         If a parent ever clips the scrim, add data-portal="body". -->
    <div class="scrim" data-show="open"
         data-action="closeDialog" data-event-self data-cloak>
        <div class="dialog">
            <h3>Share link</h3>
            <p class="muted">Anyone with this URL can view the document.</p>

            <div class="urlbox">
                <!-- data-bind on an input writes the .value PROPERTY.
                     Do NOT use data-bind-attr="{ value: shareUrl }". -->
                <input type="text" data-bind="shareUrl" readonly>
                <button data-action="copyUrl"
                        data-bind-class="copied ? 'ok' : ''"
                        data-bind="copyLabel">Copy</button>
            </div>

            <footer>
                <button class="btn-ghost" data-action="closeDialog">Done</button>
            </footer>
        </div>
    </div>
</div>

<script>
wildflower.component('share-dialog', {
    state: { open: false, shareUrl: '', copied: false, lastAction: '' },

    computed: {
        copyLabel() { return this.copied ? 'Copied' : 'Copy'; }
    },

    openDialog() {
        this.shareUrl = 'https://example.com/s/' + Math.random().toString(36).slice(2, 10);
        this.open = true;
        this.lastAction = '';
        // Instance prop (not state): a one-shot flag, no extra reactive pass.
        this._focusOnNextUpdate = true;
    },

    closeDialog() {
        this.open = false;
        this.copied = false;
        this.lastAction = 'Dialog was closed';
    },

    copyUrl() {
        try { navigator.clipboard.writeText(this.shareUrl); } catch (e) {}
        this.copied = true;
        clearTimeout(this._copyTimer);
        this._copyTimer = setTimeout(() => { this.copied = false; }, 1500);
    },

    // Runs after the DOM is committed (the $nextTick equivalent): the place
    // for focus, .select(), scroll-into-view, third-party widget init.
    onUpdate() {
        if (this._focusOnNextUpdate) {
            this._focusOnNextUpdate = false;
            this.$el('input').el?.select();
        }
    },

    // Esc-to-close while open: a global shortcut, so a document listener.
    init() {
        this._onKey = (e) => {
            if (e.key === 'Escape' && this.open) this.closeDialog();
        };
        document.addEventListener('keydown', this._onKey);
    },

    destroy() {
        document.removeEventListener('keydown', this._onKey);
        clearTimeout(this._copyTimer);
    }
});
</script>

Key Points

  • data-event-self on the scrim closes only when the backdrop itself is clicked, not when a click bubbles up from inside the dialog, so no inner click-stop handler is needed
  • data-bind on the <input> writes the value property; never use data-bind-attr="{ value: ... }" for inputs
  • Esc-to-close is a global-while-open shortcut, so it uses a document keydown listener added in init() and removed in destroy()
  • Focus-on-open uses onUpdate() (the post-render hook) plus $el, gated by a one-shot instance flag (this._focusOnNextUpdate) rather than reactive state
  • data-cloak prevents a flash of the open dialog before bindings process; if a parent ever clips the scrim, adding data-portal="body" is a one-attribute fix