Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use SheafDisplay fr composed sheaf view in discuss #136

Draft
wants to merge 23 commits into
base: feat/discussion/sheaf-creation-modal
Choose a base branch
from

Conversation

rtshkmr
Copy link
Member

@rtshkmr rtshkmr commented Nov 19, 2024

Also has a bunch of other inflex related things as well

@rtshkmr rtshkmr self-assigned this Nov 19, 2024
Also has a bunch of other inflex related things as well

add icon file
@rtshkmr rtshkmr force-pushed the feat/discussion/ui-iteration-improve-sheaf-display branch from d999764 to 81cb5be Compare November 19, 2024 23:12
1.  [FIXME odd tailwind config issue:](#org9cd6457)

<a id="org9cd6457"></a>

So after the annoyance about using css variables to define other css variables and how even after doing the hsl syntax, when using them in tailwind utility classes, it wasn&rsquo;t possible to add opacity classes to it,
[here&rsquo;s a related gh comment for it](shadcn-ui/ui#805)

I decided to try using an external `colors.js` so that I can make my best friend, JS, do the magic.

Turns out this breaks the tailwindconfig file and some other import doesn&rsquo;t get its name resolved. I have no idea why, let me know if you know this.

Using JS might be more convenient.
perhaps this functional fetching of colors might make work [as seen here](https://stackoverflow.com/questions/74521806/how-to-add-custom-opacity-values-to-colors-in-tailwind-css-themes/74719392#74719392)?
I&rsquo;m going to side-step this for now.
@@ -4,6 +4,7 @@
const plugin = require("tailwindcss/plugin");
const fs = require("fs");
const path = require("path");
// const colors = require("./colors"); // QQ: @ks0m1c_dharma any idea why if i uncomment this, the tailwind plugin config gets broken?
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FIXME odd tailwind config issue:

So after the annoyance about using css variables to define other css variables and how even after doing the hsl syntax, when using them in tailwind utility classes, it wasn’t possible to add opacity classes to it,
here’s a related gh comment for it

I decided to try using an external colors.js so that I can make my best friend, JS, do the magic.

Turns out this breaks the tailwindconfig file and some other import doesn’t get its name resolved. I have no idea why, let me know if you know this.

    Rebuilding...
    Error: Cannot find module 'tailwindcss/plugin'
    Require stack:
    - /Users/rtshkmr/Projects/vyasa/assets/tailwind.config.js
        at Function.Module._resolveFilename (node:internal/modules/cjs/loader:933:15)
        at Function._resolveFilename (pkg/prelude/bootstrap.js:1955:46)
        at Function.resolve (node:internal/modules/cjs/helpers:108:19)
        at _resolve (/snapshot/tailwindcss/node_modules/jiti/dist/jiti.js:1:241025)
        at jiti (/snapshot/tailwindcss/node_modules/jiti/dist/jiti.js:1:243309)
        at /Users/rtshkmr/Projects/vyasa/assets/tailwind.config.js:4:16
        at jiti (/snapshot/tailwindcss/node_modules/jiti/dist/jiti.js:1:245784)
        at /snapshot/tailwindcss/lib/lib/load-config.js:37:30
        at loadConfig (/snapshot/tailwindcss/lib/lib/load-config.js:39:6)
        at Object.loadConfig (/snapshot/tailwindcss/lib/cli/build/plugin.js:135:49) {
      code: 'MODULE_NOT_FOUND',
      requireStack: [ '/Users/rtshkmr/Projects/vyasa/assets/tailwind.config.js' ]
    }

Using JS might be more convenient.
perhaps this functional fetching of colors might make work as seen here?
I’m going to side-step this for now.

═════════════════════════════════════════════

  This works well enough now, will carry on.

  The TextareaAutoResize hook, when applied to any textarea (should work
  on other elements as well actually), will now listen for some events
  and fire off the handleInput().

  ⁃ the handleInput is a bound reference – it’s a closure fn that has
    access to the variables that the initialiser has access to. By
    having the same reference, we can remove the eventListeners as part
    of the teardown subrouting in the `destroy()' callback

  ⁃ faced an issue because the textarea is actually mounted, just not
    visible because it’s hidden within a dropdown. This is a good use
    case for the `InteractionObserver API' ([ref docs]), it allows async
    observation of visibility of an element w.r.t. the viewport. Using
    this observer allows us to call the handleInput when it’s already
    mounted

  ⁃ when my textarea turns from disabled to enabled, it for some reason
    it goes back to being number of rows = 1 and the handleInput doesn’t
    run anymore. The solution to this was to use a `MutationObserver'
    ([ref api docs]) which can watch for chanes to the dom tree.
    Specifically, we watch for changes to this element’s (textarea)
    disabled attribute, then call the handleInput.

[ref docs]
<https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver>

[ref api docs]
<https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver>
@rtshkmr
Copy link
Member Author

rtshkmr commented Nov 20, 2024

@ks0m1c

Or, see the commit msg here, same thing

Notes on TextareaAutoResize, good learning commit -- TIL

This works well enough now, will carry on.

The TextareaAutoResize hook, when applied to any textarea (should work on other elements as well actually), will now listen for some events and fire off the handleInput().

  • the handleInput is a bound reference – it’s a closure fn that has access to the variables that the initialiser has access to. By having the same reference, we can remove the eventListeners as part of the teardown subrouting in the destroy() callback

  • faced an issue because the textarea is actually mounted, just not visible because it’s hidden within a dropdown. This is a good use case for the InteractionObserver API (ref docs), it allows async observation of visibility of an element w.r.t. the viewport. Using this observer allows us to call the handleInput when it’s already mounted

  • when my textarea turns from disabled to enabled, it for some reason it goes back to being number of rows = 1 and the handleInput doesn’t run anymore. The solution to this was to use a MutationObserver (ref api docs) which can watch for chanes to the dom tree. Specifically, we watch for changes to this element’s (textarea) disabled attribute, then call the handleInput.

image

Basically, the button group was just hidden, but it would block the
buttons beneath it, even though it was invisible. This prevented button
clicks if there were buttons that would get "hidden" beneath.

The fix was to ony render button group if the control panel is open.
@@ -27,6 +27,7 @@ defmodule VyasaWeb.ControlPanel do
<!-- SVG Icon Button -->
<.control_panel_mode_indicator mode={@mode} myself={@myself} session_active?={@session.name} />
<div
:if={@show_control_panel?}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how the bug would present itself

image

see this commit message for more info

I quite like the extracted function component here
things done:
1. made sure one of the containers for the generic modal wrapper has w-full
2. used font-light for all the action buttons' text
3. add some nice styling to represent the soon-to-be-created sheaf -- a
visual container
4. the buttons for the modal have been updated to be icon-less. This
should be alright, it fits the space well.

NOTE: the "start new thread" vs "reply" needs different event handlers,
will be in the next commit
Also added the file for the js hook that somehow hasn't been committed yet
There's some bugs introduced, have to change them
CLOSED: [2024-12-08 Sun 15:00]
So far on discuss mode, one could only do reply_to but not create new thread as a one-click action.
This floating action button should just intuitively tell people to "hey add new thread"
@rtshkmr rtshkmr force-pushed the feat/discussion/ui-iteration-improve-sheaf-display branch from 60cad30 to 0e1cd6c Compare December 8, 2024 07:00
this doesn't have good devex ergonomics.
Ideally just like how there's a put_flash(), there should be some
mechanism for the  modal hiding and stuff.

The requirement would be something like:
- from within a handle_event() which is on the server side, I should be
able to define a %JS{} and then dispatch that to the client side to evaluate.
Comment on lines 492 to 495
def make_reply(
%Sheaf{
parent_id: nil
} = reply,
attrs
%Sheaf{} = reply,
%{parent: nil} = attrs
) do
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BUG] Odd bug again in sheaf creation @ make_reply()

likely need you to add verify this on the changeset side @ks0m1c

Not sure if the fix is actually something to be done at the changeset
level. In brief, the SOT should be the replyto context that
is in the socket state. So even if a sheaf may have a
currently-associated parent sheaf, if the socket says that the
parentid or parent is null then it's null (and a root sheaf).

See lib/vyasaweb/components/contexts/read.ex

def handle_event(
      "sheaf::publish",
      %{
        "body" => body
      } = _params,
      %Socket{
        assigns: %{
          draft_reflector: %Sheaf{} = draft_sheaf,
          draft_reflector_ui: %SheafUiState{
            marks_ui: %MarksUiState{} = _ui_state
          },
          reply_to: reply_to_sheaf,
          session: %VyasaWeb.Session{
            name: username,
            sangh: %Vyasa.Sangh.Session{
              id: sangh_id
            }
          }
        }
      } = socket
    ) do
  IO.inspect(%{body: body},
    label: "SHEAF CREATION without parent"
  )

  payload_precursor = %{
    body: body,
    traits: ["published"],
    signature: username,
    inserted_at: Utils.Time.get_utc_now()
  }

  # FIXME: the socket state should be the SOT. So even if the draft sheaf has an associated parent,
  # when my reply_to_sheaf is nil, then the parent should be set to nil if it gets published.
  # currently this is NOT happening, i.e. the parent assoc is still there, so the sheaf gets published but as a child of whatever the previous state was.
  reply_payload =
    case reply_to_sheaf do
      %Sheaf{} ->
        payload_precursor |> Map.put(:parent, reply_to_sheaf)

      nil ->
        # so the socket's reply_to will take priority, even if the draft sheaf may already an associated parent, this will take priority.
        payload_precursor |> Map.put(:parent, nil)
    end

  draft_sheaf
  |> Vyasa.Sangh.make_reply(reply_payload)

  {:noreply,
   socket
   |> ui_toggle_show_sheaf_modal?()
   |> register_sheaf(Sheaf.draft!(sangh_id))
   |> assign(reply_to: nil)
   |> cascade_stream_change()}
end

@rtshkmr
Copy link
Member Author

rtshkmr commented Dec 9, 2024

@ks0m1c this is just a FYI, I'm going to add this in to standardise how the modal behaviour works.

✅ this works well

Possible solution to the Problem := need to be able to properly dispatch JS commands from server-side handle-events to client-side

Problem: need to be able to properly dispatch JS commands from server-side handle-events to client-side

possible solution: js-exec event listener @ app.js to receive JS command from the server

The following is an outline, I've not tried to add in this fix but high
chance it's the correct way to do it, a la LiveBeats.

fly.io blogpost
that demonstrates how a js-exec event listener can be added to the
app.js to dispatch client action from js side

okay, so the execJS fucntion, which we can see in
livebeats

finally makes sense, and how livebeats also uses a js event
listener

How LiveBeats does it

First, livebeats has a set of focus-related helper functions:

let Focus = {
  focusMain() {
    let target =
      document.querySelector("main h1") || document.querySelector("main");
    if (target) {
      let origTabIndex = target.tabIndex;
      target.tabIndex = -1;
      target.focus();
      target.tabIndex = origTabIndex;
    }
  },
  // Subject to the W3C Software License at https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
  isFocusable(el) {
    if (
      el.tabIndex > 0 ||
      (el.tabIndex === 0 && el.getAttribute("tabIndex") !== null)
    ) {
      return true;
    }
    if (el.disabled) {
      return false;
    }

    switch (el.nodeName) {
      case "A":
        return !!el.href && el.rel !== "ignore";
      case "INPUT":
        return el.type != "hidden" && el.type !== "file";
      case "BUTTON":
      case "SELECT":
      case "TEXTAREA":
        return true;
      default:
        return false;
    }
  },
  // Subject to the W3C Software License at https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
  attemptFocus(el) {
    if (!el) {
      return;
    }
    if (!this.isFocusable(el)) {
      return false;
    }
    try {
      el.focus();
    } catch (e) {}

    return document.activeElement === el;
  },
  // Subject to the W3C Software License at https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
  focusFirstDescendant(el) {
    for (let i = 0; i < el.childNodes.length; i++) {
      let child = el.childNodes[i];
      if (this.attemptFocus(child) || this.focusFirstDescendant(child)) {
        return true;
      }
    }
    return false;
  },
  // Subject to the W3C Software License at https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
  focusLastDescendant(element) {
    for (let i = element.childNodes.length - 1; i >= 0; i--) {
      let child = element.childNodes[i];
      if (this.attemptFocus(child) || this.focusLastDescendant(child)) {
        return true;
      }
    }
    return false;
  },
};

These are used when defining event listeners at the app.js (root js)
level.

in livebeats, there's the following statements within app.js:

let routeUpdated = () => {
  Focus.focusMain()
}

// Accessible routing
window.addEventListener("phx:page-loading-stop", routeUpdated)

window.addEventListener("phx:js:exec", e => liveSocket.execJS(liveSocket.main.el, e.detail.cmd))
window.addEventListener("js:call", e => e.target[e.detail.call](...e.detail.args))
window.addEventListener("js:focus", e => {
  let parent = document.querySelector(e.detail.parent)
  if(parent && isVisible(parent)){ e.target.focus() }
})
window.addEventListener("js:focus-closest", e => {
  let el = e.target
  let sibling = el.nextElementSibling
  while(sibling){
    if(isVisible(sibling) && Focus.attemptFocus(sibling)){ return }
    sibling = sibling.nextElementSibling
  }
  sibling = el.previousElementSibling
  while(sibling){
    if(isVisible(sibling) && Focus.attemptFocus(sibling)){ return }
    sibling = sibling.previousElementSibling
  }
  Focus.attemptFocus(el.parent) || Focus.focusMain()
})
window.addEventListener("phx:remove-el", e => document.getElementById(e.detail.id).remove())

// connect if there are any LiveViews on the page
liveSocket.getSocket().onOpen(() => execJS("#connection-status", "js-hide"))
liveSocket.getSocket().onError(() => execJS("#connection-status", "js-show"))
Initial / Previous version of describing the problem

initial problem description

here's the problem description: i need a deeper underestanding on how
phx-click events are handled by liveview

here's my context

  1. i have built a custom wrapper on the default
    modalwrapper() core component that liveview provides

  2. i also have a separate, declarative struct-based definition of UI
    state, in some situations the modal rendering uses some of the
    boolean flags within such defined UI structs

  3. within the core components, there's some JS commands, they look
    something like this:

       ## JS Commands
    
    def show(js \\ %JS{}, selector) do
      JS.show(js,
        to: selector,
        transition:
          {"transition-all transform ease-out duration-300",
           "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
           "opacity-100 translate-y-0 sm:scale-100"}
      )
    end
    
    def hide(js \\ %JS{}, selector) do
      JS.hide(js,
        to: selector,
        time: 200,
        transition:
          {"transition-all transform ease-in duration-200",
           "opacity-100 translate-y-0 sm:scale-100",
           "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
      )
    end
    
    def show_modal(js \\ %JS{}, id) when is_binary(id) do
      js
      |> JS.show(to: "##{id}")
      |> JS.show(
        to: "##{id}-bg",
        transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"}
      )
      |> show("##{id}-container")
      |> JS.add_class("overflow-hidden", to: "body")
      |> JS.focus_first(to: "##{id}-content")
    end
    
    def hide_modal(js \\ %JS{}, id) do
      js
      |> JS.hide(
        to: "##{id}-bg",
        transition: {"transition-all transform ease-in duration-200", "opacity-100", "opacity-0"}
      )
      |> hide("##{id}-container")
      |> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"})
      |> JS.remove_class("overflow-hidden", to: "body")
      |> JS.pop_focus()
    end
  4. Here's an example of how the JS commands are used, note how the
    hidemodal() function is being called

      ~H"""
      <div id="sheaf-creator-container" class="flex flex-col">
        <.form
          for={%{}}
          phx-submit={CoreComponents.hide_modal(JS.push("sheaf::publish"), @id)}
          phx-target={@event_target}
          class="flex items-center"
        >
          <div class="flex flex-col w-full">
            <div class="shadow-sm border-1 border-l-2  rounded-lg border-brand">
              <textarea
                name="body"
                id={"sheaf-creator-form-body-textarea-" <> @id}
                phx-hook="TextareaFocus"
                phx-hook="TextareaAutoResize"
                class="w-full flex-grow focus:outline-none bg-brandExtraLight text-sm placeholder-gray-400 placeholder:font-light
      resize-vertical overflow-auto min-h-[2.5rem] max-h-[8rem] p-2 pt-3 border-tl-4 border-gray-300 rounded-tl-lg rounded-tr-lg transition-shadow duration-200 focus:border-brand focus:ring-0"
                placeholder={@textarea_placeholder}
              />
    
              <div class="flex justify-between p-2 pt-0 space-x-2 whitespace-nowrap">
                <div class="w-full text-sm">
                  <label
                    for={"sheaf-creator-form-signature-textarea-" <> @id}
                    class="italic font-light text-gray-600 whitespace-nowrap"
                  >
                    Signed by: @
                  </label>
                  <input
                    type="text"
                    name="signature"
                    id={"sheaf-creator-form-signature-textarea-" <> @id}
                    value={if @session, do: @session.name, else: ""}
                    class="font-medium underline p-0 flex-grow focus:outline-none bg-transparent text-sm text-light placeholder-gray-600 border-0 border-b-1 transition-shadow duration-200"
                    placeholder={if @session, do: "Session name", else: "Enter your signature..."}
                    disabled={not is_nil(@session) and @session.name}
                  />
                </div>
              </div>
              <.collapsible_marks_display
                id={"nested-" <> @id}
                myself={nil}
                marks_target={@event_target}
                sheaf={@draft_sheaf}
                sheaf_ui={@draft_sheaf_ui}
                container_class="rounded-b-lg border-gray-100"
              />
            </div>
            <!-- start -->
            <div class="flex justify-around space-x-2 pt-4">
              <button
                type="button"
                phx-click={CoreComponents.hide_modal(@on_cancel_callback, @id)}
                class="whitespace-nowrap text-xs md:text-sm font-semibold flex items-center justify-center p-2 rounded-full border border-gray-400 text-gray-800 bg-transparent hover:bg-gray-100 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-brand focus:ring-opacity-50"
                phx-target={@event_target}
              >
                Go back
              </button>
              <button
                type="submit"
                class="whitespace-nowrap text-xs md:text-sm font-semibold flex items-center justify-center p-2 rounded-full border border-brand text-white bg-brand hover:bg-brand-light transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-brand focus:ring-opacity-50 w-auto"
                phx-target={@event_target}
                phx-value-is_new_thread={false}
              >
                <%= @action_button_text %>
              </button>
            </div>
            <!-- end -->
          </div>
        </.form>
      </div>
      """
    end

Okay now that i've given you the context, here's what I need:

  1. currently if i want to compose the JS commands with the phx-click
    events, i can only do it from within the heex templates, like in
    point 4 i defined earlier. What i really need is to be able to
    dispatch these JS commands from the server side, when I'm handling
    the it via the handle events. an example of such a handle event is
    this

    ,#+beginsrc elixir

    def handleevent( "navigate::seediscussion",
    _, socket ) do IO.inspect("CHECKPOINT: the discuss context is
    reached") send(self(), "ui::toggleshowsheafmodal?")

    {:noreply, socket} end

This is a big devex upgrade

Read the detailed notes on this:

Table of Contents
─────────────────

1. [ ] Need to solve [ Problem: need to be able to properly dispatch JS commands from server-side handle-events to client-side-only ] in order to be able to finish wiring these things up, especially for the “navigate::show_discussion” event
.. 1. possible solution: js-exec event listener @ app.js to receive JS command from the server
..... 1. How LiveBeats does it
.. 2. initial problem description

1 [ ] Need to solve [ Problem: need to be able to properly dispatch JS commands from server-side handle-events to client-side-only ] in order to be able to finish wiring these things up, especially for the “navigate::show_discussion” event
═══════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════

1.1 possible solution: js-exec event listener @ app.js to receive JS command from the server
────────────────────────────────────────────────────────────────────────────────────────────

  The following is an outline, I’ve not tried to add in this fix but
  high chance it’s the correct way to do it, a la LiveBeats.

  [fly.io blogpost] that demonstrates how a js-exec event listener can
  be added to the app.js to dispatch client action from js side

  okay, so [the execJS fucntion, which we can see in livebeats] finally
  makes sense, and how [livebeats also uses a js event listener]

[fly.io blogpost] <https://fly.io/phoenix-files/server-triggered-js/>

[the execJS fucntion, which we can see in livebeats]
<https://github.com/fly-apps/live_beats/blob/ac9780472e7019af274110a1cf71250a8d40c986/assets/js/app.js#L12>

[livebeats also uses a js event listener]
<https://github.com/fly-apps/live_beats/blob/ac9780472e7019af274110a1cf71250a8d40c986/assets/js/app.js#L307>

1.1.1 How LiveBeats does it
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌

  First, livebeats has a set of focus-related helper functions:

  ┌────
  │ let Focus = {
  │   focusMain() {
  │     let target =
  │       document.querySelector("main h1") || document.querySelector("main");
  │     if (target) {
  │       let origTabIndex = target.tabIndex;
  │       target.tabIndex = -1;
  │       target.focus();
  │       target.tabIndex = origTabIndex;
  │     }
  │   },
  │   // Subject to the W3C Software License at https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
  │   isFocusable(el) {
  │     if (
  │       el.tabIndex > 0 ||
  │       (el.tabIndex === 0 && el.getAttribute("tabIndex") !== null)
  │     ) {
  │       return true;
  │     }
  │     if (el.disabled) {
  │       return false;
  │     }
  │
  │     switch (el.nodeName) {
  │       case "A":
  │         return !!el.href && el.rel !== "ignore";
  │       case "INPUT":
  │         return el.type != "hidden" && el.type !== "file";
  │       case "BUTTON":
  │       case "SELECT":
  │       case "TEXTAREA":
  │         return true;
  │       default:
  │         return false;
  │     }
  │   },
  │   // Subject to the W3C Software License at https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
  │   attemptFocus(el) {
  │     if (!el) {
  │       return;
  │     }
  │     if (!this.isFocusable(el)) {
  │       return false;
  │     }
  │     try {
  │       el.focus();
  │     } catch (e) {}
  │
  │     return document.activeElement === el;
  │   },
  │   // Subject to the W3C Software License at https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
  │   focusFirstDescendant(el) {
  │     for (let i = 0; i < el.childNodes.length; i++) {
  │       let child = el.childNodes[i];
  │       if (this.attemptFocus(child) || this.focusFirstDescendant(child)) {
  │         return true;
  │       }
  │     }
  │     return false;
  │   },
  │   // Subject to the W3C Software License at https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
  │   focusLastDescendant(element) {
  │     for (let i = element.childNodes.length - 1; i >= 0; i--) {
  │       let child = element.childNodes[i];
  │       if (this.attemptFocus(child) || this.focusLastDescendant(child)) {
  │         return true;
  │       }
  │     }
  │     return false;
  │   },
  │ };
  └────

  These are used when defining event listeners at the app.js (root js)
  level.

  in livebeats, there’s the following statements within app.js:

  ┌────
  │ let routeUpdated = () => {
  │   Focus.focusMain()
  │ }
  │
  │ // Accessible routing
  │ window.addEventListener("phx:page-loading-stop", routeUpdated)
  │
  │ window.addEventListener("phx:js:exec", e => liveSocket.execJS(liveSocket.main.el, e.detail.cmd))
  │ window.addEventListener("js:call", e => e.target[e.detail.call](...e.detail.args))
  │ window.addEventListener("js:focus", e => {
  │   let parent = document.querySelector(e.detail.parent)
  │   if(parent && isVisible(parent)){ e.target.focus() }
  │ })
  │ window.addEventListener("js:focus-closest", e => {
  │   let el = e.target
  │   let sibling = el.nextElementSibling
  │   while(sibling){
  │     if(isVisible(sibling) && Focus.attemptFocus(sibling)){ return }
  │     sibling = sibling.nextElementSibling
  │   }
  │   sibling = el.previousElementSibling
  │   while(sibling){
  │     if(isVisible(sibling) && Focus.attemptFocus(sibling)){ return }
  │     sibling = sibling.previousElementSibling
  │   }
  │   Focus.attemptFocus(el.parent) || Focus.focusMain()
  │ })
  │ window.addEventListener("phx:remove-el", e => document.getElementById(e.detail.id).remove())
  │
  │ // connect if there are any LiveViews on the page
  │ liveSocket.getSocket().onOpen(() => execJS("#connection-status", "js-hide"))
  │ liveSocket.getSocket().onError(() => execJS("#connection-status", "js-show"))
  └────

1.2 initial problem description
───────────────────────────────

  here’s the problem description: i need a deeper underestanding on how
  phx-click events are handled by liveview

  here’s my context

  1. i have built a custom wrapper on the default modal_wrapper() core
     component that liveview provides
  2. i also have a separate, declarative struct-based definition of UI
     state, in some situations the modal rendering uses some of the
     boolean flags within such defined UI structs
  3. within the core components, there’s some JS commands, they look
     something like this:
     ┌────
     │
     │      ## JS Commands
     │
     │   def show(js \\ %JS{}, selector) do
     │     JS.show(js,
     │       to: selector,
     │       transition:
     │         {"transition-all transform ease-out duration-300",
     │          "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
     │          "opacity-100 translate-y-0 sm:scale-100"}
     │     )
     │   end
     │
     │   def hide(js \\ %JS{}, selector) do
     │     JS.hide(js,
     │       to: selector,
     │       time: 200,
     │       transition:
     │         {"transition-all transform ease-in duration-200",
     │          "opacity-100 translate-y-0 sm:scale-100",
     │          "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
     │     )
     │   end
     │
     │   def show_modal(js \\ %JS{}, id) when is_binary(id) do
     │     js
     │     |> JS.show(to: "##{id}")
     │     |> JS.show(
     │       to: "##{id}-bg",
     │       transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"}
     │     )
     │     |> show("##{id}-container")
     │     |> JS.add_class("overflow-hidden", to: "body")
     │     |> JS.focus_first(to: "##{id}-content")
     │   end
     │
     │   def hide_modal(js \\ %JS{}, id) do
     │     js
     │     |> JS.hide(
     │       to: "##{id}-bg",
     │       transition: {"transition-all transform ease-in duration-200", "opacity-100", "opacity-0"}
     │     )
     │     |> hide("##{id}-container")
     │     |> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"})
     │     |> JS.remove_class("overflow-hidden", to: "body")
     │     |> JS.pop_focus()
     │   end
     └────

  4. Here’s an example of how the JS commands are used, note how the
     hide_modal() function is being called

     ┌────
     │
     │
     │     ~H"""
     │     <div id="sheaf-creator-container" class="flex flex-col">
     │       <.form
     │         for={%{}}
     │         phx-submit={CoreComponents.hide_modal(JS.push("sheaf::publish"), @id)}
     │         phx-target={@event_target}
     │         class="flex items-center"
     │       >
     │         <div class="flex flex-col w-full">
     │           <div class="shadow-sm border-1 border-l-2  rounded-lg border-brand">
     │             <textarea
     │               name="body"
     │               id={"sheaf-creator-form-body-textarea-" <> @id}
     │               phx-hook="TextareaFocus"
     │               phx-hook="TextareaAutoResize"
     │               class="w-full flex-grow focus:outline-none bg-brandExtraLight text-sm placeholder-gray-400 placeholder:font-light
     │     resize-vertical overflow-auto min-h-[2.5rem] max-h-[8rem] p-2 pt-3 border-tl-4 border-gray-300 rounded-tl-lg rounded-tr-lg transition-shadow duration-200 focus:border-brand focus:ring-0"
     │               placeholder={@textarea_placeholder}
     │             />
     │
     │             <div class="flex justify-between p-2 pt-0 space-x-2 whitespace-nowrap">
     │               <div class="w-full text-sm">
     │                 <label
     │                   for={"sheaf-creator-form-signature-textarea-" <> @id}
     │                   class="italic font-light text-gray-600 whitespace-nowrap"
     │                 >
     │                   Signed by: @
     │                 </label>
     │                 <input
     │                   type="text"
     │                   name="signature"
     │                   id={"sheaf-creator-form-signature-textarea-" <> @id}
     │                   value={if @session, do: @session.name, else: ""}
     │                   class="font-medium underline p-0 flex-grow focus:outline-none bg-transparent text-sm text-light placeholder-gray-600 border-0 border-b-1 transition-shadow duration-200"
     │                   placeholder={if @session, do: "Session name", else: "Enter your signature..."}
     │                   disabled={not is_nil(@session) and @session.name}
     │                 />
     │               </div>
     │             </div>
     │             <.collapsible_marks_display
     │               id={"nested-" <> @id}
     │               myself={nil}
     │               marks_target={@event_target}
     │               sheaf={@draft_sheaf}
     │               sheaf_ui={@draft_sheaf_ui}
     │               container_class="rounded-b-lg border-gray-100"
     │             />
     │           </div>
     │           <!-- start -->
     │           <div class="flex justify-around space-x-2 pt-4">
     │             <button
     │               type="button"
     │               phx-click={CoreComponents.hide_modal(@on_cancel_callback, @id)}
     │               class="whitespace-nowrap text-xs md:text-sm font-semibold flex items-center justify-center p-2 rounded-full border border-gray-400 text-gray-800 bg-transparent hover:bg-gray-100 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-brand focus:ring-opacity-50"
     │               phx-target={@event_target}
     │             >
     │               Go back
     │             </button>
     │             <button
     │               type="submit"
     │               class="whitespace-nowrap text-xs md:text-sm font-semibold flex items-center justify-center p-2 rounded-full border border-brand text-white bg-brand hover:bg-brand-light transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-brand focus:ring-opacity-50 w-auto"
     │               phx-target={@event_target}
     │               phx-value-is_new_thread={false}
     │             >
     │               <%= @action_button_text %>
     │             </button>
     │           </div>
     │           <!-- end -->
     │         </div>
     │       </.form>
     │     </div>
     │     """
     │   end
     │
     └────

  Okay now that i’ve given you the context, here’s what I need:
  1. currently if i want to compose the JS commands with the phx-click
     events, i can only do it from within the heex templates, like in
     point 4 i defined earlier. What i really need is to be able to
     dispatch these JS commands from the server side, when I’m handling
     the it via the handle events. an example of such a handle event is
     this

     ,#+begin_src elixir

     def handle_event( “navigate::see_discussion”, _, socket ) do
           IO.inspect(“CHECKPOINT: the discuss context is reached”)
           send(self(), “ui::toggle_show_sheaf_modal?”)

       {:noreply, socket} end #+end_src
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant