From c7da662a44334c3c8b7395c28eb7f8566b52d61d Mon Sep 17 00:00:00 2001 From: selenil Date: Mon, 18 Nov 2024 15:45:45 -0500 Subject: [PATCH 01/51] add files related to zag --- priv/static/assets/zag/ZagHook.js | 213 ++++++++++++++++++++++++++++++ priv/static/assets/zag/utils.js | 72 ++++++++++ 2 files changed, 285 insertions(+) create mode 100644 priv/static/assets/zag/ZagHook.js create mode 100644 priv/static/assets/zag/utils.js diff --git a/priv/static/assets/zag/ZagHook.js b/priv/static/assets/zag/ZagHook.js new file mode 100644 index 0000000..4ce0224 --- /dev/null +++ b/priv/static/assets/zag/ZagHook.js @@ -0,0 +1,213 @@ +import * as componentsModules from "./index"; +import { camelize, getBooleanOption, getOption, normalizeProps } from "./utils"; + +class Component { + el; + context; + service; + api; + cleanupFunctions = new Map(); + + constructor(el, context) { + this.el = el; + this.context = context; + } + + init() { + this.initializeComponent(); + this.render(); + + // Re-render on state updates + this.service.subscribe(() => { + this.api = this.initApi(this.componentModule); + this.render(); + }); + + this.service.start(); + } + + initializeComponent() { + const componentName = this.el.dataset.component; + + if (!componentName || !componentsModules[componentName]) { + console.error(`Component "${componentName}" not found.`); + return; + } + + this.componentModule = componentsModules[componentName]; + this.service = this.initService(this.componentModule, this.context); + this.api = this.initApi(this.componentModule); + } + + destroy() { + this.service.stop(); + this.cleanup(); + } + + cleanup() { + for (const cleanupFn of this.cleanupFunctions.values()) { + cleanupFn(); + } + this.cleanupFunctions.clear(); + } + + parts(el) { + try { + return JSON.parse(el.dataset.parts || "[]"); + } catch (error) { + console.error("Error parsing parts:", error); + return []; + } + } + + initService(component, context) { + return component.machine(context); + } + + initApi(component) { + return component.connect( + this.service.state, + this.service.send, + normalizeProps + ); + } + + render() { + this.cleanup(); + + for (const part of ["root", ...this.parts(this.el)]) { + 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 = {}) { + const part = + name === "root" ? root : root.querySelector(`[data-part='${name}']`); + + const getterName = `get${camelize(name, true)}Props`; + + if (part && api[getterName]) { + const cleanup = this.spreadProps(part, api[getterName](opts)); + this.cleanupFunctions.set(part, cleanup); + } + } + + renderItem(item) { + const value = item.dataset.value; + if (!value) { + console.error("Missing `data-value` attribute on item."); + return; + } + + const cleanup = this.spreadProps(item, this.api.getItemProps({ value })); + this.cleanupFunctions.set(item, cleanup); + + for (const part of this.parts(item)) { + this.renderPart(item, `item-${part}`, this.api, { value }); + } + } + + spreadProps(el, attrs) { + const oldAttrs = el; + const prevAttrsMap = new WeakMap(); + const attrKeys = Object.keys(attrs); + + const addEvent = (event, callback) => { + el.addEventListener(event.toLowerCase(), callback); + }; + + const removeEvent = (event, callback) => { + el.removeEventListener(event.toLowerCase(), callback); + }; + + const setup = (attr) => addEvent(attr.substring(2), attrs[attr]); + const teardown = (attr) => removeEvent(attr.substring(2), attrs[attr]); + + const apply = (attrName) => { + // avoid overriding element's id because LiveView will lose + // track of it and DOM patching will not work as expected + if (attrName === "id") 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", "id"].includes(attrName)) { + el[attrName] = value; + } else { + el.setAttribute(attrName.toLowerCase(), value); + } + } else { + el.removeAttribute(attrName.toLowerCase()); + } + }; + + attrKeys.forEach((key) => { + if (key.startsWith("on")) setup(key); + else apply(key); + }); + + prevAttrsMap.set(el, attrs); + + return () => { + attrKeys.filter((key) => key.startsWith("on")).forEach(teardown); + }; + } +} + +export default { + mounted() { + try { + this.component = new Component(this.el, this.context()); + this.component.init(); + } catch (error) { + console.error("Error mounting component:", error); + } + }, + + destroyed() { + try { + this.component.destroy(); + } catch (error) { + console.error("Error destroying component:", error); + } + }, + + context() { + try { + const options = this.el.dataset.options + ? Object.fromEntries( + Object.entries(JSON.parse(this.el.dataset.options)).map( + ([key, value]) => [ + camelize(key), + value === "bool" + ? getBooleanOption(this.el, key) + : getOption(this.el, key, value), + ] + ) + ) + : {}; + + const listeners = this.el.dataset.listeners + ? JSON.parse(this.el.dataset.listeners) + .map((event) => ({ + [`on${camelize(event, true)}Change`]: (details) => + this.pushEvent(event, details), + })) + .reduce((acc, listener) => ({ ...acc, ...listener }), {}) + : {}; + + return { id: this.el.id || "", ...options, ...listeners }; + } catch (error) { + console.error("Error parsing context:", error); + return {}; + } + }, +}; diff --git a/priv/static/assets/zag/utils.js b/priv/static/assets/zag/utils.js new file mode 100644 index 0000000..42a907a --- /dev/null +++ b/priv/static/assets/zag/utils.js @@ -0,0 +1,72 @@ +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 getOption = (el, name, validOptions) => { + const kebabName = name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); + let initial = el.dataset[kebabName]; + + if ( + validOptions && + initial !== undefined && + !validOptions.includes(initial) + ) { + console.error( + `Invalid '${name}' specified: '${initial}'. Expected one of '${validOptions.join( + "', '" + )}'.` + ); + initial = undefined; + } + + return initial; +}; + +export const getBooleanOption = (el, name) => { + const kebabName = name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); + return el.dataset[kebabName] === "true" || el.dataset[kebabName] === ""; +}; + +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};`; + }, ""); +}; From 3a85322068f06456732fbe3c19f98e96daadeb76 Mon Sep 17 00:00:00 2001 From: selenil Date: Mon, 18 Nov 2024 15:46:51 -0500 Subject: [PATCH 02/51] modify cli to work with zag hook --- lib/mix/tasks/salad.add.ex | 32 ++++++++++++++++++++++++++++++++ lib/mix/tasks/salad.init.ex | 20 ++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/lib/mix/tasks/salad.add.ex b/lib/mix/tasks/salad.add.ex index 05f9dfa..2c70c8e 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, file_name), :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, file_name) do + # grab the component we are targeting in Zag + case Regex.run(~r/data-component="([^"]+)"/, source) do + [_, target_zag_component] -> + zag_imports_path = Path.join(File.cwd!(), "assets/js/zag/index.js") + + File.open!( + zag_imports_path, + [:append], + fn file -> + IO.write(file, "export * as #{target_zag_component} from '@zag-js/#{target_zag_component}';\n") + end + ) + + unless Mix.env() == :test do + Mix.shell().cmd("npm install @zag-js/#{target_zag_component} --prefix assets") + end + + :ok + + _ -> + Mix.shell().info(""" + The component you are trying to install (#{file_name}) does not have a data-component attribute set, so you cannot use Zag with it. The component will lack accessibility support and interactive features + """) + + continue? = Mix.shell().yes?("Do you want to continue with the installation?") + + if continue?, do: :ok, else: {:error, "Installation aborted"} + end + 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 0289db4..c072b5a 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") From 34d187ea3e58582d4c34d9326b6d5ab5de9e31ea Mon Sep 17 00:00:00 2001 From: selenil Date: Mon, 18 Nov 2024 15:47:25 -0500 Subject: [PATCH 03/51] add unique_id helper --- lib/salad_ui/helpers.ex | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/salad_ui/helpers.ex b/lib/salad_ui/helpers.ex index 0b73c5c..b39f2c8 100644 --- a/lib/salad_ui/helpers.ex +++ b/lib/salad_ui/helpers.ex @@ -123,6 +123,13 @@ defmodule SaladUI.Helpers do "#{shared_classes} #{variation_classes}" end + def unique_id(seed \\ 16, length \\ 22) do + seed + |> :crypto.strong_rand_bytes() + |> Base.url_encode64() + |> binary_part(0, length) + end + # Translate error message # borrowed from https://github.com/petalframework/petal_components/blob/main/lib/petal_components/field.ex#L414 defp translate_error({msg, opts}) do From 9eb93f1a71767a3765b778ccabd8df5be290b500 Mon Sep 17 00:00:00 2001 From: selenil Date: Tue, 19 Nov 2024 23:17:53 -0500 Subject: [PATCH 04/51] make collapsible component accessible --- lib/salad_ui/collapsible.ex | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/lib/salad_ui/collapsible.ex b/lib/salad_ui/collapsible.ex index ec6380f..bb60861 100644 --- a/lib/salad_ui/collapsible.ex +++ b/lib/salad_ui/collapsible.ex @@ -20,26 +20,28 @@ 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: false, doc: "Initial state of collapsible content" + attr :listeners, :list, default: [] attr :class, :string, default: nil slot(:inner_block, required: true) def collapsible(assigns) do - assigns = - assigns - |> assign(:builder, %{open: assigns[:open], id: assigns[:id]}) - |> assign(:open, normalize_boolean(assigns[:open])) + assigns = assign(assigns, :open, normalize_boolean(assigns[:open])) ~H"""
- <%= render_slot(@inner_block, @builder) %> + <%= render_slot(@inner_block) %>
""" end @@ -47,13 +49,12 @@ defmodule SaladUI.Collapsible do @doc """ Render trigger for collapsible component. """ - attr :builder, :map, required: true, doc: "Builder instance for collapsible component" attr(:class, :string, default: nil) slot(:inner_block, required: true) def collapsible_trigger(assigns) do ~H""" -
@builder.id)} class={@class}> +
<%= render_slot(@inner_block) %>
""" @@ -69,9 +70,10 @@ defmodule SaladUI.Collapsible do def collapsible_content(assigns) do ~H"""