diff --git a/src/plugins/dialog/README.md b/src/plugins/dialog/README.md new file mode 100644 index 0000000..8033fce --- /dev/null +++ b/src/plugins/dialog/README.md @@ -0,0 +1,290 @@ +# @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 + +Include the plugin **before** Alpine.js: + +```html + + + + + +``` + +### Using NPM + +Install the package: + +```bash +npm install --save @ramstack/alpinegear-dialog +``` + +Initialize the plugin: + +```js +import Alpine from "alpinejs"; +import Dialog from "@ramstack/alpinegear-dialog"; + +Alpine.plugin(Dialog); +Alpine.start(); +``` + +## Usage + +### Basic Example + +```html +
+ + + + Are you sure you want to continue? + +
+ + + +
+
+
+``` + +Dialogs are composed using the following directives: + +* `x-dialog` – dialog root and scope provider (`x-dialog:modal` enables modal behavior) +* `x-dialog:trigger` – element that opens the dialog +* `x-dialog:panel` – the dialog panel (must be a `` element) +* `x-dialog:action` – closes the dialog and optionally provides a return value + +### Dialog Modes + +The root directive `x-dialog` supports two display modes: + +* **Non-modal dialog** (default) +* **Modal dialog**, enabled by using `x-dialog:modal` + +### Actions and return values + +The `x-dialog:action` directive closes the dialog when activated. + +* The `value` attribute defines the dialog's return value +* If `value` is omitted, an empty string (`""`) is used + +The return value is propagated through events and the Promise-based API. + +## Forms in dialogs + +Dialogs can contain forms and fully rely on the browser's native form handling. + +```html +
+ + + +
+ + +
+ + +
+
+
+
+``` + +### Notes + +* `x-dialog:action` is **optional** inside `
` +* Native form validation applies automatically +* The dialog closes only if validation succeeds +* `formnovalidate` allows closing the dialog without triggering validation + +In short, the dialog behaves exactly like a standard HTML dialog with a form. + +## Events + +All events are dispatched from the `x-dialog` root element. + +### `open` + +* Fired when the dialog is opened +* Non-cancelable, does not bubble + +### `toggle` + +* Fired whenever the dialog state changes +* `event.detail.state` contains the new state (`true` / `false`) +* Non-cancelable, does not bubble + +### `requestclose` + +* Fired **before** the dialog is closed +* Cancelable, does not bubble +* `event.detail.value` contains the proposed return value + +If this event is canceled, the dialog remains **open**. + +### `close:[value]` + +* Fired after the dialog is closed +* Value-scoped event +* Event name is normalized to lowercase +* `event.detail.value` contains the return value + +Example: +`value="Yes"` → `close:yes` + +### `close` + +* Fired after the dialog is fully closed +* `event.detail.value` contains the return value + +### Event Example + +```html +
+ + + + + Are you sure you want to continue? + +
+ + + +
+
+
+``` + +## HTXM Integration + +Value-scoped close events make integration with `htmx` straightforward and js-free. + +```html +
+ + + + + Are you sure you wish to deactivate your account? + +
+ + +
+
+
+``` + +## Properties and Methods + +All properties and methods are available within the `x-dialog` scope. + +### `open` (readonly) + +A boolean representing the dialog state: + +* `true` — dialog is open +* `false` — dialog is closed + +### `show(): Promise` + +Displays the dialog using the configured mode (modal or non-modal). + +Returns a `Promise` that resolves when the dialog is closed. +The resolved value is the dialog's return value. + +### `close(returnValue?: string): void` + +Closes the dialog programmatically. + +* `returnValue` — string returned by the dialog +* Closing can be prevented by canceling `requestclose` + + +## 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..44ccb73 --- /dev/null +++ b/src/plugins/dialog/index.js @@ -0,0 +1,168 @@ +import { + closest, + is_dialog, + listen, + warn +} from "@/utilities/utils"; + +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(`x-dialog:${value} is missing a parent x-dialog`); + return; + } + + if (value === "panel") { + process_panel(); + } + else if (value === "trigger") { + process_trigger(); + } + else if (value === "action") { + process_action(); + } + else if (value === "modal" || !value) { + process_dialog(); + } + else { + __DEV__ && warn(`Unknown x-dialog:${value} directive`); + } + + function process_dialog() { + el._r_dialog = { + owner: el, + panel: null, + modal: value === "modal" + }; + + 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); + } + } + } + }); + + 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_close(e.submitter?.value); + } + }) + ); + } + + function process_panel() { + 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, { + "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(); + }, + // + // https://issues.chromium.org/issues/346597066 + // HTMLDialogElement's "cancel" event is not cancelable when "ESC" key is pressed several times + // + "@keydown.escape.prevent.stop"() { + // + // 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(); + } + } + }); + } + + function process_trigger() { + bind(el, { + "@click.prevent": "show" + }); + } + + function process_action() { + if (!closest(el, is_dialog)) { + warn("x-dialog:action is missing a parent x-dialog:panel"); + return; + } + + el.form || bind(el, { + "@click.prevent"() { + dialog_close(el.value); + } + }); + } + + function dialog_close(value) { + value ??= ""; + + const { owner, panel } = get_dialog_info(); + const detail = { value }; + + if (dispatch(owner, "requestclose", detail, { cancelable: true })) { + value && dispatch(owner, "close:" + value.toLowerCase(), detail); + dispatch(owner, "close", detail); + panel.close(value); + } + } + + 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..7a1df90 --- /dev/null +++ b/src/plugins/dialog/package.json @@ -0,0 +1,26 @@ +{ + "name": "@ramstack/alpinegear-dialog", + "version": "0.0.0", + "description": "A headless, unstyled directive-based dialog (modal) component for Alpine.js, built on the native element", + "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", + "htmx" + ], + "main": "alpinegear-dialog.js", + "module": "alpinegear-dialog.esm.js" +} diff --git a/src/utilities/utils.js b/src/utilities/utils.js index 0afc582..1349e29 100644 --- a/src/utilities/utils.js +++ b/src/utilities/utils.js @@ -3,7 +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_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];