From 3f6bc76ea17cb0ac54b6b2f15fa9508095a189ed Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 30 Apr 2026 19:09:49 -0500 Subject: [PATCH] feat: add configurable widget min width --- NAMESPACE | 1 + R/engine.R | 3 +- R/shiny.R | 6 +- R/spec.R | 6 +- R/widget.R | 46 +++++++- inst/htmlwidgets/ggsql_vega.css | 2 +- inst/htmlwidgets/ggsql_vega.js | 25 +++-- man/ggsql_widget.Rd | 26 +++++ srcts/vega/widget.test.ts | 181 +++++++++++++++++++++++++++----- srcts/vega/widget.ts | 41 +++++--- tests/testthat/test-spec.R | 61 ++++++++++- 11 files changed, 334 insertions(+), 64 deletions(-) create mode 100644 man/ggsql_widget.Rd diff --git a/NAMESPACE b/NAMESPACE index 3b45e1f..7e7f98b 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -37,6 +37,7 @@ export(ggsql_unregister) export(ggsql_validate) export(ggsql_visual) export(ggsql_warnings) +export(ggsql_widget) export(odbc_reader) export(renderGgsql) export(snowflake_reader) diff --git a/R/engine.R b/R/engine.R index 92fda46..2aad42e 100644 --- a/R/engine.R +++ b/R/engine.R @@ -361,8 +361,7 @@ ggsql_engine_eval <- function(query, reader, options) { )) } writer <- vegalite_writer() - json <- ggsql_render(writer, spec) - widget <- ggsql_widget(json) + widget <- ggsql_widget(writer, spec) out <- knitr::knit_print(widget, options = options) knitr::knit_meta_add(attr(out, "knit_meta")) knitr::engine_output(options, options$code, out = out) diff --git a/R/shiny.R b/R/shiny.R index a0e1072..9449dba 100644 --- a/R/shiny.R +++ b/R/shiny.R @@ -122,8 +122,7 @@ renderGgsql <- function( } if (inherits(value, "Spec")) { - json <- ggsql_render(vegalite_writer(), value) - return(ggsql_widget(json)) + return(ggsql_widget(vegalite_writer(), value)) } if (!is.character(value) || length(value) != 1L) { @@ -151,8 +150,7 @@ renderGgsql <- function( ) } spec <- ggsql_execute(r, query) - json <- ggsql_render(vegalite_writer(), spec) - ggsql_widget(json) + ggsql_widget(vegalite_writer(), spec) }) htmlwidgets::shinyRenderWidget( diff --git a/R/spec.R b/R/spec.R index 2434ac7..7bd6bfc 100644 --- a/R/spec.R +++ b/R/spec.R @@ -10,8 +10,7 @@ Spec <- R6::R6Class( }, print = function(...) { - json <- ggsql_render(vegalite_writer(), self) - widget <- ggsql_widget(json) + widget <- ggsql_widget(vegalite_writer(), self) print(widget) } ) @@ -38,8 +37,7 @@ knit_print.Spec <- function(x, ..., inline = FALSE) { switch( writer_type, vegalite = { - json <- ggsql_render(vegalite_writer(), x) - widget <- ggsql_widget(json) + widget <- ggsql_widget(vegalite_writer(), x) knitr::knit_print(widget, options = options) }, vegalite_svg = , diff --git a/R/widget.R b/R/widget.R index 7b86530..e8baf5a 100644 --- a/R/widget.R +++ b/R/widget.R @@ -1,13 +1,51 @@ -#' @noRd +#' Create a ggsql htmlwidget +#' +#' Create a `ggsql_vega` htmlwidget from a writer and spec. +#' +#' @param writer A `Writer` object created by e.g. [vegalite_writer()]. +#' @param spec A `Spec` object returned by [ggsql_execute()]. +#' @param width,height Optional widget dimensions passed to +#' [htmlwidgets::createWidget()]. +#' @param min_width Optional minimum render width for small containers. When +#' supplied, the widget renders at at least this width and scales down to fit +#' narrower hosts. +#' +#' @return An `htmlwidget` with class `ggsql_vega`. +#' +#' @export ggsql_widget <- function( - spec_json, + writer, + spec, width = NULL, - height = NULL + height = NULL, + min_width = NULL ) { + check_r6(writer, "Writer") + check_r6(spec, "Spec") + + if (!is.null(min_width)) { + if ( + !is.numeric(min_width) || + length(min_width) != 1L || + is.na(min_width) || + !is.finite(min_width) || + min_width <= 0 + ) { + cli::cli_abort( + "{.arg min_width} must be `NULL` or a single positive number." + ) + } + + min_width <- as.numeric(min_width) + } + + spec_json <- ggsql_render(writer, spec) + widget <- htmlwidgets::createWidget( name = "ggsql_vega", x = list( - spec = jsonlite::parse_json(spec_json) + spec = jsonlite::parse_json(spec_json), + min_width = min_width ), width = width, height = height, diff --git a/inst/htmlwidgets/ggsql_vega.css b/inst/htmlwidgets/ggsql_vega.css index 306b363..0353dec 100644 --- a/inst/htmlwidgets/ggsql_vega.css +++ b/inst/htmlwidgets/ggsql_vega.css @@ -13,5 +13,5 @@ ggsql-vega .ggsql-vega-scale-wrapper { } ggsql-vega .ggsql-vega-container { - transform-origin: top left; + transform-origin: left center; } diff --git a/inst/htmlwidgets/ggsql_vega.js b/inst/htmlwidgets/ggsql_vega.js index 6f42a63..3b30155 100644 --- a/inst/htmlwidgets/ggsql_vega.js +++ b/inst/htmlwidgets/ggsql_vega.js @@ -123,20 +123,19 @@ } // srcts/vega/widget.ts - var MIN_WIDTH = 450; function readHostBox(el, width, height) { const hostWidth = typeof width === "number" && width > 0 ? width : el.clientWidth || 0; const styledHeight = typeof el.style.height === "string" && /px$/.test(el.style.height) ? parseFloat(el.style.height) : 0; const hostHeight = typeof height === "number" && height > 0 ? height : el.clientHeight || styledHeight || 0; return { hostWidth, hostHeight }; } - function buildSimpleLayout(hostWidth, hostHeight) { + function buildSimpleLayout(hostWidth, hostHeight, minWidth) { return { hostWidth, hostHeight, - renderWidth: Math.max(hostWidth, MIN_WIDTH), + renderWidth: minWidth === null ? hostWidth : Math.max(hostWidth, minWidth), renderHeight: hostHeight, - scale: hostWidth > 0 && hostWidth < MIN_WIDTH ? hostWidth / MIN_WIDTH : 1 + scale: minWidth !== null && hostWidth > 0 && hostWidth < minWidth ? hostWidth / minWidth : 1 }; } var VegaWidget = class extends HTMLElement { @@ -160,6 +159,7 @@ this._embedToken = null; this._scaleWrapper = null; this._vegaContainer = null; + this.classList.remove("ggsql-scaled"); } createStructure() { this.innerHTML = ""; @@ -178,6 +178,8 @@ this._vegaContainer.style.width = `${layout.renderWidth}px`; this._vegaContainer.style.height = `${layout.renderHeight}px`; this._vegaContainer.style.transform = layout.scale < 1 ? `scale(${layout.scale})` : ""; + if (layout.scale < 1) this.classList.add("ggsql-scaled"); + else this.classList.remove("ggsql-scaled"); } buildSimpleSpec(spec, layout) { return { @@ -204,6 +206,7 @@ this.applyLayout(layout); view.width(layout.renderWidth).height(layout.renderHeight).resize().runAsync().catch((err) => { if (self._view !== view) return; + self.classList.remove("ggsql-scaled"); self.textContent = `ggsql render error: ${String(err)}`; }); } @@ -243,23 +246,31 @@ self.applyLayout(self._layout); }).catch((err) => { if (self._embedToken !== token || self._vegaContainer !== container) return; + self.classList.remove("ggsql-scaled"); self.textContent = `ggsql render error: ${String(err)}`; }); } renderValue(x) { const host = readHostBox(this); + const minWidth = x.min_width ?? null; this._value = x; this._isCompound = isCompoundSpec(x.spec); if (this._isCompound) { - this.renderCompound(buildSimpleLayout(host.hostWidth, host.hostHeight)); + this.renderCompound( + buildSimpleLayout(host.hostWidth, host.hostHeight, minWidth) + ); return; } - this.renderSimple(buildSimpleLayout(host.hostWidth, host.hostHeight)); + this.renderSimple(buildSimpleLayout(host.hostWidth, host.hostHeight, minWidth)); } resize(width, height) { if (!this._value) return; const host = readHostBox(this, width, height); - const layout = buildSimpleLayout(host.hostWidth, host.hostHeight); + const layout = buildSimpleLayout( + host.hostWidth, + host.hostHeight, + this._value.min_width ?? null + ); if (this._isCompound) { if (this.hasMaterialCompoundResize(layout)) this.renderCompound(layout); else { diff --git a/man/ggsql_widget.Rd b/man/ggsql_widget.Rd new file mode 100644 index 0000000..cca2288 --- /dev/null +++ b/man/ggsql_widget.Rd @@ -0,0 +1,26 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/widget.R +\name{ggsql_widget} +\alias{ggsql_widget} +\title{Create a ggsql htmlwidget} +\usage{ +ggsql_widget(writer, spec, width = NULL, height = NULL, min_width = NULL) +} +\arguments{ +\item{writer}{A \code{Writer} object created by e.g. \code{\link[=vegalite_writer]{vegalite_writer()}}.} + +\item{spec}{A \code{Spec} object returned by \code{\link[=ggsql_execute]{ggsql_execute()}}.} + +\item{width, height}{Optional widget dimensions passed to +\code{\link[htmlwidgets:createWidget]{htmlwidgets::createWidget()}}.} + +\item{min_width}{Optional minimum render width for small containers. When +supplied, the widget renders at at least this width and scales down to fit +narrower hosts.} +} +\value{ +An \code{htmlwidget} with class \code{ggsql_vega}. +} +\description{ +Create a \code{ggsql_vega} htmlwidget from a writer and spec. +} diff --git a/srcts/vega/widget.test.ts b/srcts/vega/widget.test.ts index 443e7a6..22d548d 100644 --- a/srcts/vega/widget.test.ts +++ b/srcts/vega/widget.test.ts @@ -3,8 +3,16 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { test } from "node:test"; +import type { WidgetValue } from "./widget"; import { createDeferred, createWidgetTestEnvironment, flushMicrotasks } from "./test-helpers"; +function renderWidgetValue( + instance: { renderValue: (x: { spec: Record }) => void }, + value: WidgetValue +): void { + (instance.renderValue as (x: WidgetValue) => void)(value); +} + test("loads widget tests from TypeScript sources without requiring the built bundle", async () => { const bundlePath = path.join(process.cwd(), "inst", "htmlwidgets", "ggsql_vega.js"); const backupPath = `${bundlePath}.bak`; @@ -20,14 +28,14 @@ test("loads widget tests from TypeScript sources without requiring the built bun ); const w = env.createInstance(450, 400); - w.instance.renderValue({ spec: { mark: "point" } }); + renderWidgetValue(w.instance, { spec: { mark: "point" } }); await flushMicrotasks(); } finally { await fs.rename(backupPath, bundlePath); } }); -test("keeps the layout height while rendering a scaled simple spec", async () => { +test("keeps the layout height while rendering an opt-in scaled simple spec", async () => { const env = createWidgetTestEnvironment(); env.setEmbed((container, spec) => { container.scrollHeight = 200; @@ -39,11 +47,11 @@ test("keeps the layout height while rendering a scaled simple spec", async () => const w = env.createInstance(225); // < 450 = scaled w.el.style.height = "400px"; - w.instance.renderValue({ spec: { name: "first" } }); + renderWidgetValue(w.instance, { spec: { name: "first" }, min_width: 450 }); await flushMicrotasks(); assert.equal(w.el.style.height, "400px"); - w.instance.renderValue({ spec: { name: "second" } }); + renderWidgetValue(w.instance, { spec: { name: "second" }, min_width: 450 }); await flushMicrotasks(); assert.equal(w.el.style.height, "400px"); @@ -67,8 +75,8 @@ test("ignores superseded async embed results", async () => { const w = env.createInstance(450); - w.instance.renderValue({ spec: { name: "first" } }); - w.instance.renderValue({ spec: { name: "second" } }); + renderWidgetValue(w.instance, { spec: { name: "first" } }); + renderWidgetValue(w.instance, { spec: { name: "second" } }); const staleView = { id: "stale", finalized: false, finalize() { this.finalized = true; } }; const latestView = { id: "latest", finalized: false, finalize() { this.finalized = true; } }; @@ -99,7 +107,7 @@ test("finalizes view when widget element is disconnected", async () => { ); const w = env.createInstance(450); - w.instance.renderValue({ spec: { name: "first" } }); + renderWidgetValue(w.instance, { spec: { name: "first" } }); await flushMicrotasks(); w.el.remove(); @@ -120,7 +128,7 @@ test("finalizes stale deferred embeds after disconnect", async () => { env.setEmbed(() => deferred.promise); const w = env.createInstance(450); - w.instance.renderValue({ spec: { name: "first" } }); + renderWidgetValue(w.instance, { spec: { name: "first" } }); w.el.remove(); deferred.resolve({ view: staleView }); @@ -149,7 +157,7 @@ test("rerenders compound specs after moderate width changes", async () => { const w = env.createInstance(900); w.el.clientHeight = 400; - w.instance.renderValue({ + renderWidgetValue(w.instance, { spec: { hconcat: [{ mark: "point" }, { mark: "bar" }] } @@ -184,7 +192,7 @@ test("passes explicit dimensions and fit autosize for simple specs", async () => const w = env.createInstance(600, 320); - w.instance.renderValue({ + renderWidgetValue(w.instance, { spec: { mark: "point" } }); await flushMicrotasks(); @@ -199,7 +207,7 @@ test("passes explicit dimensions and fit autosize for simple specs", async () => }); }); -test("updates simple specs in-place on resize without re-embedding", async () => { +test("updates simple specs in-place on resize using the configured min_width", async () => { const env = createWidgetTestEnvironment(); let embedCalls = 0; const widthCalls: number[] = []; @@ -235,19 +243,24 @@ test("updates simple specs in-place on resize without re-embedding", async () => const w = env.createInstance(600, 320); - w.instance.renderValue({ spec: { mark: "point" } }); + renderWidgetValue(w.instance, { spec: { mark: "point" }, min_width: 450 }); await flushMicrotasks(); - w.el.clientWidth = 540; + w.el.clientWidth = 300; w.el.clientHeight = 280; - w.instance.resize(540, 280); + w.instance.resize(300, 280); await flushMicrotasks(); assert.equal(embedCalls, 1); - assert.deepEqual(widthCalls, [540]); + assert.deepEqual(widthCalls, [450]); assert.deepEqual(heightCalls, [280]); assert.equal(resizeCalls, 1); assert.equal(runAsyncCalls, 1); + assert.equal(w.el._vegaContainer?.style.width, "450px"); + assert.equal( + w.el._vegaContainer?.style.transform, + `scale(${300 / 450})` + ); }); test("does not mutate host height for compound specs", async () => { @@ -266,7 +279,7 @@ test("does not mutate host height for compound specs", async () => { const w = env.createInstance(900, 360); w.el.style.height = "360px"; - w.instance.renderValue({ + renderWidgetValue(w.instance, { spec: { facet: { field: "carb" }, columns: 3, @@ -278,7 +291,33 @@ test("does not mutate host height for compound specs", async () => { assert.equal(w.el.style.height, "360px"); }); -test("renders narrow widgets at logical width 450 with scale transform", async () => { +test("does not scale simple specs by default", async () => { + const env = createWidgetTestEnvironment(); + const calls: Array> = []; + + env.setEmbed((container, spec) => { + calls.push(spec); + return Promise.resolve({ + view: { + spec, + finalize: () => {} + } + }); + }); + + const w = env.createInstance(225, 400); + renderWidgetValue(w.instance, { spec: { mark: "point" } }); + await flushMicrotasks(); + + assert.equal(calls[0].width, 225); + assert.equal(calls[0].height, 400); + assert.equal(w.el._vegaContainer?.style.transform, ""); + assert.equal(w.el._vegaContainer?.style.width, "225px"); + assert.equal(w.el._vegaContainer?.style.height, "400px"); + assert.equal(w.el._scaleWrapper?.className, "ggsql-vega-scale-wrapper"); +}); + +test("scales simple specs when min_width is provided in the payload", async () => { const env = createWidgetTestEnvironment(); const calls: Array> = []; @@ -293,18 +332,110 @@ test("renders narrow widgets at logical width 450 with scale transform", async ( }); const w = env.createInstance(225, 400); - w.instance.renderValue({ spec: { mark: "point" } }); + renderWidgetValue(w.instance, { spec: { mark: "point" }, min_width: 450 }); await flushMicrotasks(); assert.equal(calls[0].width, 450); assert.equal(calls[0].height, 400); assert.equal(w.el._vegaContainer?.style.transform, "scale(0.5)"); assert.equal(w.el._vegaContainer?.style.width, "450px"); - assert.equal(w.el._vegaContainer?.style.height, "400px"); - assert.equal(w.el._scaleWrapper?.className, "ggsql-vega-scale-wrapper"); }); -test("renderValue refreshes viewport from host when the host shrinks", async () => { +test("toggles the ggsql-scaled class for scaled renders and resize updates", async () => { + const env = createWidgetTestEnvironment(); + + env.setEmbed((container, spec) => + Promise.resolve({ + view: { + spec, + finalize: () => {}, + width() { + return this; + }, + height() { + return this; + }, + resize() { + return this; + }, + runAsync() { + return Promise.resolve(this); + } + } + }) + ); + + const w = env.createInstance(225, 400); + + renderWidgetValue(w.instance, { spec: { mark: "point" }, min_width: 450 }); + await flushMicrotasks(); + + assert.equal(w.el.classList.contains("ggsql-scaled"), true); + + w.el.clientWidth = 450; + w.el.clientHeight = 400; + w.instance.resize(450, 400); + await flushMicrotasks(); + + assert.equal(w.el.classList.contains("ggsql-scaled"), false); +}); + +test("does not add the ggsql-scaled class without scaling", async () => { + const env = createWidgetTestEnvironment(); + const w = env.createInstance(450, 320); + + env.setEmbed((container, spec) => + Promise.resolve({ + view: { + spec, + finalize: () => {} + } + }) + ); + + renderWidgetValue(w.instance, { spec: { mark: "point" } }); + await flushMicrotasks(); + + assert.equal(w.el.classList.contains("ggsql-scaled"), false); +}); + +test("clears the ggsql-scaled class when embed fails", async () => { + const env = createWidgetTestEnvironment(); + const w = env.createInstance(225, 400); + + env.setEmbed(() => Promise.reject(new Error("embed failed"))); + + renderWidgetValue(w.instance, { spec: { mark: "point" }, min_width: 450 }); + await flushMicrotasks(); + + assert.equal(w.el.textContent, "ggsql render error: Error: embed failed"); + assert.equal(w.el.classList.contains("ggsql-scaled"), false); +}); + +test("clears the ggsql-scaled class on finalize", async () => { + const env = createWidgetTestEnvironment(); + + env.setEmbed((container, spec) => + Promise.resolve({ + view: { + spec, + finalize: () => {} + } + }) + ); + + const w = env.createInstance(225, 400); + + renderWidgetValue(w.instance, { spec: { mark: "point" }, min_width: 450 }); + await flushMicrotasks(); + assert.equal(w.el.classList.contains("ggsql-scaled"), true); + + w.el.remove(); + + assert.equal(w.el.classList.contains("ggsql-scaled"), false); +}); + +test("renderValue refreshes viewport from host without scaling by default", async () => { const env = createWidgetTestEnvironment(); const calls: Array> = []; @@ -319,16 +450,16 @@ test("renderValue refreshes viewport from host when the host shrinks", async () }); const w = env.createInstance(900, 400); - w.instance.renderValue({ spec: { mark: "point" } }); + renderWidgetValue(w.instance, { spec: { mark: "point" } }); await flushMicrotasks(); w.el.clientWidth = 225; w.el.clientHeight = 400; - w.instance.renderValue({ spec: { mark: "point" } }); + renderWidgetValue(w.instance, { spec: { mark: "point" } }); await flushMicrotasks(); assert.equal(calls.length, 2); - assert.equal(calls[1].width, 450); + assert.equal(calls[1].width, 225); assert.equal(calls[1].height, 400); - assert.equal(w.el._vegaContainer?.style.transform, "scale(0.5)"); + assert.equal(w.el._vegaContainer?.style.transform, ""); }); diff --git a/srcts/vega/widget.ts b/srcts/vega/widget.ts index 2fecaf9..fc76411 100644 --- a/srcts/vega/widget.ts +++ b/srcts/vega/widget.ts @@ -23,8 +23,8 @@ type HostBox = { }; // Computed layout for a render pass. renderWidth/renderHeight may exceed the -// host (see MIN_WIDTH below); `scale` is the CSS transform applied to shrink -// the rendered output back into the host when that happens. +// host when a min-width threshold is configured; `scale` is the CSS transform +// applied to shrink the rendered output back into the host when that happens. type Layout = HostBox & { renderWidth: number; renderHeight: number; @@ -51,6 +51,7 @@ type EmbedToken = Record; export type WidgetValue = { spec: AnyRecord; + min_width?: number | null; }; type WidgetLayoutSpec = AnyRecord & { @@ -72,11 +73,6 @@ declare global { } } -// Vega-Lite charts look bad below ~450px (axis labels overlap, legends wrap -// poorly). Instead of letting Vega render at the true host width, we render -// at MIN_WIDTH and CSS-scale the result down into the smaller container. -const MIN_WIDTH = 450; - // Determine the host container size. htmlwidgets passes explicit width/height // to resize(), but renderValue() receives only the data payload — no sizing // info at all. So on initial render we fall back to el.clientWidth and @@ -96,13 +92,20 @@ function readHostBox(el: HTMLElement, width?: number, height?: number): HostBox return { hostWidth, hostHeight }; } -function buildSimpleLayout(hostWidth: number, hostHeight: number): Layout { +function buildSimpleLayout( + hostWidth: number, + hostHeight: number, + minWidth: number | null +): Layout { return { hostWidth, hostHeight, - renderWidth: Math.max(hostWidth, MIN_WIDTH), + renderWidth: minWidth === null ? hostWidth : Math.max(hostWidth, minWidth), renderHeight: hostHeight, - scale: hostWidth > 0 && hostWidth < MIN_WIDTH ? hostWidth / MIN_WIDTH : 1 + scale: + minWidth !== null && hostWidth > 0 && hostWidth < minWidth + ? hostWidth / minWidth + : 1 }; } @@ -126,6 +129,7 @@ class VegaWidget extends HTMLElement { this._embedToken = null; this._scaleWrapper = null; this._vegaContainer = null; + this.classList.remove("ggsql-scaled"); } createStructure(): HTMLDivElement { @@ -153,6 +157,8 @@ class VegaWidget extends HTMLElement { this._vegaContainer.style.height = `${layout.renderHeight}px`; this._vegaContainer.style.transform = layout.scale < 1 ? `scale(${layout.scale})` : ""; + if (layout.scale < 1) this.classList.add("ggsql-scaled"); + else this.classList.remove("ggsql-scaled"); } buildSimpleSpec(spec: AnyRecord, layout: Layout): WidgetLayoutSpec { @@ -191,6 +197,7 @@ class VegaWidget extends HTMLElement { .runAsync() .catch((err: unknown) => { if (self._view !== view) return; + self.classList.remove("ggsql-scaled"); self.textContent = `ggsql render error: ${String(err)}`; }); } @@ -241,29 +248,37 @@ class VegaWidget extends HTMLElement { }) .catch((err: unknown) => { if (self._embedToken !== token || self._vegaContainer !== container) return; + self.classList.remove("ggsql-scaled"); self.textContent = `ggsql render error: ${String(err)}`; }); } renderValue(x: WidgetValue): void { const host = readHostBox(this); + const minWidth = x.min_width ?? null; this._value = x; this._isCompound = isCompoundSpec(x.spec); if (this._isCompound) { - this.renderCompound(buildSimpleLayout(host.hostWidth, host.hostHeight)); + this.renderCompound( + buildSimpleLayout(host.hostWidth, host.hostHeight, minWidth) + ); return; } - this.renderSimple(buildSimpleLayout(host.hostWidth, host.hostHeight)); + this.renderSimple(buildSimpleLayout(host.hostWidth, host.hostHeight, minWidth)); } resize(width: number, height: number): void { if (!this._value) return; const host = readHostBox(this, width, height); - const layout = buildSimpleLayout(host.hostWidth, host.hostHeight); + const layout = buildSimpleLayout( + host.hostWidth, + host.hostHeight, + this._value.min_width ?? null + ); if (this._isCompound) { if (this.hasMaterialCompoundResize(layout)) this.renderCompound(layout); diff --git a/tests/testthat/test-spec.R b/tests/testthat/test-spec.R index f0642be..0cd33db 100644 --- a/tests/testthat/test-spec.R +++ b/tests/testthat/test-spec.R @@ -86,8 +86,7 @@ test_that("ggsql_widget returns an htmlwidget", { reader, "SELECT * FROM cars VISUALISE mpg AS x, disp AS y DRAW point" ) - json <- ggsql:::ggsql_render(ggsql:::vegalite_writer(), spec) - widget <- ggsql:::ggsql_widget(json) + widget <- ggsql_widget(vegalite_writer(), spec) expect_s3_class(widget, "htmlwidget") expect_s3_class(widget, "ggsql_vega") expect_true(!is.null(widget$x$spec)) @@ -100,11 +99,65 @@ test_that("ggsql_widget renders with a custom element root", { reader, "SELECT * FROM cars VISUALISE mpg AS x, disp AS y DRAW point" ) - json <- ggsql:::ggsql_render(ggsql:::vegalite_writer(), spec) - widget <- ggsql:::ggsql_widget(json, width = "225px", height = "360px") + widget <- ggsql_widget( + vegalite_writer(), + spec, + width = "225px", + height = "360px" + ) html <- htmltools::as.tags(widget, standalone = FALSE) expect_match(as.character(html), "