diff --git a/assets/package-lock.json b/assets/package-lock.json index 03fbf65..27ebcec 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -4,6 +4,9 @@ "requires": true, "packages": { "": { + "dependencies": { + "@zag-js/types": "^0.82.1" + }, "devDependencies": { "tailwindcss-animate": "^1.0.7" } @@ -141,6 +144,15 @@ "node": ">=14" } }, + "node_modules/@zag-js/types": { + "version": "0.82.1", + "resolved": "https://registry.npmjs.org/@zag-js/types/-/types-0.82.1.tgz", + "integrity": "sha512-Nr/CU/z/SZWDL92P2u9VDZL9JUxY8L1P7dGI0CmDKHlEHk1+vzqg3UnVkUKkZ5eVMNLtloHbrux5X9Gmkl39WQ==", + "license": "MIT", + "dependencies": { + "csstype": "3.1.3" + } + }, "node_modules/ansi-regex": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", @@ -344,6 +356,12 @@ "node": ">=4" } }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -1602,6 +1620,14 @@ "optional": true, "peer": true }, + "@zag-js/types": { + "version": "0.82.1", + "resolved": "https://registry.npmjs.org/@zag-js/types/-/types-0.82.1.tgz", + "integrity": "sha512-Nr/CU/z/SZWDL92P2u9VDZL9JUxY8L1P7dGI0CmDKHlEHk1+vzqg3UnVkUKkZ5eVMNLtloHbrux5X9Gmkl39WQ==", + "requires": { + "csstype": "3.1.3" + } + }, "ansi-regex": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", @@ -1754,6 +1780,11 @@ "dev": true, "peer": true }, + "csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + }, "didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", diff --git a/assets/package.json b/assets/package.json index 266b195..5a09934 100644 --- a/assets/package.json +++ b/assets/package.json @@ -1,5 +1,8 @@ { "devDependencies": { "tailwindcss-animate": "^1.0.7" + }, + "dependencies": { + "@zag-js/types": "^0.82.1" } } diff --git a/assets/zag/component.js b/assets/zag/component.js new file mode 100644 index 0000000..f5b6bc6 --- /dev/null +++ b/assets/zag/component.js @@ -0,0 +1,236 @@ +import { camelize, normalizeProps } from "./utils"; + +/* + document for component + */ + +export class Component { + el; // root element + context; // context passed to the component + service; // state machine service + api; // api object + cleanupFunctions = new Map(); // cleanup functions for event listeners + prevAttrsMap = new WeakMap(); + + constructor(el, context, componentsModules) { + this.el = el; + this.context = context; + this.componentsModules = componentsModules; + } + + // Initialize the component + init() { + this.initializeComponent(); + this.render(); + + // Re-render on state updates + this.service.subscribe((e) => { + // console.log("State updated", e.event); + this.api = this.initApi(this.componentModule); + this.render(); + }); + + this.service.start(); + } + + destroy() { + this.service.stop(); + this.cleanup(); + } + + // clean up event listeners + cleanup() { + for (const cleanupFn of this.cleanupFunctions.values()) { + cleanupFn(); + } + this.cleanupFunctions.clear(); + } + + /*----------------- Private methods -----------------*/ + initializeComponent() { + // component name is set on the root element via data-component attribute + const componentName = this.el.dataset.component; + + if (!componentName || !this.componentsModules[componentName]) { + console.error(`Component "${componentName}" not found.`); + return; + } + + this.componentModule = this.componentsModules[componentName]; + this.service = this.initService(this.componentModule, this.context); + this.api = this.initApi(this.componentModule); + } + + initService(component, context) { + if (context.collection) { + context.collection = component.collection(context.collection); + } + return component.machine(context); + } + + initApi(component) { + return component.connect( + this.service.state, + this.service.send, + normalizeProps + ); + } + + render() { + this.cleanup(); + + // render the root container + this.renderPart(this.el, "root", this.api); + + for (const part of this.parts(this.el)) { + if (part === "item") continue; + this.renderPart(this.el, part, this.api); + } + + for (const item of this.el.querySelectorAll("[data-part='item']")) { + this.renderItem(item); + } + } + + renderPart(root, name, api, opts = {}, isItemChild = false) { + const getterName = isItemChild + ? `getItem${camelize(name, true)}Props` + : `get${camelize(name, true)}Props`; + if (!api[getterName]) return; + + // wrapper around spreadProps + const spreadProps = (el, attrs, isRoot = false) => { + const cleanup = this.spreadProps(el, attrs, isRoot); + this.cleanupFunctions.set(el, cleanup); + }; + + const isRoot = name === "root"; + if (isRoot) { + spreadProps(root, api[getterName](opts), isRoot); + return; + } + + const elements = root.querySelectorAll(`[data-part='${name}']`); + + for (const el of elements) { + let elOpts = { ...opts }; + if (el.dataset.options) elOpts = JSON.parse(el.dataset.options); + + spreadProps(el, api[getterName](elOpts)); + if (!el.hasAttribute("data-api-bind")) continue; + + // if the element has a data-api-bind attribute, + // bind its text content to the value provided by the api + const apiBind = camelize(el.dataset.apiBind); + if (!apiBind || !this.api[apiBind]) continue; + + const apiValue = this.api[apiBind]; + el.textContent = Array.isArray(apiValue) ? apiValue[0] : apiValue; + } + } + + // Render an item in a list item + renderItem(item) { + let itemOpts = {}; + if (item.dataset.options) { + itemOpts = JSON.parse(item.dataset.options); + } + // console.log("itemProps", this.api.getItemProps(itemProps)); + const cleanup = this.spreadProps(item, this.api.getItemProps(itemOpts)); + this.cleanupFunctions.set(item, cleanup); + + for (const part of this.parts(item)) { + this.renderPart(item, part, this.api, itemOpts, true); + } + } + + // get parts from the element, default to an empty array + // parts data is encoded json in the data-parts attribute + parts(el) { + try { + return JSON.parse(el.dataset.parts || "[]"); + } catch (error) { + console.error("Error parsing parts:", error); + return []; + } + } + + // spread props to the element + // if the prop is an event, update the event listener if it's new or changed + // if the prop is an attribute, update the attribute value if it's new or changed + spreadProps(node, attrs, isRoot = false) { + const oldAttrs = this.prevAttrsMap.get(node) || {}; + const attrKeys = Object.keys(attrs); + + const addEvt = (eventName, listener) => { + node.addEventListener(eventName.toLowerCase(), listener); + }; + + const remEvt = (eventName, listener) => { + node.removeEventListener(eventName.toLowerCase(), listener); + }; + + const onEvents = (attr) => attr.startsWith("on"); + const others = (attr) => !attr.startsWith("on"); + + const setup = (attr) => { + const eventName = attr.substring(2); + const newHandler = attrs[attr]; + const existingHandler = oldAttrs[attr]; + + if (newHandler !== existingHandler) { + addEvt(eventName, newHandler); + } + }; + + const teardown = (attr) => remEvt(attr.substring(2), attrs[attr]); + + // update attribute value if it's new or changed + const apply = (attrName) => { + // avoid replacing id on root element because LiveView + // will lose track of it and DOM patching will not work as expected + if (attrName === "id" && isRoot) return; + + let value = attrs[attrName]; + + const oldValue = oldAttrs[attrName]; + if (value === oldValue) return; + + if (typeof value === "boolean") { + value = value || undefined; + } + + if (value != null) { + if (["value", "checked", "htmlFor"].includes(attrName)) { + node[attrName] = value; + } else { + node.setAttribute(attrName.toLowerCase(), value); + } + return; + } + + node.removeAttribute(attrName.toLowerCase()); + }; + + // reconcile old attributes + for (const key in oldAttrs) { + if (attrs[key] == null) { + node.removeAttribute(key.toLowerCase()); + } + } + + // clean old event listeners + const oldEvents = Object.keys(oldAttrs).filter(onEvents); + oldEvents.forEach((evt) => { + remEvt(evt.substring(2), oldAttrs[evt]); + }); + + attrKeys.filter(onEvents).forEach(setup); + attrKeys.filter(others).forEach(apply); + this.prevAttrsMap.set(node, attrs); + + return function cleanup() { + attrKeys.filter(onEvents).forEach(teardown); + }; + } +} diff --git a/assets/zag/index.js b/assets/zag/index.js new file mode 100644 index 0000000..4a663c8 --- /dev/null +++ b/assets/zag/index.js @@ -0,0 +1,94 @@ +import { Component } from "./component"; +import { camelize } from "./utils"; + +export function createZagHook(components) { + return { + mounted() { + try { + this.component = new Component(this.el, this.context(), components); + this.component.init(); + } catch (error) { + console.error("Error mounting component:", error); + } + }, + + updated() { + // re-render the component when the context changes + this.component.render(); + }, + + destroyed() { + try { + this.component.destroy(); + } catch (error) { + console.error("Error destroying component:", error); + } + }, + + parseOptions() { + let options = {}; + + if (this.el.dataset.options) { + options = JSON.parse(this.el.dataset.options); + } + + return options; + }, + + parseListeners() { + let listeners = {}; + if (this.el.dataset.listeners) { + const listenersConfig = JSON.parse(this.el.dataset.listeners); + listenersConfig.forEach((listener) => { + if (listener.length === 0) { + console.warn( + "Invalid listener format. Please provide at least an event name to listen to." + ); + return; + } + + let event, env; + if (listener.length === 1) { + event = listener[0]; + // default to a client only enviroment when no enviroment is provided + env = ["client"]; + } else { + [event, ...env] = listener; + } + // the event will be dispatched and/or pushed with this name + const eventFacade = `${this.el.dataset.component}:${event.replace( + /_/g, + "-" + )}`; + + listeners[camelize(event)] = (detail) => { + if (env.includes("client")) { + window.dispatchEvent(new CustomEvent(eventFacade, { detail })); + } + + if (env.includes("server")) { + this.pushEvent(eventFacade, detail); + } + }; + }); + } + + return listeners; + }, + + context() { + try { + const options = this.parseOptions(); + const listeners = this.parseListeners(); + return { + id: this.el.id || "", + ...options, + ...listeners, + }; + } catch (error) { + console.error("Error parsing context:", error); + return {}; + } + }, + }; +} diff --git a/assets/zag/utils.js b/assets/zag/utils.js new file mode 100644 index 0000000..d44c2eb --- /dev/null +++ b/assets/zag/utils.js @@ -0,0 +1,47 @@ +import { createNormalizer } from "@zag-js/types"; + +const propMap = { + onFocus: "onFocusin", + onBlur: "onFocusout", + onChange: "onInput", + onDoubleClick: "onDblclick", + htmlFor: "for", + className: "class", + defaultValue: "value", + defaultChecked: "checked", +}; + +export const camelize = (str, capitalizeFirst = false) => { + return str + .toLowerCase() + .replace(/[-_](.)/g, (_match, letter) => letter.toUpperCase()) + .replace(/^(.)/, (_match, firstLetter) => + capitalizeFirst ? firstLetter.toUpperCase() : firstLetter, + ); +}; + +export const normalizeProps = createNormalizer((props) => { + return Object.entries(props).reduce((acc, [key, value]) => { + if (value === undefined) return acc; + key = propMap[key] || key; + + if (key === "style" && typeof value === "object") { + acc.style = toStyleString(value); + } else { + acc[key.toLowerCase()] = value; + } + + return acc; + }, {}); +}); + +export const toStyleString = (style) => { + return Object.entries(style).reduce((styleString, [key, value]) => { + if (value === null || value === undefined) return styleString; + const formattedKey = key.startsWith("--") + ? key + : key.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`); + + return `${styleString}${formattedKey}:${value};`; + }, ""); +}; diff --git a/lib/mix/tasks/salad.add.ex b/lib/mix/tasks/salad.add.ex index 05f9dfa..3d6e28d 100644 --- a/lib/mix/tasks/salad.add.ex +++ b/lib/mix/tasks/salad.add.ex @@ -67,6 +67,7 @@ defmodule Mix.Tasks.Salad.Add do :ok <- File.mkdir_p(Path.dirname(target_path)), modified_content = insert_target_module_name(source_content, file_name), :ok <- File.write(target_path, modified_content), + :ok <- setup_zag_integration(source_content), :ok <- maybe_perform_additional_setup(file_name) do Mix.shell().info("#{file_name} component installed successfully ✅") else @@ -131,6 +132,37 @@ defmodule Mix.Tasks.Salad.Add do :ok end + defp setup_zag_integration(source) do + # grab the component we are targeting in Zag + case Regex.run(~r/data-component="([^"]+)"/, source) do + [_, target_zag_component] -> + Mix.shell().info(""" + The component you are installing requires Zag to work. + + In order to use it, go to your app.js file and add the following lines: + + import { createZagHook } from '../../deps/salad_ui/assets/zag' + import * as components from '../vendor/zag' + + Then, register a hook named `ZagHook` in your LiveSocket constructor: + + new LiveSocket(..., {..., hooks: { ZagHook: createZagHook(components) }}) + """) + + install_zag_package(target_zag_component) + + _ -> + :ok + end + end + + # TODO: Download the source code of the zag package, + # place it in the `assets/vendor/zag` folder in the user project + # and patch the `assets/vendor/zag/index.js` to export the package + defp install_zag_package(_component_name) do + :ok + end + defp get_module_name do Mix.Project.config()[:app] |> Atom.to_string() diff --git a/lib/mix/tasks/salad.init.ex b/lib/mix/tasks/salad.init.ex index 5494361..ee17870 100644 --- a/lib/mix/tasks/salad.init.ex +++ b/lib/mix/tasks/salad.init.ex @@ -45,6 +45,7 @@ defmodule Mix.Tasks.Salad.Init do :ok <- patch_css(color_scheme, assets_path), :ok <- patch_js(assets_path), :ok <- copy_tailwind_colors(assets_path), + :ok <- copy_zag_files(assets_path), :ok <- patch_tailwind_config(opts), :ok <- maybe_write_helpers_module(component_path, app_name, opts), :ok <- maybe_write_component_module(component_path, app_name, opts), @@ -155,6 +156,25 @@ defmodule Mix.Tasks.Salad.Init do :ok end + defp copy_zag_files(assets_path) do + Mix.shell().info("Copying zag files to assets folder") + source_path = Path.join(assets_path, "zag") + target_path = Path.join(File.cwd!(), "assets/js/zag") + + File.mkdir_p!(target_path) + + Enum.each(File.ls!(source_path), fn file -> + unless File.exists?(Path.join(target_path, file)) do + File.cp!(Path.join(source_path, file), Path.join(target_path, file)) + end + end) + + Mix.shell().info("\nZagHook installed successfully") + Mix.shell().info("Do not forget to import it into your app.js file and pass it to your live socket") + + :ok + end + defp patch_tailwind_config(opts) do Mix.shell().info("Patching tailwind.config.js") tailwind_config_path = Path.join(File.cwd!(), "assets/tailwind.config.js") diff --git a/lib/salad_ui/accordion.ex b/lib/salad_ui/accordion.ex index 3091858..6a490be 100644 --- a/lib/salad_ui/accordion.ex +++ b/lib/salad_ui/accordion.ex @@ -2,27 +2,27 @@ defmodule SaladUI.Accordion do @moduledoc """ Accordion component for displaying collapsible content. - ## Example + ## Examples <.accordion> - <.accordion_item> - <.accordion_trigger group="exclusive"> + <.accordion_item group="1"> + <.accordion_trigger> Is it accessible? <.accordion_content> Yes. It adheres to the WAI-ARIA design pattern. - <.accordion_item> - <.accordion_trigger group="exclusive"> + <.accordion_item group="2"> + <.accordion_trigger> Is it styled? <.accordion_content> Yes. It comes with default styles that matches the other components' aesthetic. - <.accordion_item> - <.accordion_trigger group="exclusive"> + <.accordion_item group="3"> + <.accordion_trigger> Is it animated? <.accordion_content> @@ -36,6 +36,9 @@ defmodule SaladUI.Accordion do attr :class, :string, default: nil slot :inner_block, required: true + @doc """ + Renders the root container for the accordion component. + """ def accordion(assigns) do ~H"""
@@ -44,31 +47,50 @@ defmodule SaladUI.Accordion do """ end + attr :group, :string, required: true, doc: "unique identifier to the accordion item" + attr :class, :string, default: nil slot :inner_block, required: true + @doc """ + Renders an accordion item. + """ def accordion_item(assigns) do + assigns = assign(assigns, :builder, %{group: assigns[:group]}) + ~H"""
- {render_slot(@inner_block)} + {render_slot(@inner_block, @builder)}
""" end - attr :group, :string, default: nil - attr :class, :string, default: nil + attr :builder, :map, required: true, doc: "Builder instance for accordion item" attr :open, :boolean, default: false + attr :class, :string, default: nil slot :inner_block, required: true + @doc """ + Renders the trigger for an accordion item. + """ def accordion_trigger(assigns) do ~H""" -
- +
+

{render_slot(@inner_block)}

@@ -92,12 +114,20 @@ defmodule SaladUI.Accordion do """ end + attr :builder, :map, required: true, doc: "Builder instance for accordion item" attr :class, :string, default: nil slot :inner_block, required: true + @doc """ + Renders the content for an accordion item. + """ def accordion_content(assigns) do ~H""" -
+
{render_slot(@inner_block)} diff --git a/lib/salad_ui/collapsible.ex b/lib/salad_ui/collapsible.ex index eab9265..c9b5c25 100644 --- a/lib/salad_ui/collapsible.ex +++ b/lib/salad_ui/collapsible.ex @@ -4,8 +4,8 @@ defmodule SaladUI.Collapsible do ## Examples: - <.collapsible id="collapsible-1" open let={builder}> - <.collapsible_trigger builder={builder}> + <.collapsible id="collapsible-1" open> + <.collapsible_trigger> <.button variant="outline">Show content <.collapsible_content> @@ -20,23 +20,26 @@ defmodule SaladUI.Collapsible do attr :id, :string, required: true, - doc: "Id to identify collapsible component, collapsible_trigger uses this id to toggle content visibility" + doc: "Id to identify collapsible component" - attr :open, :boolean, default: true, doc: "Initial state of collapsible content" + attr :open, :boolean, default: false, doc: "Initial state of collapsible content" + attr :listeners, :list, default: [] attr :class, :string, default: nil attr :rest, :global, include: ~w(title) - slot(:inner_block, required: true) + slot :inner_block, required: true def collapsible(assigns) do - assigns = - assign(assigns, :open, normalize_boolean(assigns[:open])) + assigns = assign(assigns, :open, normalize_boolean(assigns[:open])) ~H"""
@@ -55,12 +58,7 @@ defmodule SaladUI.Collapsible do def collapsible_trigger(assigns) do ~H""" - <.dynamic - tag={@as_tag} - onclick={exec_closest("phx-toggle-collapsible", ".collapsible-root")} - class={@class} - {@rest} - > + <.dynamic tag={@as_tag} data-part="trigger" class={@class} {@rest}> {render_slot(@inner_block)} """ @@ -76,9 +74,10 @@ defmodule SaladUI.Collapsible do def collapsible_content(assigns) do ~H"""