phoenix_live_view icon indicating copy to clipboard operation
phoenix_live_view copied to clipboard

Feature Request: toggle_attribute

Open keatz55 opened this issue 3 years ago • 5 comments

I've come across a few instances where it would be useful to have the ability to toggle aria-*, disabled, checked, dir, and various other types of attributes via Phoenix.LiveView.JS using a single button without having to store the state manually. This PR operates by adding an attribute key-value pair if it is not present, or removing the attribute altogether if it is present. E.g.:

<!-- Active State -->
<article dir="rtl">...</article>
<!-- Inactive State -->
<article>...</article>

If useful, I can update to also support active vs inactive value options:

<!-- Active State -->
<div aria-expanded="true">...</div>
<!-- Inactive State -->
<div aria-expanded="false">...</div>

In any case, thank you for this wonderful library and all of the great work all of you do!

keatz55 avatar May 04 '22 00:05 keatz55

Brilliant! Naively, I'd of thought the addition of toggle_attribute and toggle_class would be great functions to add along with their explicit add/remove friends. The beauty of OSS though, we can add it anyway.

megalithic avatar May 20 '22 21:05 megalithic

@keatz55 you probably need to update your PR, but I agree with @megalithic this would be a great addition. I want to use it for aria-expanded too!

jsmestad avatar Sep 20 '22 16:09 jsmestad

@keatz55 you probably need to update your PR, but I agree with @megalithic this would be a great addition. I want to use it for aria-expanded too!

@jsmestad Sounds good. I have quite a bit on my plate this week, but will update this weekend.

keatz55 avatar Sep 22 '22 02:09 keatz55

Here is an updated patch for you.

It also adds a test against multiple selectors and adjusts some doc language to match existing terms as well as fixing some omissions (only mentioned removing attributes and incorrectly describes the first argument type in current pr).

I can repackage this as a PR but didn't want to "snipe" your work or make duplicate noise.

2004-rebase.patch
diff --git a/assets/js/phoenix_live_view/js.js b/assets/js/phoenix_live_view/js.js
index 0361271c..69df0d3f 100644
--- a/assets/js/phoenix_live_view/js.js
+++ b/assets/js/phoenix_live_view/js.js
@@ -116,6 +116,14 @@ let JS = {
     this.setOrRemoveAttrs(el, [], [attr])
   },
 
+  exec_toggle_attr(eventType, phxEvent, view, sourceEl, el, {attr: [attr, val]}){
+    if (el.hasAttribute(attr)) {
+      this.setOrRemoveAttrs(el, [], [attr])
+    }  else {
+      this.setOrRemoveAttrs(el, [[attr, val]], [])
+    }
+  },
+
   // utils for commands
 
   show(eventType, view, el, display, transition, time){
diff --git a/assets/test/js_test.js b/assets/test/js_test.js
index 5856a846..2361997a 100644
--- a/assets/test/js_test.js
+++ b/assets/test/js_test.js
@@ -556,4 +556,78 @@ describe("JS", () => {
       expect(modal.getAttribute("aria-expanded")).toEqual("true")
     })
   })
+
+  describe("exec_toggle_attr", () => {
+    test("with defaults", () => {
+      let view = setupView(`
+      <div id="modal" class="modal">modal</div>
+      <div id="toggle" phx-click='[["toggle_attr", {"to": "#modal", "attr": ["open", "true"]}]]'></div>
+      `)
+      let modal = document.querySelector("#modal")
+      let toggle = document.querySelector("#toggle")
+
+      expect(modal.getAttribute("open")).toEqual(null)
+      JS.exec("click", toggle.getAttribute("phx-click"), view, toggle)
+      expect(modal.getAttribute("open")).toEqual("true")
+
+      JS.exec("click", toggle.getAttribute("phx-click"), view, toggle)
+      expect(modal.getAttribute("open")).toEqual(null)
+    })
+
+    test("with no selector", () => {
+      let view = setupView(`
+      <div id="toggle" phx-click='[["toggle_attr", {"to": null, "attr": ["open", "true"]}]]'></div>
+      `)
+      let toggle = document.querySelector("#toggle")
+
+      expect(toggle.getAttribute("open")).toEqual(null)
+      JS.exec("click", toggle.getAttribute("phx-click"), view, toggle)
+      expect(toggle.getAttribute("open")).toEqual("true")
+    })
+
+    test("with multiple selector", () => {
+      let view = setupView(`
+      <div id="modal1">modal</div>
+      <div id="modal2" open="true">modal</div>
+      <div id="toggle" phx-click='[["toggle_attr", {"to": "#modal1, #modal2", "attr": ["open", "true"]}]]'></div>
+      `)
+      let modal1 = document.querySelector("#modal1")
+      let modal2 = document.querySelector("#modal2")
+      let toggle = document.querySelector("#toggle")
+
+      expect(modal1.getAttribute("open")).toEqual(null)
+      expect(modal2.getAttribute("open")).toEqual("true")
+      JS.exec("click", toggle.getAttribute("phx-click"), view, toggle)
+      expect(modal1.getAttribute("open")).toEqual("true")
+      expect(modal2.getAttribute("open")).toEqual(null)
+    })
+
+    test("toggling a pre-existing attribute updates its value", () => {
+      let view = setupView(`
+      <div id="modal" class="modal" open="true">modal</div>
+      <div id="toggle" phx-click='[["toggle_attr", {"to": "#modal", "attr": ["open", "true"]}]]'></div>
+      `)
+      let toggle = document.querySelector("#toggle")
+
+      expect(modal.getAttribute("open")).toEqual("true")
+      JS.exec("click", toggle.getAttribute("phx-click"), view, toggle)
+      expect(modal.getAttribute("open")).toEqual(null)
+    })
+
+    test("toggling a dynamically added attribute updates its value", () => {
+      let view = setupView(`
+      <div id="modal" class="modal">modal</div>
+      <div id="toggle1" phx-click='[["toggle_attr", {"to": "#modal", "attr": ["open", "true"]}]]'></div>
+      <div id="toggle2" phx-click='[["toggle_attr", {"to": "#modal", "attr": ["open", "true"]}]]'></div>
+      `)
+      let toggle1 = document.querySelector("#toggle1")
+      let toggle2 = document.querySelector("#toggle2")
+
+      expect(modal.getAttribute("open")).toEqual(null)
+      JS.exec("click", toggle1.getAttribute("phx-click"), view, toggle1)
+      expect(modal.getAttribute("open")).toEqual("true")
+      JS.exec("click", toggle2.getAttribute("phx-click"), view, toggle2)
+      expect(modal.getAttribute("open")).toEqual(null)
+    })
+  })
 })
diff --git a/lib/phoenix_live_view/js.ex b/lib/phoenix_live_view/js.ex
index ceab4a53..22a53121 100644
--- a/lib/phoenix_live_view/js.ex
+++ b/lib/phoenix_live_view/js.ex
@@ -21,6 +21,7 @@ defmodule Phoenix.LiveView.JS do
     * `remove_class` - Remove classes from elements, with optional transitions
     * `set_attribute` - Set an attribute on elements
     * `remove_attribute` - Remove an attribute from elements
+    * `toggle_attribute` - Set or remove an attribute from elements, based on attribute presence
     * `show` - Show elements, with optional transitions
     * `hide` - Hide elements, with optional transitions
     * `toggle` - Shows or hides elements based on visibility, with optional transitions
@@ -601,6 +602,39 @@ defmodule Phoenix.LiveView.JS do
     put_op(js, "remove_attr", %{to: opts[:to], attr: attr})
   end
 
+  @doc """
+  Sets or removes attribute from element, based on attribute presence.
+
+  Accepts a tuple containing the string attribute name/value pair.
+
+  Note that the value is only used when setting the attribute and is
+  otherwise ignored when removing an attribute.
+
+  ## Options
+
+    * `:to` - The optional DOM selector to set or remove attributes from.
+      Defaults to the interacted element.
+
+  ## Examples
+
+      <button phx-click={JS.toggle_attribute({"aria-expanded", "true"}, to: "#dropdown")}>
+        toggle
+      </button>
+  """
+  def toggle_attribute({attr, val}), do: toggle_attribute(%JS{}, {attr, val}, [])
+
+  @doc "See `toggle_attribute/1`."
+  def toggle_attribute({attr, val}, opts) when is_list(opts),
+    do: toggle_attribute(%JS{}, {attr, val}, opts)
+
+  def toggle_attribute(%JS{} = js, {attr, val}), do: toggle_attribute(js, {attr, val}, [])
+
+  @doc "See `toggle_attribute/1`."
+  def toggle_attribute(%JS{} = js, {attr, val}, opts) when is_list(opts) do
+    opts = validate_keys(opts, :toggle_attribute, [:to])
+    put_op(js, "toggle_attr", %{to: opts[:to], attr: [attr, val]})
+  end
+
   @doc """
   Sends focus to a selector.
 
@@ -616,6 +650,7 @@ defmodule Phoenix.LiveView.JS do
   def focus(), do: focus(%JS{}, [])
   def focus(%JS{} = js), do: focus(js, [])
   def focus(opts) when is_list(opts), do: focus(%JS{}, opts)
+
   def focus(%JS{} = js, opts) when is_list(opts) do
     opts = validate_keys(opts, :focus, [:to])
     put_op(js, "focus", %{to: opts[:to]})
@@ -636,6 +671,7 @@ defmodule Phoenix.LiveView.JS do
   def focus_first(), do: focus_first(%JS{}, [])
   def focus_first(%JS{} = js), do: focus_first(js, [])
   def focus_first(opts) when is_list(opts), do: focus_first(%JS{}, opts)
+
   def focus_first(%JS{} = js, opts) when is_list(opts) do
     opts = validate_keys(opts, :focus_first, [:to])
     put_op(js, "focus_first", %{to: opts[:to]})
@@ -657,6 +693,7 @@ defmodule Phoenix.LiveView.JS do
   def push_focus(), do: push_focus(%JS{}, [])
   def push_focus(%JS{} = js), do: push_focus(js, [])
   def push_focus(opts) when is_list(opts), do: push_focus(%JS{}, opts)
+
   def push_focus(%JS{} = js, opts) when is_list(opts) do
     opts = validate_keys(opts, :push_focus, [:to])
     put_op(js, "push_focus", %{to: opts[:to]})
@@ -670,6 +707,7 @@ defmodule Phoenix.LiveView.JS do
       JS.pop_focus()
   """
   def pop_focus(), do: pop_focus(%JS{})
+
   def pop_focus(%JS{} = js) do
     put_op(js, "pop_focus", %{})
   end
@@ -688,12 +726,15 @@ defmodule Phoenix.LiveView.JS do
   def navigate(href) when is_binary(href) do
     navigate(%JS{}, href, [])
   end
+
   def navigate(href, opts) when is_binary(href) and is_list(opts) do
     navigate(%JS{}, href, opts)
   end
+
   def navigate(%JS{} = js, href) when is_binary(href) do
     navigate(js, href, [])
   end
+
   def navigate(%JS{} = js, href, opts) when is_binary(href) and is_list(opts) do
     opts = validate_keys(opts, :navigate, [:replace])
     put_op(js, "navigate", %{href: href, replace: !!opts[:replace]})
@@ -713,12 +754,15 @@ defmodule Phoenix.LiveView.JS do
   def patch(href) when is_binary(href) do
     patch(%JS{}, href, [])
   end
+
   def patch(href, opts) when is_binary(href) and is_list(opts) do
     patch(%JS{}, href, opts)
   end
+
   def patch(%JS{} = js, href) when is_binary(href) do
     patch(js, href, [])
   end
+
   def patch(%JS{} = js, href, opts) when is_binary(href) and is_list(opts) do
     opts = validate_keys(opts, :patch, [:replace])
     put_op(js, "patch", %{href: href, replace: !!opts[:replace]})
diff --git a/test/phoenix_live_view/js_test.exs b/test/phoenix_live_view/js_test.exs
index 7344bd1a..d5dbda8b 100644
--- a/test/phoenix_live_view/js_test.exs
+++ b/test/phoenix_live_view/js_test.exs
@@ -834,6 +834,49 @@ defmodule Phoenix.LiveView.JSTest do
     end
   end
 
+  describe "toggle_attribute" do
+    test "with defaults" do
+      assert JS.toggle_attribute({"open", "true"}) == %JS{
+               ops: [
+                 ["toggle_attr", %{attr: ["open", "true"], to: nil}]
+               ]
+             }
+
+      assert JS.toggle_attribute({"open", "true"}, to: "#dropdown") == %JS{
+               ops: [
+                 ["toggle_attr", %{attr: ["open", "true"], to: "#dropdown"}]
+               ]
+             }
+    end
+
+    test "composability" do
+      js =
+        {"expanded", "true"}
+        |> JS.toggle_attribute()
+        |> JS.toggle_attribute({"open", "true"})
+        |> JS.toggle_attribute({"disabled", "true"}, to: "#dropdown")
+
+      assert js == %JS{
+               ops: [
+                 ["toggle_attr", %{to: nil, attr: ["expanded", "true"]}],
+                 ["toggle_attr", %{to: nil, attr: ["open", "true"]}],
+                 ["toggle_attr", %{to: "#dropdown", attr: ["disabled", "true"]}]
+               ]
+             }
+    end
+
+    test "raises with unknown options" do
+      assert_raise ArgumentError, ~r/invalid option for toggle_attribute/, fn ->
+        JS.toggle_attribute({"disabled", "true"}, bad: :opt)
+      end
+    end
+
+    test "encoding" do
+      assert js_to_string(JS.toggle_attribute({"disabled", "true"})) ==
+               "[[&quot;toggle_attr&quot;,{&quot;attr&quot;:[&quot;disabled&quot;,&quot;true&quot;],&quot;to&quot;:null}]]"
+    end
+  end
+
   defp js_to_string(%JS{} = js) do
     js
     |> Phoenix.HTML.Safe.to_iodata()

rktjmp avatar Sep 22 '22 03:09 rktjmp

Here is an updated patch for you.

It also adds a test against multiple selectors and adjusts some doc language to match existing terms as well as fixing some omissions (only mentioned removing attributes and incorrectly describes the first argument type in current pr).

I can repackage this as a PR but didn't want to "snipe" your work or make duplicate noise.

2004-rebase.patch

@rktjmp added your suggested edits. Thanks!

keatz55 avatar Sep 22 '22 22:09 keatz55

Interesting feature. I've just mucked around briefly and added a first stab at a general extension loader so mods like this can be injected somewhat safely.

I use a slightly different toggler to make is easy to switch aria-* to true/false

const jsExtension = new Map();
jsExtension.set('version', '0.18.18');
const jsExtensionMethods = new Map();
jsExtensionMethods.set('exec_toggle_attr', function(eventType, phxEvent, view, sourceEl, el, {attr: [attr, val]}){
    if (el.hasAttribute(attr)) {
        if (val == 'true' || value == 'false') {
            let cur = el.getAttribute(attr);
            if (cur == 'true') {
                cur = 'false';
                this.setOrRemoveAttrs(el, [[attr, cur]], [])
            } else if (cur == 'false') {
                cur = 'true';
                this.setOrRemoveAttrs(el, [[attr, cur]], [])
            } else {
                this.setOrRemoveAttrs(el, [], [[attr, val]])
            }
        } else {
            this.setOrRemoveAttrs(el, [], [[attr, val]])
        }
    }  else {
        this.setOrRemoveAttrs(el, [[attr, val]], [])
    }
});

jsExtension.set('methods', jsExtensionMethods);
let live_extension = {
    jsExtension: jsExtension
}

liveSocket.loadExtension(live_extension);

with a very crude extension loader here: https://github.com/phoenixframework/phoenix_live_view/compare/v0.18.18...noizu:phoenix_live_view:0.18.18?expand=1

noizu avatar Jun 05 '23 22:06 noizu

Is anything blocking this PR from being merged, or has it simply not been reviewed yet? A toggle_attribute function would allow me to avoid JS hooks in most (or maybe all) cases in my component library Doggo, potentially avoiding the need to package any JS at all with it.

woylie avatar Jan 07 '24 00:01 woylie