diff --git a/docs/content/docs/api-surface.mdx b/docs/content/docs/api-surface.mdx index cc80575a..798bc3ec 100644 --- a/docs/content/docs/api-surface.mdx +++ b/docs/content/docs/api-surface.mdx @@ -300,6 +300,35 @@ let element = document->WebAPI.Document.createElement("div") let node = element->WebAPI.Element.asNode ``` +### Classifying DOM values + +Event libraries and browser APIs sometimes return a type that is broader than the value present at +runtime. A React form event target, for example, does not tell ReScript whether the target is an +input, select, or another element. Calling an input-specific API then requires narrowing that value. + +Use `classify` instead of repeating an `instanceof` binding and an unchecked `Obj.magic` cast. The +classifier performs the runtime check and returns the correctly typed value as an `option`. + +```ReScript +let readInputValue = target => + switch target->WebAPI.HTMLInputElement.classify { + | Some(input) => Some(input.value) + | None => None + } +``` + +The classifier parameter is polymorphic, so a library-owned target can be passed directly without an +identity cast to a WebAPI type first. `Element.classify`, `Document.classify`, +`HTMLElement.classify`, `HTMLInputElement.classify`, `HTMLImageElement.classify`, and +`HTMLCanvasElement.classify` follow the same contract. Their matching `isInstanceOf` functions are +available when only a boolean check is needed. + +Classification uses the corresponding constructor from `globalThis`. It returns `None` rather than +raising when that constructor is unavailable in a server or worker environment. Like JavaScript +`instanceof`, it only recognizes values created in the current realm; values from another window or +iframe may not match. Enable the `WebAPI.HTML` feature for HTML-specific classifiers and +`WebAPI.Canvas` for `HTMLCanvasElement.classify`. + ## Visual Viewport `Window.visualViewport` returns a nullable `WebAPI.VisualViewport.t`. diff --git a/docs/content/docs/philosophy.mdx b/docs/content/docs/philosophy.mdx index 2b52fa65..32a0f623 100644 --- a/docs/content/docs/philosophy.mdx +++ b/docs/content/docs/philosophy.mdx @@ -49,4 +49,19 @@ let element: WebAPI.Element.t = document->WebAPI.Document.createElement("div") let node: WebAPI.Node.t = element->WebAPI.Element.asNode ``` -Any other conversions should be treated as unsafe casts and used with caution, because the type system cannot guarantee they are valid at runtime. +Converting in the other direction requires proving the runtime type. Broad DOM values commonly come +from event libraries, selector APIs, or JavaScript bindings that cannot describe the concrete +interface statically. Use the destination module's `classify` function for that downcast instead of +calling `Obj.magic` directly. + +```ReScript +switch target->WebAPI.HTMLInputElement.classify { +| Some(input) => Some(input.value) +| None => None +} +``` + +Classifiers return an `option` because a runtime value may not implement the requested interface. +They also return `None` when the browser constructor is unavailable. The checked cast remains inside +the binding, so application code handles absence explicitly and receives the concrete public +interface type only after a successful check. diff --git a/docs/pages/examples.astro b/docs/pages/examples.astro index 871e59cc..53ec1320 100644 --- a/docs/pages/examples.astro +++ b/docs/pages/examples.astro @@ -19,8 +19,8 @@ const headings = testFiles.map(({ name }) => ({ The best example of usage of these bindings are the tests.
- These typically contain tweaks made to the generated bindings to make them more - ergonomic. + These exercise the public bindings directly. Reusable Web API bindings and conversion + helpers belong in the library rather than in individual examples.

{ testFiles.map(({ source, output, name }) => { diff --git a/src/canvas/CanvasRenderingContext2D.res b/src/canvas/CanvasRenderingContext2D.res index aa649758..0bde4367 100644 --- a/src/canvas/CanvasRenderingContext2D.res +++ b/src/canvas/CanvasRenderingContext2D.res @@ -1,3 +1,28 @@ +/** +[Read more on MDN](https://developer.mozilla.org/docs/Web/API/CanvasRenderingContext2D/fillStyle) +*/ +@get +external getFillStyle: DOM.canvasRenderingContext2D => CanvasTypes.fillStyle = "fillStyle" + +/** +[Read more on MDN](https://developer.mozilla.org/docs/Web/API/CanvasRenderingContext2D/fillStyle) +*/ +@set +external setFillStyle: (DOM.canvasRenderingContext2D, CanvasTypes.fillStyle) => unit = "fillStyle" + +/** +[Read more on MDN](https://developer.mozilla.org/docs/Web/API/CanvasRenderingContext2D/font) +*/ +@set +external setFont: (DOM.canvasRenderingContext2D, string) => unit = "font" + +/** +[Read more on MDN](https://developer.mozilla.org/docs/Web/API/CanvasRenderingContext2D/textBaseline) +*/ +@set +external setTextBaseline: (DOM.canvasRenderingContext2D, CanvasTypes.canvasTextBaseline) => unit = + "textBaseline" + /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/CanvasRenderingContext2D/save) */ diff --git a/src/canvas/HTMLCanvasElement.res b/src/canvas/HTMLCanvasElement.res index 9288e87b..885e3f40 100644 --- a/src/canvas/HTMLCanvasElement.res +++ b/src/canvas/HTMLCanvasElement.res @@ -19,6 +19,40 @@ type t = { include HTMLElement.Impl({type t = t}) +/** +`isInstanceOf(value)` + +Returns whether `value` is an `HTMLCanvasElement` created in the current JavaScript realm. + +This is a runtime check. It returns `false` when `globalThis.HTMLCanvasElement` is unavailable, such +as in some server or worker environments. +*/ +let isInstanceOf = (_: 'value): bool => + %raw(`typeof globalThis.HTMLCanvasElement === "function" && param instanceof globalThis.HTMLCanvasElement`) + +/** +`classify(value)` + +Safely narrows a value from a broad DOM element type to `HTMLCanvasElement.t`. + +Use this for values returned by APIs such as `Document.getElementById`. Returns `Some(canvas)` for +an `HTMLCanvasElement` in the current realm, and `None` when the value is not a canvas or when the +`HTMLCanvasElement` constructor is unavailable. + +```res +switch element->HTMLCanvasElement.classify { +| Some(canvas) => canvas->HTMLCanvasElement.getContext2D +| None => Null +} +``` +*/ +let classify = (value: 'value): option => + if value->isInstanceOf { + Some(Obj.magic(value)) + } else { + None + } + /** Returns an object that provides methods and properties for drawing and manipulating images and graphics on a canvas element in a document. A context object includes information about colors, line widths, fonts, and other graphic parameters that can be drawn on a canvas. Creates a CanvasRenderingContext2D object representing a two-dimensional rendering context. diff --git a/src/dom-nodes/Element.res b/src/dom-nodes/Element.res index 3ab0a09f..768e384a 100644 --- a/src/dom-nodes/Element.res +++ b/src/dom-nodes/Element.res @@ -506,4 +506,36 @@ Returns true if qualifiedName is now present, and false otherwise. include Impl({type t = t}) -let isInstanceOf = (_: 't): bool => %raw(`param instanceof Element`) +/** +`isInstanceOf(value)` + +Returns whether `value` is an `Element` created in the current JavaScript realm. + +This is a runtime check. It returns `false` when `globalThis.Element` is unavailable, such as in +some server or worker environments. +*/ +let isInstanceOf = (_: 'value): bool => + %raw(`typeof globalThis.Element === "function" && param instanceof globalThis.Element`) + +/** +`classify(value)` + +Safely narrows a value from a broad DOM or library type to `Element.t`. + +Use this when an event target, JavaScript binding, or union-like API cannot express its concrete DOM +type statically. Returns `Some(element)` when the value is an `Element` in the current realm, and +`None` when it is not or when the `Element` constructor is unavailable. + +```res +switch value->Element.classify { +| Some(element) => element->Element.hasAttribute("data-ready") +| None => false +} +``` +*/ +let classify = (value: 'value): option => + if value->isInstanceOf { + Some(Obj.magic(value)) + } else { + None + } diff --git a/src/html/HTMLElement.res b/src/html/HTMLElement.res index 25c513b7..4f436620 100644 --- a/src/html/HTMLElement.res +++ b/src/html/HTMLElement.res @@ -106,3 +106,37 @@ rather than relying on coercion. } include Impl({type t = t}) + +/** +`isInstanceOf(value)` + +Returns whether `value` is an `HTMLElement` created in the current JavaScript realm. + +This is a runtime check. It returns `false` when `globalThis.HTMLElement` is unavailable, such as in +some server or worker environments. +*/ +let isInstanceOf = (_: 'value): bool => + %raw(`typeof globalThis.HTMLElement === "function" && param instanceof globalThis.HTMLElement`) + +/** +`classify(value)` + +Safely narrows a value from a broad DOM or library type to `HTMLElement.t`. + +Use this when an event target or element-returning API does not distinguish HTML elements from SVG +or other element kinds. Returns `Some(element)` for an `HTMLElement` in the current realm, and +`None` when the value is not an HTML element or when the `HTMLElement` constructor is unavailable. + +```res +switch value->HTMLElement.classify { +| Some(element) => element->HTMLElement.focus +| None => () +} +``` +*/ +let classify = (value: 'value): option => + if value->isInstanceOf { + Some(Obj.magic(value)) + } else { + None + } diff --git a/src/html/HTMLImageElement.res b/src/html/HTMLImageElement.res index 859a0c54..7e08a6bf 100644 --- a/src/html/HTMLImageElement.res +++ b/src/html/HTMLImageElement.res @@ -342,6 +342,40 @@ type t = { include HTMLElement.Impl({type t = t}) +/** +`isInstanceOf(value)` + +Returns whether `value` is an `HTMLImageElement` created in the current JavaScript realm. + +This is a runtime check. It returns `false` when `globalThis.HTMLImageElement` is unavailable, such +as in some server or worker environments. +*/ +let isInstanceOf = (_: 'value): bool => + %raw(`typeof globalThis.HTMLImageElement === "function" && param instanceof globalThis.HTMLImageElement`) + +/** +`classify(value)` + +Safely narrows a value from a broad DOM element type to `HTMLImageElement.t`. + +Use this for values returned by element-creating or selector APIs. Returns `Some(image)` for an +`HTMLImageElement` in the current realm, and `None` when the value is not an image or when the +`HTMLImageElement` constructor is unavailable. + +```res +switch element->HTMLImageElement.classify { +| Some(image) => Some(image.src) +| None => None +} +``` +*/ +let classify = (value: 'value): option => + if value->isInstanceOf { + Some(Obj.magic(value)) + } else { + None + } + /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/HTMLImageElement/decode) */ diff --git a/src/html/HTMLInputElement.res b/src/html/HTMLInputElement.res index d90e5fbd..bee1478a 100644 --- a/src/html/HTMLInputElement.res +++ b/src/html/HTMLInputElement.res @@ -230,6 +230,40 @@ type t = { include HTMLElement.Impl({type t = t}) +/** +`isInstanceOf(value)` + +Returns whether `value` is an `HTMLInputElement` created in the current JavaScript realm. + +This is a runtime check. It returns `false` when `globalThis.HTMLInputElement` is unavailable, such +as in some server or worker environments. +*/ +let isInstanceOf = (_: 'value): bool => + %raw(`typeof globalThis.HTMLInputElement === "function" && param instanceof globalThis.HTMLInputElement`) + +/** +`classify(value)` + +Safely narrows a value from a broad event target or element type to `HTMLInputElement.t`. + +Use this for values such as React form event targets, which are typed more broadly than the input +element that emitted the event. Returns `Some(input)` for an `HTMLInputElement` in the current realm, +and `None` when the value is not an input or when the `HTMLInputElement` constructor is unavailable. + +```res +switch target->HTMLInputElement.classify { +| Some(input) => Some(input.value) +| None => None +} +``` +*/ +let classify = (value: 'value): option => + if value->isInstanceOf { + Some(Obj.magic(value)) + } else { + None + } + /** Increments a range input control's value by the value given by the Step attribute. If the optional parameter is used, will increment the input control's value by that value. @param n Value to increment the value by. diff --git a/src/window/Document.res b/src/window/Document.res index b38bcab3..c6b0315b 100644 --- a/src/window/Document.res +++ b/src/window/Document.res @@ -460,7 +460,36 @@ external hasStorageAccess: DOM.document => promise = "hasStorageAccess" @send external requestStorageAccess: DOM.document => promise = "requestStorageAccess" -let isInstanceOf = (_: 't): bool => %raw(`param instanceof Document`) +/** +`isInstanceOf(value)` + +Returns whether `value` is a `Document` created in the current JavaScript realm. + +This is a runtime check. It returns `false` when `globalThis.Document` is unavailable, such as in +some server or worker environments. +*/ +let isInstanceOf = (_: 'value): bool => + %raw(`typeof globalThis.Document === "function" && param instanceof globalThis.Document`) + +/** +`classify(value)` + +Safely narrows a value from a broad DOM or library type to `DOM.document`. + +Use this when a JavaScript binding or union-like API can contain a document but cannot express that +concrete type statically. Returns `Some(document)` when the value is a `Document` in the current +realm, and `None` when it is not or when the `Document` constructor is unavailable. + +```res +let document = value->Document.classify +``` +*/ +let classify = (value: 'value): option => + if value->isInstanceOf { + Some(Obj.magic(value)) + } else { + None + } /** Returns the Location associated with this document, which provides information about the current URL and methods for navigating to another URL. diff --git a/tests/DOMAPI/Classify__test.res b/tests/DOMAPI/Classify__test.res new file mode 100644 index 00000000..3a4799ad --- /dev/null +++ b/tests/DOMAPI/Classify__test.res @@ -0,0 +1,46 @@ +%%raw(` +globalThis.Element = class Element {} +globalThis.Document = class Document {} +globalThis.HTMLElement = class HTMLElement extends globalThis.Element {} +globalThis.HTMLCanvasElement = class HTMLCanvasElement extends globalThis.HTMLElement {} +globalThis.HTMLImageElement = class HTMLImageElement extends globalThis.HTMLElement {} +globalThis.HTMLInputElement = class HTMLInputElement extends globalThis.HTMLElement { + checkValidity() { + return true + } +} +`) + +let element: unknown = %raw(`new globalThis.Element()`) +let document: unknown = %raw(`new globalThis.Document()`) +let htmlElement: unknown = %raw(`new globalThis.HTMLElement()`) +let canvas: unknown = %raw(`new globalThis.HTMLCanvasElement()`) +let image: unknown = %raw(`new globalThis.HTMLImageElement()`) +let input: unknown = %raw(`new globalThis.HTMLInputElement()`) + +assert(element->Element.classify->Option.isSome) +assert(document->Document.classify->Option.isSome) +assert(htmlElement->HTMLElement.classify->Option.isSome) +assert(canvas->HTMLCanvasElement.classify->Option.isSome) +assert(image->HTMLImageElement.classify->Option.isSome) +assert(input->HTMLInputElement.classify->Option.isSome) + +assert(input->Element.classify->Option.isSome) +assert(input->HTMLElement.classify->Option.isSome) +assert(document->Element.classify->Option.isNone) +assert(image->HTMLCanvasElement.classify->Option.isNone) +assert(htmlElement->HTMLInputElement.classify->Option.isNone) + +input +->HTMLInputElement.classify +->Option.forEach(input => assert(input->HTMLInputElement.checkValidity)) + +%%raw(` +delete globalThis.HTMLCanvasElement +delete globalThis.HTMLImageElement +delete globalThis.HTMLInputElement +`) + +assert(canvas->HTMLCanvasElement.classify->Option.isNone) +assert(image->HTMLImageElement.classify->Option.isNone) +assert(input->HTMLInputElement.classify->Option.isNone) diff --git a/tests/DOMAPI/HTMLCanvasElement__test.res b/tests/DOMAPI/HTMLCanvasElement__test.res index 4ac1156a..c074557b 100644 --- a/tests/DOMAPI/HTMLCanvasElement__test.res +++ b/tests/DOMAPI/HTMLCanvasElement__test.res @@ -1,37 +1,36 @@ -external toHTMLCanvasElement: null => HTMLCanvasElement.t = "%identity" -@set -external setFillStyle: (DOM.canvasRenderingContext2D, CanvasTypes.fillStyle) => unit = "fillStyle" -@get -external getFillStyle: DOM.canvasRenderingContext2D => CanvasTypes.fillStyle = "fillStyle" -@set -external setFont: (DOM.canvasRenderingContext2D, string) => unit = "font" -@set -external setTextBaseline: (DOM.canvasRenderingContext2D, CanvasTypes.canvasTextBaseline) => unit = - "textBaseline" +let draw = (ctx: DOM.canvasRenderingContext2D) => { + ctx->CanvasRenderingContext2D.setFillStyle(FillStyle.fromString("red")) + ctx->CanvasRenderingContext2D.fillRect(~x=50., ~y=50., ~w=200., ~h=200.) -let myCanvas: HTMLCanvasElement.t = - DomGlobal.document->Document.getElementById("myCanvas")->toHTMLCanvasElement -let ctx = myCanvas->HTMLCanvasElement.getContext2D->Null.getOrThrow + ctx->CanvasRenderingContext2D.setFillStyle(FillStyle.fromString("black")) + ctx->CanvasRenderingContext2D.setFont("2px Tahoma") + ctx->CanvasRenderingContext2D.setTextBaseline(CanvasTypes.Top) + ctx->CanvasRenderingContext2D.fillText(~text="MY TEXT", ~x=60., ~y=60.) -ctx->setFillStyle(FillStyle.fromString("red")) -ctx->CanvasRenderingContext2D.fillRect(~x=50., ~y=50., ~w=200., ~h=200.) + switch ctx->CanvasRenderingContext2D.getFillStyle->FillStyle.decode { + | FillStyle.String(color) => Console.log(`Color: ${color}`) + | FillStyle.CanvasGradient(_) => Console.log("CanvasGradient") + | FillStyle.CanvasPattern(_) => Console.log("CanvasPattern") + } -ctx->setFillStyle(FillStyle.fromString("black")) -ctx->setFont("2px Tahoma") -ctx->setTextBaseline(CanvasTypes.Top) -ctx->CanvasRenderingContext2D.fillText(~text="MY TEXT", ~x=60., ~y=60.) - -switch ctx->getFillStyle->FillStyle.decode { -| FillStyle.String(color) => Console.log(`Color: ${color}`) -| FillStyle.CanvasGradient(_) => Console.log("CanvasGradient") -| FillStyle.CanvasPattern(_) => Console.log("CanvasPattern") + DomGlobal.document + ->Document.createElement("img") + ->HTMLImageElement.classify + ->Option.forEach(image => + ctx->CanvasRenderingContext2D.drawImageWithDimensions( + ~image, + ~dx=0., + ~dy=0., + ~dw=200., + ~dh=200., + ) + ) } -let img: HTMLImageElement.t = DomGlobal.document->Document.createElement("img")->Obj.magic -ctx->CanvasRenderingContext2D.drawImageWithDimensions( - ~image=img, - ~dx=0., - ~dy=0., - ~dw=200., - ~dh=200., +DomGlobal.document +->Document.getElementById("myCanvas") +->Null.toOption +->Option.flatMap(element => element->HTMLCanvasElement.classify) +->Option.forEach(canvas => + canvas->HTMLCanvasElement.getContext2D->Null.toOption->Option.forEach(draw) ) diff --git a/tests/DOMAPI/HTMLInputElement__test.res b/tests/DOMAPI/HTMLInputElement__test.res index 41e76ce0..35758f5e 100644 --- a/tests/DOMAPI/HTMLInputElement__test.res +++ b/tests/DOMAPI/HTMLInputElement__test.res @@ -1,5 +1,4 @@ -external toHTMLInputElement: DOMTree.element => HTMLInputElement.t = "%identity" - -let input: HTMLInputElement.t = - DomGlobal.document->Document.createElement("input")->toHTMLInputElement -let value = input.value +let value = switch DomGlobal.document->Document.createElement("input")->HTMLInputElement.classify { +| Some(input) => Some(input.value) +| None => None +} diff --git a/tests/index.js b/tests/index.js index 9d73db6a..c989c737 100644 --- a/tests/index.js +++ b/tests/index.js @@ -12,6 +12,7 @@ const rescriptConfig = JSON.parse(fs.readFileSync(path.join(repoRoot, "rescript. const compiledSuffix = rescriptConfig.suffix ?? ".js"; const runtimeTests = [ + "DOMAPI/Classify__test.res", "FetchAPI/Headers__test.res", "FetchAPI/Request__test.res", "FetchAPI/Response__test.res",