Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions gleam.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ redraw = ">= 19.2.2 and < 20.0.0"
redraw_dom = ">= 19.2.2 and < 20.0.0"
lustre = ">= 5.7.1 and < 6.0.0"
mendraw = ">= 2.0.0 and < 3.0.0"
plinth = ">= 0.11.0 and < 1.0.0"
simplifile = ">= 2.6.0 and < 3.0.0"
xmlm = ">= 1.0.1 and < 2.0.0"
tom = ">= 2.1.0 and < 3.0.0"
Expand Down
2 changes: 2 additions & 0 deletions manifest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ packages = [
{ name = "houdini", version = "1.2.1", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "6F8AC2F12974567FB744BEA66AC93CEB76AAEA19AD28564623F76CDA9BC26A85" },
{ name = "lustre", version = "5.7.1", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "663A2D1A3458914CA537A42AC07E601DD3FD85415EBDAF4717F2DFF6E86F90F9" },
{ name = "mendraw", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "redraw", "redraw_dom"], otp_app = "mendraw", source = "hex", outer_checksum = "BB913054A5FEDC872AAB114FFADDFB791DACFE605B3DCEEAD3620F11BB59D090" },
{ name = "plinth", version = "0.11.0", build_tools = ["gleam"], requirements = ["gleam_javascript", "gleam_json", "gleam_stdlib"], otp_app = "plinth", source = "hex", outer_checksum = "D30FDE8FC11EB76538AB498ACFF5399D0AE27993C74D63BE05AFE8CE80C4C839" },
{ name = "redraw", version = "19.2.2", build_tools = ["gleam"], requirements = ["gleam_javascript", "gleam_stdlib"], otp_app = "redraw", source = "hex", outer_checksum = "B8CEEB74E8846CE10B8360B924DAD22441B61D947F9449854164F0686C4B8661" },
{ name = "redraw_dom", version = "19.2.2", build_tools = ["gleam"], requirements = ["gleam_fetch", "gleam_stdlib", "redraw"], otp_app = "redraw_dom", source = "hex", outer_checksum = "80278296AD6E3D4457D6FF6A14FEA3E90696284BEA297BDE10E430FC4CE726B8" },
{ name = "simplifile", version = "2.7.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "A2727627B063E87351934C7F7F008F2D1FDB16F6DE0B8C79F9E46459CFC9C164" },
Expand All @@ -37,6 +38,7 @@ gleam_stdlib = { version = ">= 1.0.3 and < 2.0.0" }
gleeunit = { version = ">= 1.11.0 and < 2.0.0" }
lustre = { version = ">= 5.7.1 and < 6.0.0" }
mendraw = { version = ">= 2.0.0 and < 3.0.0" }
plinth = { version = ">= 0.11.0 and < 1.0.0" }
redraw = { version = ">= 19.2.2 and < 20.0.0" }
redraw_dom = { version = ">= 19.2.2 and < 20.0.0" }
simplifile = { version = ">= 2.6.0 and < 3.0.0" }
Expand Down
44 changes: 26 additions & 18 deletions src/glendix/js/dom.gleam
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
//// Provides typed DOM element operations at the JavaScript FFI boundary.
//// Provides typed DOM operations delegated to Plinth.
////
//// Glendix's established element and rectangle handles stay public through
//// identity adapters. Native click and default scrolling remain minimal FFI
//// gaps because Plinth 0.11.0 does not provide equivalent operations.
////

import gleam/option
import plinth/browser/dom_rect
import plinth/browser/element

/// Represents a DOM element handle.
pub type DomElement
Expand All @@ -11,55 +17,57 @@ pub type DomRect

/// Focuses a DOM element.
pub fn focus(element element: DomElement) -> Nil {
focus_raw(element)
element.focus(to_plinth_element(element))
}

/// Removes focus from a DOM element.
pub fn blur(element element: DomElement) -> Nil {
blur_raw(element)
element.blur(to_plinth_element(element))
}

/// Dispatches the element's native click behavior.
pub fn click(element element: DomElement) -> Nil {
// Plinth 0.11.0 does not bind `HTMLElement.click`, so this remains the
// smallest compatibility FFI required to preserve Glendix's public API.
click_raw(element)
}

/// Scrolls an element into the viewport.
pub fn scroll_into_view(element element: DomElement) -> Nil {
// Plinth 0.11.0 always requests smooth/nearest scrolling. Glendix has
// historically used the platform defaults, so retain this one adapter.
scroll_into_view_raw(element)
}

/// Reads the element's bounding client rectangle.
pub fn get_bounding_client_rect(element element: DomElement) -> DomRect {
get_bounding_client_rect_raw(element)
element.get_bounding_client_rect(to_plinth_element(element))
|> from_plinth_dom_rect
}

/// Finds the first descendant matching a CSS selector.
pub fn query_selector(
in element: DomElement,
matching selector: String,
) -> option.Option(DomElement) {
query_selector_raw(element, selector)
case element.query_selector(to_plinth_element(element), selector) {
Ok(found) -> option.Some(from_plinth_element(found))
Error(Nil) -> option.None
}
}

// -- FFI --
@external(javascript, "./dom_ffi.mjs", "dom_focus")
fn focus_raw(element: DomElement) -> Nil
@external(javascript, "./dom_ffi.mjs", "identity")
fn to_plinth_element(element: DomElement) -> element.Element

@external(javascript, "./dom_ffi.mjs", "identity")
fn from_plinth_element(element: element.Element) -> DomElement

@external(javascript, "./dom_ffi.mjs", "dom_blur")
fn blur_raw(element: DomElement) -> Nil
@external(javascript, "./dom_ffi.mjs", "identity")
fn from_plinth_dom_rect(rectangle: dom_rect.DomRect) -> DomRect

@external(javascript, "./dom_ffi.mjs", "dom_click")
fn click_raw(element: DomElement) -> Nil

@external(javascript, "./dom_ffi.mjs", "dom_scroll_into_view")
fn scroll_into_view_raw(element: DomElement) -> Nil

@external(javascript, "./dom_ffi.mjs", "dom_get_bounding_client_rect")
fn get_bounding_client_rect_raw(element: DomElement) -> DomRect

@external(javascript, "./dom_ffi.mjs", "dom_query_selector")
fn query_selector_raw(
element: DomElement,
selector: String,
) -> option.Option(DomElement)
18 changes: 5 additions & 13 deletions src/glendix/js/dom_ffi.mjs
Original file line number Diff line number Diff line change
@@ -1,20 +1,12 @@
import { Some, None } from "../../../gleam_stdlib/gleam/option.mjs";
export function dom_focus(element) {
element.focus();
}
export function dom_blur(element) {
element.blur();
// Glendix keeps its established public handles while Plinth owns the platform
// operations. JavaScript uses the same object for both typed views.
export function identity(value) {
return value;
}

export function dom_click(element) {
element.click();
}
export function dom_scroll_into_view(element) {
element.scrollIntoView();
}
export function dom_get_bounding_client_rect(element) {
return element.getBoundingClientRect();
}
export function dom_query_selector(element, selector) {
const result = element.querySelector(selector);
return result !== null ? new Some(result) : new None();
}
31 changes: 16 additions & 15 deletions src/glendix/js/timer.gleam
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
//// Provides typed JavaScript timer handles.
//// Provides typed JavaScript timer handles backed by Plinth.
////
//// Glendix's established timer handle stays public through identity adapters
//// so callers retain the original platform handle at the JavaScript boundary.
////

import plinth/javascript/global

/// Represents a JavaScript timer handle.
pub type TimerId
Expand All @@ -9,36 +14,32 @@ pub fn set_timeout(
callback callback: fn() -> Nil,
after milliseconds: Int,
) -> TimerId {
set_timeout_raw(callback, milliseconds)
global.set_timeout(milliseconds, callback)
|> from_plinth_timer
}

/// Cancels a one-shot timer.
pub fn clear_timeout(timer timer: TimerId) -> Nil {
clear_timeout_raw(timer)
global.clear_timeout(to_plinth_timer(timer))
}

/// Starts a repeating timer.
pub fn set_interval(
callback callback: fn() -> Nil,
every milliseconds: Int,
) -> TimerId {
set_interval_raw(callback, milliseconds)
global.set_interval(milliseconds, callback)
|> from_plinth_timer
}

/// Cancels a repeating timer.
pub fn clear_interval(timer timer: TimerId) -> Nil {
clear_interval_raw(timer)
global.clear_interval(to_plinth_timer(timer))
}

// -- FFI --
@external(javascript, "./timer_ffi.mjs", "set_timeout")
fn set_timeout_raw(callback: fn() -> Nil, milliseconds: Int) -> TimerId

@external(javascript, "./timer_ffi.mjs", "clear_timeout")
fn clear_timeout_raw(timer: TimerId) -> Nil

@external(javascript, "./timer_ffi.mjs", "set_interval")
fn set_interval_raw(callback: fn() -> Nil, milliseconds: Int) -> TimerId
@external(javascript, "./timer_ffi.mjs", "identity")
fn from_plinth_timer(timer: global.TimerID) -> TimerId

@external(javascript, "./timer_ffi.mjs", "clear_interval")
fn clear_interval_raw(timer: TimerId) -> Nil
@external(javascript, "./timer_ffi.mjs", "identity")
fn to_plinth_timer(timer: TimerId) -> global.TimerID
15 changes: 4 additions & 11 deletions src/glendix/js/timer_ffi.mjs
Original file line number Diff line number Diff line change
@@ -1,12 +1,5 @@
export function set_timeout(callback, ms) {
return setTimeout(callback, ms);
}
export function clear_timeout(id) {
clearTimeout(id);
}
export function set_interval(callback, ms) {
return setInterval(callback, ms);
}
export function clear_interval(id) {
clearInterval(id);
// Glendix retains its public timer handle while Plinth owns all timer
// operations. JavaScript uses the same platform handle for both typed views.
export function identity(value) {
return value;
}
90 changes: 90 additions & 0 deletions test/glendix/js/dom_test.gleam
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
//// Exercises Glendix DOM operations with deterministic element handles.
////

import gleam/option
import gleeunit/should
import glendix/js/dom

/// Verifies focus delegates to the element's native focus operation.
pub fn focus_invokes_element_method_test() -> Nil {
let element = new_element_fixture()
dom.focus(element)
operation_count(element, "focus") |> should.equal(1)
}

/// Verifies blur delegates to the element's native blur operation.
pub fn blur_invokes_element_method_test() -> Nil {
let element = new_element_fixture()
dom.blur(element)
operation_count(element, "blur") |> should.equal(1)
}

/// Verifies the retained click adapter invokes native click behavior.
pub fn click_invokes_element_method_test() -> Nil {
let element = new_element_fixture()
dom.click(element)
operation_count(element, "click") |> should.equal(1)
}

/// Verifies scrolling retains the native no-options behavior.
pub fn scroll_into_view_uses_platform_defaults_test() -> Nil {
let element = new_element_fixture()
dom.scroll_into_view(element)
operation_count(element, "scroll_into_view") |> should.equal(1)
scroll_argument_count(element) |> should.equal(0)
}

/// Verifies bounding rectangles preserve the platform rectangle values.
pub fn get_bounding_client_rect_returns_element_rectangle_test() -> Nil {
let element = new_element_fixture()
let rectangle = dom.get_bounding_client_rect(element)
rectangle_x(rectangle) |> should.equal(1.0)
rectangle_y(rectangle) |> should.equal(2.0)
rectangle_width(rectangle) |> should.equal(30.0)
rectangle_height(rectangle) |> should.equal(40.0)
}

/// Verifies query selector converts a Plinth hit to `Some`.
pub fn query_selector_hit_returns_some_test() -> Nil {
let element = new_element_fixture()

case dom.query_selector(in: element, matching: ".child") {
option.Some(child) -> is_fixture_child(element, child) |> should.be_true
option.None -> should.fail()
}
}

/// Verifies query selector converts a Plinth miss to `None`.
pub fn query_selector_miss_returns_none_test() -> Nil {
let element = new_element_fixture()

case dom.query_selector(in: element, matching: ".missing") {
option.None -> Nil
option.Some(_) -> should.fail()
}
}

// -- FFI --
@external(javascript, "./dom_test_ffi.mjs", "new_element_fixture")
fn new_element_fixture() -> dom.DomElement

@external(javascript, "./dom_test_ffi.mjs", "operation_count")
fn operation_count(element: dom.DomElement, operation: String) -> Int

@external(javascript, "./dom_test_ffi.mjs", "scroll_argument_count")
fn scroll_argument_count(element: dom.DomElement) -> Int

@external(javascript, "./dom_test_ffi.mjs", "is_fixture_child")
fn is_fixture_child(element: dom.DomElement, candidate: dom.DomElement) -> Bool

@external(javascript, "./dom_test_ffi.mjs", "rectangle_x")
fn rectangle_x(rectangle: dom.DomRect) -> Float

@external(javascript, "./dom_test_ffi.mjs", "rectangle_y")
fn rectangle_y(rectangle: dom.DomRect) -> Float

@external(javascript, "./dom_test_ffi.mjs", "rectangle_width")
fn rectangle_width(rectangle: dom.DomRect) -> Float

@external(javascript, "./dom_test_ffi.mjs", "rectangle_height")
fn rectangle_height(rectangle: dom.DomRect) -> Float
65 changes: 65 additions & 0 deletions test/glendix/js/dom_test_ffi.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
export function new_element_fixture() {
const child = {
operations: { click: 0 },
click() {
this.operations.click += 1;
},
};
return {
child,
operations: {
blur: 0,
click: 0,
focus: 0,
scroll_into_view: 0,
},
scrollArgumentCount: -1,
focus() {
this.operations.focus += 1;
},
blur() {
this.operations.blur += 1;
},
click() {
this.operations.click += 1;
},
scrollIntoView(...arguments_) {
this.operations.scroll_into_view += 1;
this.scrollArgumentCount = arguments_.length;
},
getBoundingClientRect() {
return { x: 1, y: 2, width: 30, height: 40 };
},
querySelector(selector) {
return selector === ".child" ? this.child : null;
},
};
}

export function operation_count(element, operation) {
return element.operations[operation];
}

export function scroll_argument_count(element) {
return element.scrollArgumentCount;
}

export function is_fixture_child(element, candidate) {
return element.child === candidate;
}

export function rectangle_x(rectangle) {
return rectangle.x;
}

export function rectangle_y(rectangle) {
return rectangle.y;
}

export function rectangle_width(rectangle) {
return rectangle.width;
}

export function rectangle_height(rectangle) {
return rectangle.height;
}
Loading
Loading