From f9f6b18df5fde9208bb4d4f0430ee9dac2ec5e94 Mon Sep 17 00:00:00 2001 From: rameel Date: Tue, 16 Dec 2025 04:09:49 +0500 Subject: [PATCH 1/4] Add headless dialog (modal) component --- src/plugins/dialog/README.md | 83 ++++++++++++++ src/plugins/dialog/index.js | 193 ++++++++++++++++++++++++++++++++ src/plugins/dialog/package.json | 25 +++++ src/utilities/utils.js | 1 + 4 files changed, 302 insertions(+) create mode 100644 src/plugins/dialog/README.md create mode 100644 src/plugins/dialog/index.js create mode 100644 src/plugins/dialog/package.json diff --git a/src/plugins/dialog/README.md b/src/plugins/dialog/README.md new file mode 100644 index 0000000..06d7b6c --- /dev/null +++ b/src/plugins/dialog/README.md @@ -0,0 +1,83 @@ +# @ramstack/alpinegear-dialog +[![NPM](https://img.shields.io/npm/v/@ramstack/alpinegear-dialog)](https://www.npmjs.com/package/@ramstack/alpinegear-dialog) +[![MIT](https://img.shields.io/github/license/rameel/ramstack.alpinegear.js)](https://github.com/rameel/ramstack.alpinegear.js/blob/main/LICENSE) + +## Installation + +### Using CDN +To include the CDN version of this plugin, add the following ` + + + +``` + +### Using NPM +Alternatively, you can install the plugin via `npm`: + +```bash +npm install --save @ramstack/alpinegear-dialog +``` + +Then initialize it in your bundle: + +```js +import Alpine from "alpinejs"; +import Dialog from "@ramstack/alpinegear-dialog"; + +Alpine.plugin(Dialog); +Alpine.start(); +``` + +## Source Code +You can find the source code for this plugin on GitHub: + +https://github.com/rameel/ramstack.alpinegear.js/tree/main/src/plugins/dialog + + +## Related projects + +**[@ramstack/alpinegear-main](https://www.npmjs.com/package/@ramstack/alpinegear-main)** ([README](https://github.com/rameel/ramstack.alpinegear.js/tree/main/src/plugins/main))
+Provides a combined plugin that includes several useful directives. +This package aggregates multiple individual plugins, offering a convenient all-in-one bundle. +Included directives: `x-bound`, `x-format`, `x-fragment`, `x-match`, `x-template`, and `x-when`. + +**[@ramstack/alpinegear-bound](https://www.npmjs.com/package/@ramstack/alpinegear-bound)** ([README](https://github.com/rameel/ramstack.alpinegear.js/tree/main/src/plugins/bound))
+Provides the `x-bound` directive, which allows for two-way binding of input elements and their associated data properties. +It works similarly to the binding provided by [Svelte](https://svelte.dev/docs/element-directives#bind-property) +and also supports synchronizing values between two `Alpine.js` data properties. + +**[@ramstack/alpinegear-template](https://www.npmjs.com/package/@ramstack/alpinegear-template)** ([README](https://github.com/rameel/ramstack.alpinegear.js/tree/main/src/plugins/template))
+Provides the `x-template` directive, which allows you to define a template once anywhere in the DOM and reference it by its ID. + +**[@ramstack/alpinegear-fragment](https://www.npmjs.com/package/@ramstack/alpinegear-fragment)** ([README](https://github.com/rameel/ramstack.alpinegear.js/tree/main/src/plugins/fragment))
+Provides the `x-fragment` directive, which allows for fragment-like behavior similar to what's available in frameworks +like `Vue.js` or `React`, where multiple root elements can be grouped together. + +**[@ramstack/alpinegear-match](https://www.npmjs.com/package/@ramstack/alpinegear-match)** ([README](https://github.com/rameel/ramstack.alpinegear.js/tree/main/src/plugins/match))
+Provides the `x-match` directive, which functions similarly to the `switch` statement in many programming languages, +allowing you to conditionally render elements based on matching cases. + +**[@ramstack/alpinegear-when](https://www.npmjs.com/package/@ramstack/alpinegear-when)** ([README](https://github.com/rameel/ramstack.alpinegear.js/tree/main/src/plugins/when))
+Provides the `x-when` directive, which allows for conditional rendering of elements similar to `x-if`, but supports multiple root elements. + +**[@ramstack/alpinegear-destroy](https://www.npmjs.com/package/@ramstack/alpinegear-destroy)** ([README](https://github.com/rameel/ramstack.alpinegear.js/tree/main/src/plugins/destroy))
+Provides the `x-destroy` directive, which is the opposite of `x-init` and allows you to hook into the cleanup phase +of any element, running a callback when the element is removed from the DOM. + +**[@ramstack/alpinegear-hotkey](https://www.npmjs.com/package/@ramstack/alpinegear-hotkey)** ([README](https://github.com/rameel/ramstack.alpinegear.js/tree/main/src/plugins/hotkey))
+Provides the `x-hotkey` directive, which allows you to easily handle keyboard shortcuts within your Alpine.js components or application. + +**[@ramstack/alpinegear-router](https://www.npmjs.com/package/@ramstack/alpinegear-router)** ([README](https://github.com/rameel/ramstack.alpinegear.js/tree/main/src/plugins/router))
+Provides the `x-router` and `x-route` directives, which enable client-side navigation and routing functionality within your Alpine.js application. + + +## Contributions +Bug reports and contributions are welcome. + +## License +This package is released as open source under the **MIT License**. +See the [LICENSE](https://github.com/rameel/ramstack.alpinegear.js/blob/main/LICENSE) file for more details. diff --git a/src/plugins/dialog/index.js b/src/plugins/dialog/index.js new file mode 100644 index 0000000..012b2fa --- /dev/null +++ b/src/plugins/dialog/index.js @@ -0,0 +1,193 @@ +import { + closest, + is_dialog, + is_nullish, + is_template, + listen, + warn +} from "@/utilities/utils"; + +function plugin({ $data, addScopeToNode, bind, directive }) { + directive("dialog", (el, { expression, value }, { cleanup }) => { + const get_dialog_info = () => closest(el, n => n._r_dialog)?._r_dialog; + + value ||= ""; + + if (!get_dialog_info() && value !== "modal" && value !== "") { + warn("no x-dialog found"); + return; + } + + if (value === "panel") { + process_panel(); + } + else if (value === "trigger") { + process_trigger(); + } + else if (value === "accept") { + process_accept(); + } + else if (value === "cancel") { + process_cancel(); + } + else if (value === "" || value === "modal") { + process_dialog(); + } + else { + __DEV__ && warn(`Unknown x-dialog:${value} directive`); + } + + function process_dialog() { + if (is_template(el)) { + return warn("x-dialog cannot be used on a 'template' tag"); + } + + el._r_dialog = { + owner: el, + panel: null, + modal: value === "modal" + }; + + addScopeToNode(el, { + $dialog: { + show() { + const { panel, modal } = get_dialog_info(); + if (panel) { + return new Promise(resolve => { + listen(panel, "close", () => resolve(panel.returnValue), { once: true }); + panel[modal ? "showModal" : "show"](); + }); + } + return Promise.resolve(); + }, + close(value = null) { + is_nullish(value) ? dialog_cancel() : dialog_accept(value); + } + } + }); + + cleanup( + // + // Listening to the "submit" event on the document element, ensuring (to some extent) + // that our handler executes last among all handlers listening for this event. + // This allows us to determine whether the event was canceled by someone else. + // + listen(document, "submit", e => { + if (e.target.method === "dialog" && closest(e.target, n => n === el) && !e.defaultPrevented) { + // + // Prevent the dialog from closing immediately, + // as we need to trigger our own custom events first. + // + e.preventDefault(); + + dialog_accept(e.submitter?.value); + } + }) + ); + } + + function process_panel() { + if (!is_dialog(el)) { + return warn("x-dialog:panel can only be used on a 'dialog' element"); + } + + if (__DEV__ && get_dialog_info().panel) { + warn("x-dialog:panel is already present. Only the last one will be used."); + } + + const owner = get_dialog_info().owner; + get_dialog_info().panel = el; + + bind(el, { + "@toggle": e => { + dispatch(owner, el.open ? "open" : "close"); + dispatch(owner, "toggle", { oldState: e.oldState, newState: e.newState }); + }, + // + // https://issues.chromium.org/issues/346597066 + // HTMLDialogElement's "cancel" event is not cancelable when "ESC" key is pressed several times + // + "@keydown.escape.prevent.stop": e => { + // + // https://bugs.webkit.org/show_bug.cgi?id=284592 + // Safari still lacks native support for the "closedby" attribute on + // + if (["any", "closerequest"].includes(el.getAttribute("closedby"))) { + // + // "requestClose" fires a "cancel" event before firing the "close" event + // + el.requestClose(); + } + } + }); + + // + // Use setTimeout to defer binding, ensuring this listener executes last + // + setTimeout(() => + cleanup( + listen(el, "cancel", e => { + dispatch(owner, "requestcancel", {}, { cancelable: true }) || e.preventDefault(); + e.defaultPrevented || dispatch(owner, "cancel"); + }) + ) + ); + } + + function process_trigger() { + bind(el, { + "@click.prevent": "$dialog.show" + }); + } + + function process_accept() { + // + // The x-dialog:accept button must be placed inside a element. + // + if (ensure_dialog_panel("x-dialog:accept")) { + el.form || bind(el, { + "@click.prevent": e => dialog_accept(el.value) + }); + } + } + + function process_cancel() { + // + // The x-dialog:cancel button must be placed inside a element. + // + if (ensure_dialog_panel("x-dialog:cancel")) { + bind(el, { + "@click.prevent": dialog_cancel + }); + } + } + + function dialog_accept(value) { + value ??= "ok" + const { owner, panel } = get_dialog_info(); + const detail = { value }; + if (dispatch(owner, "requestaccept", detail, { cancelable: true })) { + value && dispatch(owner, "accept:" + value.toLowerCase(), detail); + dispatch(owner, "accept", detail); + panel.close(value); + } + } + + function dialog_cancel() { + get_dialog_info().panel?.requestClose(); + } + + function ensure_dialog_panel(name) { + return !!(closest(el, is_dialog) || warn(name + ": no x-dialog:panel found")); + } + + function dispatch(el, name, detail = {}, options = {}) { + return el.dispatchEvent(new CustomEvent(name, { detail, ...options })); + } + }); +} + +export default plugin; +export { + plugin as dialog +} diff --git a/src/plugins/dialog/package.json b/src/plugins/dialog/package.json new file mode 100644 index 0000000..f49ac1b --- /dev/null +++ b/src/plugins/dialog/package.json @@ -0,0 +1,25 @@ +{ + "name": "@ramstack/alpinegear-dialog", + "version": "0.0.0", + "description": "A headless, unstyled dialog (modal) component for Alpine.js", + "author": "Rameel Burhan", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/rameel/ramstack.alpinegear.js.git", + "directory": "src/plugins/dialog" + }, + "keywords": [ + "alpine.js", + "alpinejs", + "dialog", + "modal", + "headless-ui", + "component", + "alpinejs-directive", + "alpinejs-plugin", + "alpinejs-component" + ], + "main": "alpinegear-dialog.js", + "module": "alpinegear-dialog.esm.js" +} diff --git a/src/utilities/utils.js b/src/utilities/utils.js index 0afc582..793763e 100644 --- a/src/utilities/utils.js +++ b/src/utilities/utils.js @@ -4,6 +4,7 @@ export const is_nullish = value => value === null || value === undefined; export const is_checkable_input = el => el.type === "checkbox" || el.type === "radio"; export const is_numeric_input = el => el.type === "number" || el.type === "range"; export const is_template = el => el instanceof HTMLTemplateElement; +export const is_dialog = el => el instanceof HTMLDialogElement; export const is_element = el => el.nodeType === Node.ELEMENT_NODE; export const is_function = value => typeof value === "function"; export const as_array = value => is_array(value) ? value : [value]; From 43876bcaf3cfb72cca11e83454003fe213d6fe77 Mon Sep 17 00:00:00 2001 From: rameel Date: Wed, 17 Dec 2025 00:33:49 +0500 Subject: [PATCH 2/4] Simplify dialog actions by unifying accept/cancel into x-dialog:action --- src/plugins/dialog/index.js | 74 +++++++++++-------------------------- 1 file changed, 22 insertions(+), 52 deletions(-) diff --git a/src/plugins/dialog/index.js b/src/plugins/dialog/index.js index 012b2fa..bf7bb0e 100644 --- a/src/plugins/dialog/index.js +++ b/src/plugins/dialog/index.js @@ -1,7 +1,6 @@ import { closest, is_dialog, - is_nullish, is_template, listen, warn @@ -24,13 +23,10 @@ function plugin({ $data, addScopeToNode, bind, directive }) { else if (value === "trigger") { process_trigger(); } - else if (value === "accept") { - process_accept(); + else if (value === "action") { + process_action(); } - else if (value === "cancel") { - process_cancel(); - } - else if (value === "" || value === "modal") { + else if (value === "modal" || !value) { process_dialog(); } else { @@ -61,7 +57,7 @@ function plugin({ $data, addScopeToNode, bind, directive }) { return Promise.resolve(); }, close(value = null) { - is_nullish(value) ? dialog_cancel() : dialog_accept(value); + dialog_close(value); } } }); @@ -80,7 +76,7 @@ function plugin({ $data, addScopeToNode, bind, directive }) { // e.preventDefault(); - dialog_accept(e.submitter?.value); + dialog_close(e.submitter?.value); } }) ); @@ -100,9 +96,10 @@ function plugin({ $data, addScopeToNode, bind, directive }) { bind(el, { "@toggle": e => { - dispatch(owner, el.open ? "open" : "close"); - dispatch(owner, "toggle", { oldState: e.oldState, newState: e.newState }); + el.open && dispatch(owner, "open"); + dispatch(owner, "toggle", { state: e.newState }); }, + "@cancel.prevent": e => dialog_close(), // // https://issues.chromium.org/issues/346597066 // HTMLDialogElement's "cancel" event is not cancelable when "ESC" key is pressed several times @@ -120,18 +117,6 @@ function plugin({ $data, addScopeToNode, bind, directive }) { } } }); - - // - // Use setTimeout to defer binding, ensuring this listener executes last - // - setTimeout(() => - cleanup( - listen(el, "cancel", e => { - dispatch(owner, "requestcancel", {}, { cancelable: true }) || e.preventDefault(); - e.defaultPrevented || dispatch(owner, "cancel"); - }) - ) - ); } function process_trigger() { @@ -140,47 +125,32 @@ function plugin({ $data, addScopeToNode, bind, directive }) { }); } - function process_accept() { + function process_action() { // - // The x-dialog:accept button must be placed inside a element. + // The x-dialog:action button must be placed inside a element. // - if (ensure_dialog_panel("x-dialog:accept")) { - el.form || bind(el, { - "@click.prevent": e => dialog_accept(el.value) - }); + if (!closest(el, is_dialog)) { + return warn("x-dialog:action is missing a parent x-dialog:panel"); } - } - function process_cancel() { - // - // The x-dialog:cancel button must be placed inside a element. - // - if (ensure_dialog_panel("x-dialog:cancel")) { - bind(el, { - "@click.prevent": dialog_cancel - }); - } + el.form || bind(el, { + "@click.prevent": e => dialog_close(el.value) + }); } - function dialog_accept(value) { - value ??= "ok" + function dialog_close(value) { + value ??= ""; + const { owner, panel } = get_dialog_info(); const detail = { value }; - if (dispatch(owner, "requestaccept", detail, { cancelable: true })) { - value && dispatch(owner, "accept:" + value.toLowerCase(), detail); - dispatch(owner, "accept", detail); + + if (dispatch(owner, "requestclose", detail, { cancelable: true })) { + value && dispatch(owner, "close:" + value.toLowerCase(), detail); + dispatch(owner, "close", detail); panel.close(value); } } - function dialog_cancel() { - get_dialog_info().panel?.requestClose(); - } - - function ensure_dialog_panel(name) { - return !!(closest(el, is_dialog) || warn(name + ": no x-dialog:panel found")); - } - function dispatch(el, name, detail = {}, options = {}) { return el.dispatchEvent(new CustomEvent(name, { detail, ...options })); } From b6dec6830a8195f2d34afa0180092e14a286c91d Mon Sep 17 00:00:00 2001 From: rameel Date: Wed, 17 Dec 2025 18:49:22 +0500 Subject: [PATCH 3/4] Clean up and simplify internals --- src/plugins/dialog/index.js | 73 ++++++++++++++++++++----------------- src/utilities/utils.js | 4 +- 2 files changed, 41 insertions(+), 36 deletions(-) diff --git a/src/plugins/dialog/index.js b/src/plugins/dialog/index.js index bf7bb0e..44ccb73 100644 --- a/src/plugins/dialog/index.js +++ b/src/plugins/dialog/index.js @@ -1,19 +1,18 @@ import { closest, is_dialog, - is_template, listen, warn } from "@/utilities/utils"; -function plugin({ $data, addScopeToNode, bind, directive }) { - directive("dialog", (el, { expression, value }, { cleanup }) => { +function plugin({ bind, directive }) { + directive("dialog", (el, { value }, { cleanup }) => { const get_dialog_info = () => closest(el, n => n._r_dialog)?._r_dialog; value ||= ""; if (!get_dialog_info() && value !== "modal" && value !== "") { - warn("no x-dialog found"); + warn(`x-dialog:${value} is missing a parent x-dialog`); return; } @@ -34,30 +33,29 @@ function plugin({ $data, addScopeToNode, bind, directive }) { } function process_dialog() { - if (is_template(el)) { - return warn("x-dialog cannot be used on a 'template' tag"); - } - el._r_dialog = { owner: el, panel: null, modal: value === "modal" }; - addScopeToNode(el, { - $dialog: { - show() { - const { panel, modal } = get_dialog_info(); - if (panel) { - return new Promise(resolve => { - listen(panel, "close", () => resolve(panel.returnValue), { once: true }); - panel[modal ? "showModal" : "show"](); - }); + bind(el, { + "x-data"() { + return { + open: false, + show() { + const { panel, modal } = get_dialog_info(); + if (panel) { + return new Promise(resolve => { + listen(panel, "close", () => resolve(panel.returnValue), { once: true }); + panel[modal ? "showModal" : "show"](); + }); + } + return Promise.resolve(); + }, + close(value) { + dialog_close(value); } - return Promise.resolve(); - }, - close(value = null) { - dialog_close(value); } } }); @@ -83,28 +81,35 @@ function plugin({ $data, addScopeToNode, bind, directive }) { } function process_panel() { - if (!is_dialog(el)) { - return warn("x-dialog:panel can only be used on a 'dialog' element"); - } - if (__DEV__ && get_dialog_info().panel) { warn("x-dialog:panel is already present. Only the last one will be used."); } + if (!is_dialog(el)) { + warn("x-dialog:panel can only be used on a 'dialog' element"); + return; + } + const owner = get_dialog_info().owner; get_dialog_info().panel = el; bind(el, { - "@toggle": e => { + "x-init"() { + this.open = el.open; + }, + "@toggle"(e) { el.open && dispatch(owner, "open"); dispatch(owner, "toggle", { state: e.newState }); + this.open = el.open; + }, + "@cancel.prevent"() { + dialog_close(); }, - "@cancel.prevent": e => dialog_close(), // // https://issues.chromium.org/issues/346597066 // HTMLDialogElement's "cancel" event is not cancelable when "ESC" key is pressed several times // - "@keydown.escape.prevent.stop": e => { + "@keydown.escape.prevent.stop"() { // // https://bugs.webkit.org/show_bug.cgi?id=284592 // Safari still lacks native support for the "closedby" attribute on @@ -121,20 +126,20 @@ function plugin({ $data, addScopeToNode, bind, directive }) { function process_trigger() { bind(el, { - "@click.prevent": "$dialog.show" + "@click.prevent": "show" }); } function process_action() { - // - // The x-dialog:action button must be placed inside a element. - // if (!closest(el, is_dialog)) { - return warn("x-dialog:action is missing a parent x-dialog:panel"); + warn("x-dialog:action is missing a parent x-dialog:panel"); + return; } el.form || bind(el, { - "@click.prevent": e => dialog_close(el.value) + "@click.prevent"() { + dialog_close(el.value); + } }); } diff --git a/src/utilities/utils.js b/src/utilities/utils.js index 793763e..1349e29 100644 --- a/src/utilities/utils.js +++ b/src/utilities/utils.js @@ -3,8 +3,8 @@ export const is_array = Array.isArray; export const is_nullish = value => value === null || value === undefined; export const is_checkable_input = el => el.type === "checkbox" || el.type === "radio"; export const is_numeric_input = el => el.type === "number" || el.type === "range"; -export const is_template = el => el instanceof HTMLTemplateElement; -export const is_dialog = el => el instanceof HTMLDialogElement; +export const is_template = el => el.matches("template"); +export const is_dialog = el => el.matches("dialog"); export const is_element = el => el.nodeType === Node.ELEMENT_NODE; export const is_function = value => typeof value === "function"; export const as_array = value => is_array(value) ? value : [value]; From bcdc006a5c025cdf0e1331435774c47860784c2b Mon Sep 17 00:00:00 2001 From: rameel Date: Thu, 18 Dec 2025 02:38:35 +0500 Subject: [PATCH 4/4] Update README --- src/plugins/dialog/README.md | 213 +++++++++++++++++++++++++++++++- src/plugins/dialog/package.json | 5 +- 2 files changed, 213 insertions(+), 5 deletions(-) diff --git a/src/plugins/dialog/README.md b/src/plugins/dialog/README.md index 06d7b6c..8033fce 100644 --- a/src/plugins/dialog/README.md +++ b/src/plugins/dialog/README.md @@ -1,11 +1,32 @@ # @ramstack/alpinegear-dialog + [![NPM](https://img.shields.io/npm/v/@ramstack/alpinegear-dialog)](https://www.npmjs.com/package/@ramstack/alpinegear-dialog) [![MIT](https://img.shields.io/github/license/rameel/ramstack.alpinegear.js)](https://github.com/rameel/ramstack.alpinegear.js/blob/main/LICENSE) +`@ramstack/alpinegear-dialog` is a **headless dialog directive for Alpine.js** built on top of the native HTML `` element. + +It allows you to describe dialog behavior declaratively, without coupling logic to JavaScript code, +which makes it especially suitable for **progressive enhancement** and **seamless integration with htmx**. + +The plugin provides a small set of composable directives that together form a dialog "component", +while leaving markup, layout, and styling entirely up to you. + +## Features + +* Declarative dialog composition using Alpine directives +* Supports **modal** and **non-modal** dialogs +* Built on the native `` element +* Value-based close semantics +* Promise-based API for imperative usage +* Value-scoped events for htmx integration +* No styling or markup constraints (headless UI) + + ## Installation ### Using CDN -To include the CDN version of this plugin, add the following `