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

Dialog with input element does not behave correctly #200

Open
patwid opened this issue Oct 27, 2024 · 1 comment
Open

Dialog with input element does not behave correctly #200

patwid opened this issue Oct 27, 2024 · 1 comment

Comments

@patwid
Copy link
Contributor

patwid commented Oct 27, 2024

The first example does not work properly. On every input event, the dialog is closed.

In the second example, updating the value via ffi instead provides a workaround, so that the dialog with the input element does work properly (i.e. it is neither closed upon re-rendering nor does dragging the input slider "get stuck/interrupted" or similar).

The third example does work as well, it does not use a dialog at all.

First example (not working):
// foo.gleam

import gleam/int
import lustre
import lustre/attribute
import lustre/effect.{type Effect}
import lustre/element.{type Element}
import lustre/element/html
import lustre/event

pub fn main() {
  let app = lustre.application(init, update, view)
  let assert Ok(_) = lustre.start(app, "#app", Nil)

  Nil
}

type Model {
  Model(count: Int, dialog: Bool)
}

fn init(_: flags) -> #(Model, Effect(Msg)) {
  #(Model(count: 0, dialog: False), init_dialog())
}

fn init_dialog() -> Effect(Msg) {
  effect.from(fn(_) { do_init_dialog() })
}

@external(javascript, "./foo_ffi.mjs", "init_dialog")
fn do_init_dialog() -> Nil

type Msg {
  UserOpenedDialog
  UserClosedDialog
  UserChangedCount(Int)
}

fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
  case msg {
    UserOpenedDialog -> #(Model(..model, dialog: True), effect.none())
    UserClosedDialog -> #(Model(..model, dialog: False), effect.none())
    UserChangedCount(count) -> #(Model(..model, count:), effect.none())
  }
}

fn view(model: Model) -> Element(Msg) {
  let count = int.to_string(model.count)

  html.div([], [
    html.input([
      attribute.type_("number"),
      attribute.value(count),
      attribute.attribute("readonly", ""),
    ]),
    html.button([event.on_click(UserOpenedDialog)], [html.text("Open Dialog")]),
    html.dialog(
      [
        attribute.property("modalopen", model.dialog),
        event.on_click(UserClosedDialog),
      ],
      [
        html.label(
          [
            event.on("click", fn(event) {
              event.stop_propagation(event)
              Error([])
            }),
          ],
          [
            html.input([
              attribute.type_("range"),
              event.on_input(fn(value) {
                let assert Ok(value) = int.parse(value)
                UserChangedCount(value)
              }),
            ]),
          ],
        ),
      ],
    ),
  ])
}
// foo_ffi.mjs

export function init_dialog() {
  Object.defineProperty(HTMLDialogElement.prototype, "modalopen", {
    get() {
      return this.open
    },

    set(value) {
      if (value) {
        requestAnimationFrame(() => this.show())
      } else {
        this.close()
      }
    }
  })
}
Second example (working, workaround ffi):
// foo.gleam

import gleam/dynamic
import gleam/int
import lustre
import lustre/attribute
import lustre/effect.{type Effect}
import lustre/element.{type Element}
import lustre/element/html
import lustre/event

pub fn main() {
  let app = lustre.application(init, update, view)
  let assert Ok(_) = lustre.start(app, "#app", Nil)

  Nil
}

type Model {
  Model(count: Int, dialog: Bool)
}

fn init(_: flags) -> #(Model, Effect(Msg)) {
  #(Model(count: 0, dialog: False), init_dialog())
}

fn init_dialog() -> Effect(Msg) {
  effect.from(fn(_) { do_init_dialog() })
}

@external(javascript, "./foo_ffi.mjs", "init_dialog")
fn do_init_dialog() -> Nil

type Msg {
  UserOpenedDialog
  UserClosedDialog(Int)
  UserChangedCount(Int)
}

fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
  case msg {
    UserOpenedDialog -> #(Model(..model, dialog: True), effect.none())
    UserClosedDialog(count) -> #(
      Model(..model, count:, dialog: False),
      effect.none(),
    )
    UserChangedCount(value) -> #(model, update_count(value))
  }
}

fn update_count(value: Int) -> Effect(Msg) {
  effect.from(fn(_) { do_update_count(value) })
}

@external(javascript, "./foo_ffi.mjs", "update_count")
fn do_update_count(value: Int) -> Nil

fn view(model: Model) -> Element(Msg) {
  let count = int.to_string(model.count)

  html.div([], [
    html.input([
      attribute.id("count"),
      attribute.type_("number"),
      attribute.value(count),
      attribute.attribute("readonly", ""),
    ]),
    html.button([event.on_click(UserOpenedDialog)], [html.text("Open Dialog")]),
    html.dialog(
      [
        attribute.property("modalopen", model.dialog),
        event.on("click", fn(event) {
          let assert Ok(value) =
            dynamic.field(
              "target",
              dynamic.field(
                "firstChild",
                dynamic.field(
                  "lastChild",
                  dynamic.field("value", dynamic.string),
                ),
              ),
            )(event)

          let assert Ok(count) = int.parse(value)
          Ok(UserClosedDialog(count))
        }),
      ],
      [
        html.label(
          [
            event.on("click", fn(event) {
              event.stop_propagation(event)
              Error([])
            }),
          ],
          [
            html.input([
              attribute.type_("range"),
              attribute.value(count),
              event.on_input(fn(value) {
                let assert Ok(value) = int.parse(value)
                UserChangedCount(value)
              }),
            ]),
          ],
        ),
      ],
    ),
  ])
}
// foo_ffi.mjs

export function init_dialog() {
  Object.defineProperty(HTMLDialogElement.prototype, "modalopen", {
    get() {
      return this.open
    },

    set(value) {
      if (value) {
        requestAnimationFrame(() => this.showModal())
      } else {
        this.close()
      }
    }
  })
}

export function update_count(value) {
  requestAnimationFrame(() => {
    document.getElementById("count").value = value
  })
}
Third example (working, no dialog):
// foo.gleam

import gleam/int
import lustre
import lustre/attribute
import lustre/effect.{type Effect}
import lustre/element.{type Element}
import lustre/element/html
import lustre/event

pub fn main() {
  let app = lustre.simple(init, update, view)
  let assert Ok(_) = lustre.start(app, "#app", Nil)

  Nil
}

type Model =
  Int

fn init(_: flags) -> Model {
  0
}

type Msg {
  UserChangedCount(Int)
}

fn update(model: Model, msg: Msg) -> Model {
  case msg {
    UserChangedCount(count) -> count
  }
}

fn view(model: Model) -> Element(Msg) {
  let count = int.to_string(model)

  html.div([], [
    html.input([
      attribute.type_("number"),
      attribute.value(count),
      attribute.attribute("readonly", ""),
    ]),
    html.label([], [
      html.input([
        attribute.type_("range"),
        event.on_input(fn(value) {
          let assert Ok(value) = int.parse(value)
          UserChangedCount(value)
        }),
      ]),
    ]),
  ])
}

Please let me know, if I can provide any further information.

@jquesada2016
Copy link

In case this helps, here's the code I use for a dialog. In short, I make it a component.

// /src/app/element/dialog.gleam

import gleam/dict.{type Dict}
import gleam/dynamic.{type DecodeErrors, type Decoder, type Dynamic}
import gleam/float
import gleam/javascript/promise
import gleam/json
import gleam/option.{type Option, None, Some}
import gleam/result
import lustre
import lustre/attribute.{type Attribute} as attr
import lustre/effect.{type Effect}
import lustre/element.{type Element}
import lustre/element/html
import lustre/event

const dialog_element_name = "quorvz-dialog"

const close_event_name = "close"

pub fn register() -> Result(Nil, lustre.Error) {
  let dialog = lustre.component(init, update, view, on_attr_change())

  lustre.register(dialog, dialog_element_name)
}

pub opaque type DialogProps(a) {
  Props(class: String, open: Bool, on_close: Option(a))
}

pub fn props() -> DialogProps(a) {
  Props(class: "", open: False, on_close: None)
}

pub fn with_class(props: DialogProps(a), class: String) -> DialogProps(a) {
  Props(..props, class:)
}

pub fn with_open(props: DialogProps(a), open: Bool) -> DialogProps(a) {
  Props(..props, open:)
}

pub fn with_on_close(props: DialogProps(a), on_close: a) {
  Props(..props, on_close: Some(on_close))
}

pub fn dialog(props: DialogProps(a), children: List(Element(a))) -> Element(a) {
  let Props(class:, open:, on_close:) = props

  let open = case open {
    True -> "true"
    False -> "false"
  }

  let on_close = case on_close {
    None -> attr.none()
    Some(on_close) -> on_close_handler(on_close)
  }

  element.element(
    dialog_element_name,
    [attr.class(class), attr.attribute("open", open), on_close],
    children,
  )
}

fn on_close_handler(on_close) {
  event.on(close_event_name, fn(_) { Ok(on_close) })
}

pub opaque type Model {
  Model(id: String, class: String)
}

pub opaque type Message {
  ClassAttributeChanged(String)
  OpenAttributeChanged(Bool)
  UserTriggeredEscapeOnDialogElement
}

fn init(_: Nil) -> #(Model, Effect(Message)) {
  let id = float.random() |> float.to_string()

  #(Model(id:, class: ""), effect.none())
}

fn on_attr_change() -> Dict(String, Decoder(Message)) {
  dict.from_list([#("class", class_attr_decoder), #("open", open_attr_decoder)])
}

fn class_attr_decoder(value: Dynamic) -> Result(Message, DecodeErrors) {
  use class <- result.try(dynamic.string(value))

  Ok(ClassAttributeChanged(class))
}

fn open_attr_decoder(value: Dynamic) -> Result(Message, DecodeErrors) {
  use open <- result.try(dynamic.string(value))

  let open = case open {
    "true" -> True
    _ -> False
  }

  Ok(OpenAttributeChanged(open))
}

fn update(model: Model, msg: Message) -> #(Model, Effect(Message)) {
  case msg {
    ClassAttributeChanged(class) -> #(Model(..model, class:), effect.none())
    UserTriggeredEscapeOnDialogElement -> #(
      model,
      event.emit(close_event_name, json.null()),
    )
    OpenAttributeChanged(open) -> #(
      model,
      set_dialog_open_state(model.id, open),
    )
  }
}

fn set_dialog_open_state(id: String, open: Bool) -> Effect(Message) {
  use _ <- effect.from

  // We need to wait because the open attribute change notification comes in before
  // lustre has had a chance to render the DOM
  let _ = {
    use _ <- promise.await(promise.wait(0))

    promise.resolve(do_set_dialog_open_state(dialog_element_name, id:, open:))
  }

  Nil
}

@external(javascript, "../../app.ffi.mjs", "setDialogOpenState")
fn do_set_dialog_open_state(
  element_name element_name: String,
  id id: String,
  open open: Bool,
) -> Nil

fn view(model: Model) -> Element(Message) {
  let Model(id:, class:) = model

  html.dialog([attr.id(id), attr.class(class), on_escape_keydown()], [
    html.slot([]),
  ])
}

fn on_escape_keydown() {
  event.on("keydown", fn(e) {
    use key <- result.try(dynamic.field("key", dynamic.string)(e))

    case key {
      "Escape" -> {
        event.prevent_default(e)
        Ok(UserTriggeredEscapeOnDialogElement)
      }
      _ -> Error([dynamic.DecodeError(expected: "", found: "", path: [])])
    }
  })
}
// /src/app.ffi.mjs

export function setDialogOpenState(elementName, id, open) {
  const dialogs = document.querySelectorAll(elementName);

  dialogs.forEach((dialog) => {
    const el = dialog.shadowRoot.getElementById(id);

    if (el instanceof HTMLDialogElement) {
      if (open) {
        el.showModal();
      } else {
        el.close();
      }
    }
  });
}

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

No branches or pull requests

2 participants