From ac5c984dcd0502e3f7a3e9c6fc3b7207d72ba091 Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 14 Dec 2023 18:39:50 +0100 Subject: [PATCH] clean up docs --- docs/make.jl | 21 ++++-- docs/src/api.md | 1 + docs/src/assets.md | 2 +- docs/src/components.md | 58 +++++++++++++++ docs/src/index.md | 12 +-- docs/src/{animation.md => interactions.md} | 14 ++-- .../{tutorial.md => javascript-libraries.md} | 8 +- docs/src/layouting.md | 73 ++++++++++++++----- docs/src/plotting.md | 6 +- docs/src/static.md | 14 ++++ docs/src/styling.md | 13 ++-- docs/src/widgets.md | 17 +++-- src/JSServe.jl | 5 +- src/{dashboard.jl => components.jl} | 17 +++++ src/export.jl | 34 ++++++--- src/interactive.jl | 30 ++++++++ src/rendering/styling.jl | 14 ++-- src/types.jl | 29 +++++++- src/widgets.jl | 25 +++++++ test/runtests.jl | 42 ++++++++--- test/styling.jl | 62 ++++++++++++++++ 21 files changed, 405 insertions(+), 92 deletions(-) create mode 100644 docs/src/components.md rename docs/src/{animation.md => interactions.md} (90%) rename docs/src/{tutorial.md => javascript-libraries.md} (81%) create mode 100644 docs/src/static.md rename src/{dashboard.jl => components.jl} (96%) create mode 100644 test/styling.jl diff --git a/docs/make.jl b/docs/make.jl index e9cb36d3..0528ac86 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -10,14 +10,21 @@ makedocs( authors="Simon Danisch and other contributors", pages=[ "Home" => "index.md", - "Styling" => "styling.md", - "Layouting" => "layouting.md", - "Widgets" => "widgets.md", - "Animation" => "animation.md", - "Plotting" => "plotting.md", + "Components" => [ + "Styling" => "styling.md", + "Components" => "components.md", + "Layouting" => "layouting.md", + "Widgets" => "widgets.md", + "Interactions" => "interactions.md", + ], + "Examples" => [ + "Plotting" => "plotting.md", + "Wrapping JS libraries" => "javascript-libraries.md", + "Assets" => "assets.md", + "Extending" => "extending.md", + ], "Deployment" => "deployment.md", - "Assets" => "assets.md", - "Extending" => "extending.md", + "Static Sites" => "static.md", "Api" => "api.md", ] ) diff --git a/docs/src/api.md b/docs/src/api.md index 06a81d65..c0e18a97 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -2,6 +2,7 @@ ## Public Functions + ```@autodocs Modules = [JSServe] Order = [:module, :constant, :type, :function, :macro] diff --git a/docs/src/assets.md b/docs/src/assets.md index f38b2b51..8d395868 100644 --- a/docs/src/assets.md +++ b/docs/src/assets.md @@ -25,7 +25,7 @@ DOM.img(src=some_file) # This will also resolve to a valid URL and load jsmodule as an es6 module DOM.sript(src=jsmodule, type="module") -# Assets also work with online sources, which is great for online dependencies! +# Assets also work with online sources. # Usage is exactly the same as when using local files THREE = ES6Module("https://unpkg.com/three@0.136.0/build/three.js") # Also offer an easy way to use packages from a CDN (currently esm.sh): diff --git a/docs/src/components.md b/docs/src/components.md new file mode 100644 index 00000000..fa13318b --- /dev/null +++ b/docs/src/components.md @@ -0,0 +1,58 @@ +# Components + +Components in JSServe are meant to be re-usable, easily shareable types and functions to create complex JSServe Apps. +We invite everyone to share their Components by turning them into a Julia library. + +There are two ways of defining components in JSServe: + +1. Write a function which returns `DOM` objects +2. Overload `jsrender` for a type + +The first is a very lightweight form of defining reusable components, which should be preferred if possible. + +But, for e.g. widgets the second form is unavoidable, since you will want to return a type that the user can register interactions with. +Also, the second form is great for integrating existing types into JSServe like plot objects. +How to do the latter for Plotly is described in [Plotting](@ref). + +Let's start with the simple function based components that reuses existing JSServe components: + +```@setup 1 +using JSServe +JSServe.Page() +``` + +```@example 1 +using Dates +function CurrentMonth(date=now(); style=Styles(), div_attributes...) + current_day = Dates.day(date) + month = Dates.monthname(date) + ndays = Dates.daysinmonth(date) + current_day_style = Styles(style, "background-color" => "gray", "color" => "white") + days = map(1:ndays) do day + if day == current_day + return Card(Centered(day); style=current_day_style) + else + return Card(Centered(day); style=style) + end + end + grid = Grid(days...; columns="repeat(7, 1fr)") + return DOM.div(DOM.h2(month), grid; style=Styles("width" => "400px", "margin" => "5px")) +end + + +App(()-> CurrentMonth()) +``` + +Now, we could define the same kind of rendering via overloading `jsrender`, if a date is spliced into a DOM: + +```@example 1 +function JSServe.jsrender(session::Session, date::DateTime) + return JSServe.jsrender(session, CurrentMonth(date)) +end +App() do + DOM.div(now()) +end +``` + +Please note, that `jsrender` is not applied recursively on its own, so one needs to apply it manually on the return value. +It's not needed for simple divs and other Hyperscript elements, but e.g. `Styles` requires a pass through `jsrender` to do the deduplication etc. diff --git a/docs/src/index.md b/docs/src/index.md index e7fd1632..3e72cc14 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -1,16 +1,16 @@ -`JSServe.jl` is a pretty simple package allowing to render HTML and serve it from within Julia and build up a communication bridge with the Browser. This allows to combine any of your Julia applications with libraries like [WGLMakie](https://docs.makie.org/dev/documentation/backends/wglmakie/index.html#export) and create interactive Dashboards like this: +`JSServe.jl` is a pretty simple package allowing you to render HTML and serve it from within Julia and build up a communication bridge with the Browser. This allows to combine any of your Julia applications with libraries like [WGLMakie](https://docs.makie.org/dev/documentation/backends/wglmakie/index.html#export) and create interactive Dashboards like this: ![dashboard](https://user-images.githubusercontent.com/1010467/214651671-2f8174b6-48ab-4627-b15f-e19c35042faf.gif) JSServe is tightly integrated with WGLMakie, which makes them a great pair for high performance, interactive visualizations. If performance is not a high priority, many other plotting/visualization libraries which overload the Julia display system should work with `JSServe.jl` as well. -`JSServe.jl` itself tries to stay out of major choices like the HTML/CSS/Javascript framework to use for creating UIs and Dashboards. +`JSServe.jl` itself tries to stay out of major choices like the HTML/CSS/Javascript framework to use for creating UIs and Dashboards. Instead, it allows to create modular components and makes it easy to use any CSS/Javascript library. -It uses plain HTML widgets for UI elements where it can, and only to give some base convenience, there is `JSServe.TailwindDashboard` which gives the basic JSServe widgets some nicer look and make it a bit easier to construct complex layouts. +It uses plain HTML widgets for UI elements where it can, and there are a few styleable components like `Card`, `Grid`, `Row`, `Col` to make it easy to create some more complex dashboards out of the box. -As you can see in `JSServe/src/tailwind-dashboard.jl`, it's just a thin wrapper around the basic `JSServe.jl` widgets, which gives them some class(es) to style them via [TailwindCSS](https://tailwindcss.com/). -Anyone can do this with their own CSS or HTML/Javascript framework, which should help to create a rich ecosystem of extensions around `JSServe.jl`. +If you look at the source of those components, one will see that they're very simple and easy to create, which should help to create a rich ecosystem of extensions around `JSServe.jl`. +Read more about it in [Components](@ref). ## Quickstart @@ -73,7 +73,7 @@ App() do end ``` -Read more about wrapping libraries in [Tutorial](@ref). +Read more about wrapping libraries in [Javascript](@ref). ## Deploying diff --git a/docs/src/animation.md b/docs/src/interactions.md similarity index 90% rename from docs/src/animation.md rename to docs/src/interactions.md index f8aac14e..b69e44d4 100644 --- a/docs/src/animation.md +++ b/docs/src/interactions.md @@ -1,9 +1,9 @@ -# Animating things +# Interactions Animations in JSServe are done via Observables.jl, much like it's the case for Makie.jl, so the same docs apply: -https://docs.makie.org/stable/documentation/nodes/index.html -https://docs.makie.org/stable/documentation/animation/index.html +* [observables](https://docs.makie.org/stable/documentation/nodes/index.html) +* [animation](https://docs.makie.org/stable/documentation/animation/index.html) But lets quickly get started with a JSServe specific example: @@ -18,13 +18,14 @@ App() do session value = map(s.value) do x return x ^ 2 end + # Record states is an experimental feature to record all states generated in the Julia session and allow the slider to stay interactive in the statically hosted docs! return JSServe.record_states(session, DOM.div(s, value)) end ``` The `s.value` is an `Observable` which can be `mapp'ed` to take on new values, and one can insert observables as an input to `DOM.tag` or as any attribute. -The value of the `observable` will be renedered via `jssrender(session, observable[])`, and then updated whenever the value changes. -So anything that supports being inserted into the `DOM` can be inside an observable, and the fallback is to use the display system (so plots etc work as well). +The value of the `observable` will be rendered via `jssrender(session, observable[])`, and then updated whenever the value changes. +So anything that supports being inserted into the `DOM` can be inside an observable, and the fallback is to use the display system (so plots etc. work as well). This way, one can also return `DOM` elements as the result of an observable: ```@example 1 @@ -64,7 +65,6 @@ App() do session end ``` - Likes this one create interactive examples like this: ```@example 1 @@ -95,7 +95,7 @@ app = App() do session end ``` -As you notice, when exporting this example to the docs which get statically hosted, all interactions requiring Julia ceise to exist. +As you notice, when exporting this example to the docs which get statically hosted, all interactions requiring Julia cease to exist. One way to create interactive examples that stay active is to move the parts that need Julia to Javascript: ```@example 1 diff --git a/docs/src/tutorial.md b/docs/src/javascript-libraries.md similarity index 81% rename from docs/src/tutorial.md rename to docs/src/javascript-libraries.md index fdd9ffa8..08ec83d8 100644 --- a/docs/src/tutorial.md +++ b/docs/src/javascript-libraries.md @@ -1,4 +1,6 @@ -# Tutorial +# Javascript + +## Wrapping Javascript Libraries ```@setup 1 using JSServe @@ -6,8 +8,8 @@ JSServe.Page() ``` ```@example 1 -leafletjs = JSServe.ES6Module("https://esm.sh/v111/leaflet@1.9.3/es2022/leaflet.js") -leafletcss = JSServe.Asset("https://unpkg.com/leaflet@1.9.3/dist/leaflet.css") +leafletjs = JSServe.ES6Module("https://esm.sh/v133/leaflet@1.9.4/es2022/leaflet.mjs") +leafletcss = JSServe.Asset("https://unpkg.com/leaflet@1.9.4/dist/leaflet.css") struct LeafletMap position::NTuple{2,Float64} zoom::Int diff --git a/docs/src/layouting.md b/docs/src/layouting.md index 73693342..97879b3f 100644 --- a/docs/src/layouting.md +++ b/docs/src/layouting.md @@ -1,14 +1,13 @@ # Layouting +The main layouting primitive JSServe offers is `Grid`, `Column` and `Row`. +They are all based on css `display: grid` and JSServe offers a small convenience wrapper around it. -The main Layouting primitive JSServe offers is `Grid`, `Column` and `Row`. -They are all based on css `display: grid` and JSServe only offers a small convenience wrapper around it. - -We recommend to read through the great introduction to Styles grids by Josh Comeau: https://www.joshwcomeau.com/css/interactive-guide-to-grid, for a better understanding on how grids work. It's recommended to read this before following this tutorial, since the examples are styled much nicer and explain everything in much greater detail. +We recommend reading through the great introduction to Styles grids by Josh Comeau: [interactive guide to grid](https://www.joshwcomeau.com/css/interactive-guide-to-grid), for a better understanding of how grids work. It's recommended to read this before following this tutorial, since the examples are styled much nicer and explain everything in much greater detail. To easier apply the tutorial to JSServe, we ported all the examples of the tutorial, while only describing them with the bare minimum. To get the full picture, please refer to the linked, original tutorial! -Lets start with the docstring for `Grid`: +Let's start with the docstring for `Grid`: ```@docs Grid @@ -18,6 +17,7 @@ It pretty much just sets the css attributes to some defaults, but everything can All Styles objects inside one App will be merged into a single stylesheet, so using many grids with the same keyword arguments will only generate one entry into the global stylesheet. You can read more about this in the styling section. There's also `Row` and `Col` which uses `Grid` under the hood: + ```@docs Row Col @@ -30,11 +30,13 @@ Page() ## Implicit Grids -If we don't speficy any attributes for the `Grid`, the default will be one dynamic column, where every item gets their own row: +If we don't specify any attributes for the `Grid`, the default will be one dynamic column, where every item gets their own row: ```@example 1 -DemoCard(content=DOM.div(); style=Styles(), attributes...) = Card(content; backgroundcolor=:gray, border_radius="2px", style=Styles(style, "color" => :white),attributes...) + +DemoCard(content=DOM.div(); style=Styles(), attributes...) = Card(content; backgroundcolor=:silver, border_radius="2px", style=Styles(style, "color" => :white),attributes...) + App() do sess s = JSServe.Slider(1:5) @@ -46,7 +48,7 @@ App() do sess end ``` -If we specify a height, while not specifiying a height for the elements, the space will be partitioned for the n children: +If we specify a height, while not specifying a height for the elements, the space will be partitioned for the n children: ```@example 1 App() do sess @@ -82,7 +84,8 @@ App() do sess "max-width" => :none # needs to be set so it's not overwritten by others ) img = DOM.img(; src="https://docs.makie.org/stable/assets/makie_logo_transparent.svg", style=imstyle) - style = Styles("position" => :relative, "background-color" => :gray, "display" => :flex, "justify-content" => :center, "align-items" => :center) + style = Styles("position" => :relative, "display" => :flex, "justify-content" => :center, "align-items" => :center) + function example_grid(cols) grid = Grid(DemoCard(img; style=style), DemoCard(DOM.div(); style=style); columns=cols) @@ -94,6 +97,7 @@ App() do sess grid_percent = DOM.div(Grid(pgrid; rows="1fr")) grid_fr = DOM.div(Grid(frgrid; rows="1fr")) + onjs(sess, container_width.value, js"w=> {$(p1).style.width = (5 * w) + 'px';}") onjs(sess, container_width.value, js"w=> {$(p2).style.width = (5 * w) + 'px';}") title_percent = DOM.h2("Grid(...; columns=\"25% 75%\")") @@ -104,11 +108,9 @@ end Now, what happens if we add more then 2 items to a Grid with 2 columns? - ```@example 1 - # Little helper to create a Card with centered content -centered(c; style=Styles(), kw...) = DemoCard(Grid(DOM.h4(c; style=Styles("color" => :white)); justify_content=:center, justify_items=:center, columns="1fr", style=Styles("align-items"=> :center), kw...); style=style) +centered(c; style=Styles(), kw...) = DemoCard(Centered(DOM.h4(c; style=Styles("color" => :white))); style=style) App() do @@ -140,7 +142,7 @@ end ## Assigning children -Children can be assigned slots in the layout explicitely, and it's also possible to assign them to multiple slots. +Children can be assigned slots in the layout explicitly, and it's also possible to assign them to multiple slots. The css syntax for this is: ```julia @@ -149,6 +151,7 @@ end_column = 3 start_row = 1 end_row = 3 + style = Styles( "grid-column" => "$start_column / $end_column", "grid-row" => "$start_row / $end_row" @@ -160,13 +163,16 @@ To illustrate how this works, here is an interactive app where you can select th ```@example 1 + function centered2d(i, j;) return centered("($i, $j)"; dataCol="$i,$j", style=Styles("user-select" => :none)) end + App() do cards = [centered2d(i, j) for i in 1:4 for j in 1:4] + hover_style = Styles( "background-color" => :blue, "opacity" => 0.2, @@ -175,26 +181,33 @@ App() do "user-select" => :none, ) + hover = DOM.div(; style=hover_style) + grid_style = Styles("position" => :absolute, "top" => 0, "left" => 0) size = "300px" background_grid = Grid(cards...; columns="repeat(4, 1fr)", gap="0px", style=grid_style, height=size, width=size) + rows = [DOM.div() for i in 1:15] + selected_grid = Grid(hover, rows...; columns="repeat(4, 1fr)", gap="0px", style=grid_style, height=size, width=size) + style_display = centered("Styles(...)"; width="100%") + hover_js = js""" const hover = $(hover); const grid = $(selected_grid); const style_display = $(style_display); const h2_node = style_display.children[0].children[0]; + let is_dragging = false; let start_position = null; function get_element(e) { @@ -208,6 +221,7 @@ App() do return } + function handle_click(current) { // Check if the current element is a child of the container const index = current.getAttribute('data-col') @@ -215,6 +229,7 @@ App() do const start = start_position.split(",").map(x=> parseInt(x)) const end = index.split(",").map(x=> parseInt(x)) + const [start_row, end_row] = [start[0], end[0]].sort() const [start_col, end_col] = [start[1], end[1]].sort() const row = (start_row) + " / " + (end_row + 1) @@ -231,10 +246,12 @@ App() do child.style.display = nelems >= i ? "block" : "none"; } + h2_node.innerText = 'Styles(\n\"grid-row\" => \"' + row + '\",\n \"grid-column\" => \"' + col + '\"\n)'; } } + grid.addEventListener('mousedown', (e) => { if (!hover) { return @@ -250,24 +267,27 @@ App() do handle_click(current); }); + grid.addEventListener('mousemove', (e) => { if (!is_dragging) return; const current = get_element(e); handle_click(current); }); + document.addEventListener('mouseup', () => { is_dragging = false; }); + """ grids = DOM.div(background_grid, selected_grid, hover_js; style=Styles("position" => :relative, "height" => size)) + return Grid(style_display, grids; columns="1fr 2fr", width="100%") end ``` - ## Grid areas We can now easily create complex layouts like this: @@ -275,6 +295,7 @@ We can now easily create complex layouts like this: ```@example 1 App() do + sidebar = DemoCard( "SIDEBAR", style = Styles( @@ -283,6 +304,7 @@ App() do ) ) + header = DemoCard( "HEADER", style = Styles( @@ -291,6 +313,7 @@ App() do ) ) + main = DemoCard( "MAIN", style = Styles( @@ -299,6 +322,7 @@ App() do ) ) + grid = Grid( sidebar, header, main, columns = "2fr 5fr", @@ -313,21 +337,25 @@ With `areas`, in css `grid-template-areas` this can be made even simpler: ```@example 1 App() do + sidebar = DemoCard( "SIDEBAR", style = Styles("grid-area" => "sidebar") ) + header = DemoCard( "HEADER", style = Styles("grid-area" => "header") ) + main = DemoCard( "MAIN", style = Styles("grid-area" => "main") ) + grid = Grid( sidebar, header, main, columns = "2fr 5fr", @@ -340,13 +368,12 @@ App() do return DOM.div(grid; style=Styles("height" => "600px", "margin" => "20px", "position" => :relative)) end ``` -The syntax is quite similar to julias matrix syntax, just wrapping all rows into `'...row...'`! -To span multiple rows or columns, the name can be repeated multiple times. +The syntax is quite similar to Julia's matrix syntax, just wrapping all rows into `'...row...'`! +To span multiple rows or columns, the name can be repeated multiple times. ## Alignment - ```@example 1 App() do grid = Grid( @@ -357,11 +384,11 @@ App() do end ``` - ```@example 1 App() do session justification = JSServe.Dropdown(["space-evenly", "center", "end", "space-between", "space-around", "space-evenly"], style=Styles("width" => "200px")) + grid = Grid( DemoCard(), DemoCard(), columns = "90px 90px" @@ -401,7 +428,6 @@ App() do session end ``` - ```@example 1 App() do session content = JSServe.Dropdown(["space-evenly", "center", "end", "space-between", "space-around"]) @@ -417,9 +443,11 @@ App() do session style=grid_style ) + grid_col() = DOM.div(style=Styles("border" => "2px dashed white", "width"=>"100px")) grid_row() = DOM.div(style=Styles("border" => "2px dashed white", "height"=>"100px")) + shadow_cols = Grid( grid_col(), grid_col(), columns = "100px 100px", @@ -431,30 +459,35 @@ App() do session style=grid_style ) + onjs(session, content.option_index, js""" (idx) => { const grids = [$(grid), $(shadow_cols)] const val = $(content.options[])[idx-1] grids.forEach(x=> x.style["justify-content"] = val) }""") + onjs(session, items.option_index, js""" (idx) => { const grids = [$(grid), $(shadow_cols)] const val = $(items.options[])[idx-1] grids.forEach(x=> x.style["justify-items"] = val) }""") + onjs(session, align_content.option_index, js""" (idx) => { const grids = [$(grid), $(shadow_rows)] const val = $(align_content.options[])[idx-1] grids.forEach(x=> x.style["align-content"] = val) }""") + onjs(session, align_items.option_index, js""" (idx) => { const grids = [$(grid), $(shadow_rows)] const val = $(align_items.options[])[idx-1] grids.forEach(x=> x.style["align-items"] = val) }""") + grid_area = DOM.div(grid, shadow_cols, shadow_rows; style=Styles( "height" => "400px", "width" => "400px", "margin" => "20px", @@ -463,6 +496,7 @@ App() do session "background-color" => "#F88379", "grid-column" => "1 / 3", "grid-row" => "4")) + text(t) = DOM.div(t; style=Styles("font-size" => "1.3rem", "font-weight" => "bold")) final_grid = Grid( text("Row Alignment"), text("Col Justification"), @@ -474,6 +508,7 @@ App() do session justify_items="begin", justify_content="center", width="400px") + return DOM.div(final_grid, style=Styles("padding" => "20px")) end ``` diff --git a/docs/src/plotting.md b/docs/src/plotting.md index 3b92179c..ba724252 100644 --- a/docs/src/plotting.md +++ b/docs/src/plotting.md @@ -1,14 +1,16 @@ # Plotting +All plotting frameworks overloading the Julia display system should work out of the box and all Javascript plotting libraries should be easy to integrate! +The following example shows how to integrate popular libraries like Makie, Plotly and Gadfly. + ```@example 1 using JSServe using WGLMakie import WGLMakie as W import Gadfly as G import PlotlyLight as PL -import JSServe.TailwindDashboard as D -Page() +Page() # required for multi cell output inside documenter function makie_plot() N = 10 diff --git a/docs/src/static.md b/docs/src/static.md new file mode 100644 index 00000000..0914d497 --- /dev/null +++ b/docs/src/static.md @@ -0,0 +1,14 @@ +# Static Sites + +There are several ways to generate static sites with JSServe. +The simplest one is to use the Revise based `interactive_server`: + +```@docs +interactive_server +``` + +When exporting interactions defined within Julia not using Javascript, one can use, to cache all interactions: + +```@docs +JSServe.record_states +``` diff --git a/docs/src/styling.md b/docs/src/styling.md index 8457bed5..d9db4469 100644 --- a/docs/src/styling.md +++ b/docs/src/styling.md @@ -7,8 +7,11 @@ using JSServe Page() ``` -```@example 1 -@doc Styles # hide +The main type to style the DOM via css is `Style`: + + +```@docs; canonical=false +Styles ``` ## Using CSS and pseudo classes @@ -80,11 +83,11 @@ end ``` -## Using Styles as complete Stylesheet +## Using Styles as global Stylesheet -One can also define a complete stylesheet with `Styles` using selectors to style an HTML document. +One can also define a global stylesheet with `Styles` using selectors to style parts of an HTML document. This can be handy to set some global styling, but please be careful, since this will affect the whole document. That's also why we need to set a specific attribute selector for all, to not affect the whole documentation page. -This will not happen when assigning a style to `DOM.div(style=Styles(...))`. +This will not happen when assigning a style to `DOM.div(style=Styles(...))`, which will always just apply to that particular div and any other div assigned to. Note, that a style object directly inserted into the DOM will be rendered exactly where it occurs without deduplication! ```@example 1 diff --git a/docs/src/widgets.md b/docs/src/widgets.md index cb443878..05f215da 100644 --- a/docs/src/widgets.md +++ b/docs/src/widgets.md @@ -7,29 +7,30 @@ Page() ## All Available widgets -```@example 1 -@doc Button # hide +```@docs; canonical=false +Button ``` + ```@example 1 include_string(@__MODULE__, JSServe.BUTTON_EXAMPLE) # hide ``` -```@example 1 -@doc TextField # hide +```@docs; canonical=false +TextField ``` ```@example 1 include_string(@__MODULE__, JSServe.TEXTFIELD_EXAMPLE) # hide ``` -```@example 1 -@doc NumberInput # hide +```@docs; canonical=false +NumberInput ``` ```@example 1 include_string(@__MODULE__, JSServe.NUMBERINPUT_EXAMPLE) # hide ``` -```@example 1 -@doc Dropdown # hide +```@docs; canonical=false +Dropdown ``` ```@example 1 include_string(@__MODULE__, JSServe.DROPDOWN_EXAMPLE) # hide diff --git a/src/JSServe.jl b/src/JSServe.jl index 09464e71..99b752d2 100644 --- a/src/JSServe.jl +++ b/src/JSServe.jl @@ -62,7 +62,7 @@ include("util.jl") include("widgets.jl") include("display.jl") include("export.jl") -include("dashboard.jl") +include("components.jl") include("tailwind-dashboard.jl") include("interactive.jl") @@ -77,7 +77,8 @@ export NoServer, AssetFolder, HTTPAssetServer, DocumenterAssets export NoConnection, IJuliaConnection, PlutoConnection, WebSocketConnection export export_static, Routes, interactive_server export Card, Grid, FileInput, Dropdown, Styles, Col, Row -export Labeled, StylableSlider +export Labeled, StylableSlider, Centered +export interactive_server function has_html_display() for display in Base.Multimedia.displays diff --git a/src/dashboard.jl b/src/components.jl similarity index 96% rename from src/dashboard.jl rename to src/components.jl index e037b3bc..25de6e0e 100644 --- a/src/dashboard.jl +++ b/src/components.jl @@ -140,6 +140,23 @@ function Col(args...; attributes...) return Grid(args...; columns="1fr", attributes...) end +""" + Centered(content; style=Styles(), grid_attributes...) + +Creates an element where the content is centered via `Grid`. +""" +function Centered(content; style=Styles(), grid_attributes...) + return Grid( + content; + justify_content=:center, + justify_items=:center, + align_content=:center, + align_items=:center, + columns="1fr", + style=Styles(style), + grid_attributes..., + ) +end struct StylableSlider{T} <: AbstractSlider{T} values::Observable{Vector{T}} diff --git a/src/export.jl b/src/export.jl index 81bc6500..848a78fc 100644 --- a/src/export.jl +++ b/src/export.jl @@ -87,6 +87,24 @@ function while_disconnected(f, session::Session) f() end +""" + record_states(session::Session, dom::Hyperscript.Node) + +Records the states of all widgets in the dom. +Any widget that implements the following interface will be found in the DOM and can be recorded: + +```julia +# Implementing interface for JSServe.Slider! +is_widget(::Slider) = true +value_range(slider::Slider) = 1:length(slider.values[]) +to_watch(slider::Slider) = slider.index # the observable that will trigger JS state change +``` + +!!! warn + This is experimental and might change in the future! + It can also create really large HTML files, since it needs to record all combinations of widget states. + It's also not well optimized yet and may create a lot of duplicated messages. +""" function record_states(session::Session, dom::Hyperscript.Node) widgets = extract_widgets(dom) rendered = jsrender(session, dom) @@ -167,6 +185,13 @@ function export_standalone(app::App, folder::String; error("export_standalone is deprecated, please use export_static") end +""" + export_static(html_file::Union{IO, String}, app::App) + export_static(folder::String, routes::Routes) + +Exports the app defined by `app` with all its assets a single HTML file. +Or exports all routes defined by `routes` to `folder`. +""" function export_static(html_file::String, app::App; asset_server=NoServer(), connection=NoConnection(), @@ -197,12 +222,3 @@ function export_static(folder::String, routes::Routes; connection=NoConnection() export_static(html_file, app; session=Session(connection; asset_server=asset_server)) end end - - - -function export_static(routes) - dir = joinpath(@__DIR__, "docs") - # rm(dir; recursive=true, force=true); mkdir(dir) - folder = AssetFolder(dir) - JSServe.export_static(dir, routes; asset_server=folder) -end diff --git a/src/interactive.jl b/src/interactive.jl index af716a81..98f501a4 100644 --- a/src/interactive.jl +++ b/src/interactive.jl @@ -12,6 +12,36 @@ end const REVISE_LOOP = Base.RefValue(Base.RefValue(true)) const REVISE_SERVER = Base.RefValue{Union{Nothing, Server}}(nothing) +""" + interactive_server(f, paths, modules=[]; url="127.0.0.1", port=8081, all=true) + +Revise base server that will serve a static side based on JSServe and will update on any code change! + +Usage: + +```julia +using Revise, Website +using Website.JSServe + +# Start the interactive server and develop your website! +routes, task, server = interactive_server(Website.asset_paths()) do + return Routes( + "/" => App(index, title="Makie"), + "/team" => App(team, title="Team"), + "/contact" => App(contact, title="Contact"), + "/support" => App(support, title="Support") + ) +end + +# Once everything looks goo, export the static site +dir = joinpath(@__DIR__, "docs") +# only delete the jsserve generated files +rm(joinpath(dir, "jsserve"); recursive=true, force=true) +JSServe.export_static(dir, routes) +``` +For the complete code, visit the Makie website repository which is using JSServe: +[MakieOrg/Website](https://github.com/MakieOrg/Website/blob/sd/jsserve/make.jl) +""" function interactive_server(f, paths, modules=[]; url="127.0.0.1", port=8081, all=true) if isnothing(REVISE_SERVER[]) server = Server(url, port) diff --git a/src/rendering/styling.jl b/src/rendering/styling.jl index 3472f311..9d64dabd 100644 --- a/src/rendering/styling.jl +++ b/src/rendering/styling.jl @@ -27,7 +27,7 @@ end convert_css_attribute(attribute::String) = chomp(attribute) convert_css_attribute(color::Symbol) = convert_css_attribute(string(color)) -convert_css_attribute(@nospecialize(obs::Observable)) = convert_css_attribute(obs[]) +convert_css_attribute(@nospecialize(::Observable)) = error("Observable not supported in CSS attributes right now!") convert_css_attribute(@nospecialize(any)) = string(any) function convert_css_attribute(color::Colorant) @@ -38,7 +38,7 @@ end function render_style(io, prefix, css) println(io, prefix, css.selector, " {") for (k, v) in css.attributes - println(io, " ", k, ": ", convert_css_attribute(v), ";") + println(io, " ", k, ": ", v, ";") end println(io, "}") end @@ -125,12 +125,12 @@ function Base.merge!(target::Styles, styles::Set{CSS}) end end -function Base.merge!(target::Styles, styles::Styles) - for (selector, css) in styles.styles - if haskey(target.styles, selector) - target.styles[selector] = merge(target.styles[selector], css) +function Base.merge!(defaults::Styles, priority::Styles) + for (selector, css) in priority.styles + if haskey(defaults.styles, selector) + defaults.styles[selector] = merge(defaults.styles[selector], css) else - target.styles[selector] = css + defaults.styles[selector] = css end end end diff --git a/src/types.jl b/src/types.jl index 63ac87c2..1ebbd4e7 100644 --- a/src/types.jl +++ b/src/types.jl @@ -94,15 +94,30 @@ function Base.union!(set1::OrderedSet, set2) union!(set1.items, set2) end - struct CSS selector::String - attributes::Dict{String,Any} - function CSS(selector, attributes::Dict{String,Any}) - return new(selector, attributes) + # TODO use some kind of immutable Dict + attributes::Dict{String,String} + # We assume attributes to be immutable, so we calculate the hash once + hash::UInt64 + function CSS(selector, attributes::Dict{String,T}) where T <: Any + css = Dict{String,String}() + # Need to sort to always get the same hash! + sorted_keys = sort!(collect(keys(attributes))) + h = hash(selector, UInt64(0)) + h = hash(sorted_keys, h) + for k in sorted_keys + converted = convert_css_attribute(attributes[k]) + css[k] = converted + h = hash(converted, h) + end + return new(selector, css, h) end end +Base.hash(css::CSS, h::UInt64) = hash(css.hash, h) +Base.:(==)(css1::CSS, css2::CSS) = css1.hash == css2.hash + """ Styles(css::CSS...) @@ -123,6 +138,12 @@ function MyComponent(; style=Styles()) end ``` All JSServe components are stylable this way. + +!!! info + Why not `Hyperscript.Style`? While the scoped styling via `Hyperscript.Style` is great, it makes it harder to create stylable components, since it doesn't allow the deduplication of CSS objects across the session. + It's also significantly slower, since it's not as specialized on the deduplication and the camelcase keyword to css attribute conversion is pretty costly. + That's also why `CSS` uses pairs of strings instead of keyword arguments. + """ struct Styles # Dict(selector => CSS) diff --git a/src/widgets.jl b/src/widgets.jl index 4374d396..44284cc0 100644 --- a/src/widgets.jl +++ b/src/widgets.jl @@ -294,7 +294,30 @@ function Base.setindex!(slider::AbstractSlider, value) return idx end + +const CHECKBOX_EXAMPLE = """ +App() do + style = Styles( + CSS(":hover", "background-color" => "silver"), + CSS(":focus", "box-shadow" => "rgba(0, 0, 0, 0.5) 0px 0px 5px"), + ) + checkbox = Checkbox(true; style=style) + on(checkbox.value) do value::Bool + @info value + end + return checkbox +end +""" + +""" + Checkbox(default_value; style=Styles(), dom_attributes...) + +A simple Checkbox, which can be styled via the `style::Styles` attribute. +""" + function jsrender(session::Session, tb::Checkbox) + style = Styles(Styles("min-width" => "auto", "transform" => "scale(1.5)"), BUTTON_STYLE) + css = Styles(get(tb.attributes, :style, Styles()), style) return jsrender( session, DOM.input(; @@ -302,9 +325,11 @@ function jsrender(session::Session, tb::Checkbox) checked=tb.value, onchange=js"""event=> $(tb.value).notify(event.srcElement.checked);""", tb.attributes..., + style=css ), ) end + # TODO, clean this up const noUiSlider = ES6Module(dependency_path("nouislider.min.js")) const noUiSliderCSS = Asset(dependency_path("noUISlider.css")) diff --git a/test/runtests.jl b/test/runtests.jl index c5105ca6..197967d0 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -22,7 +22,7 @@ include("ElectronTests.jl") function wait_on_test_observable() global test_observable - test_channel = Channel{Dict{String, Any}}(1) + test_channel = Channel{Dict{String,Any}}(1) f = on(test_observable) do value put!(test_channel, value) end @@ -49,20 +49,38 @@ function test_value(app, statement) else statement() end - fetch(val_t) # fetch the value! + return fetch(val_t) # fetch the value! end -edisplay = JSServe.use_electron_display(devtools=true) - +edisplay = JSServe.use_electron_display(; devtools=true) @testset "JSServe" begin - @testset "threading" begin; include("threading.jl"); end - @testset "server" begin; include("server.jl"); end - @testset "subsessions" begin; include("subsessions.jl"); end - @testset "connection-serving" begin; include("connection-serving.jl"); end - @testset "serialization" begin; include("serialization.jl"); end - @testset "widgets" begin; include("widgets.jl"); end + @testset "styling" begin + include("styling.jl") + end + @testset "threading" begin + include("threading.jl") + end + @testset "server" begin + include("server.jl") + end + @testset "subsessions" begin + include("subsessions.jl") + end + @testset "connection-serving" begin + include("connection-serving.jl") + end + @testset "serialization" begin + include("serialization.jl") + end + @testset "widgets" begin + include("widgets.jl") + end # @testset "various" begin; include("various.jl"); end - @testset "markdown" begin; include("markdown.jl"); end - @testset "basics" begin; include("basics.jl"); end + @testset "markdown" begin + include("markdown.jl") + end + @testset "basics" begin + include("basics.jl") + end end diff --git a/test/styling.jl b/test/styling.jl new file mode 100644 index 00000000..59bda224 --- /dev/null +++ b/test/styling.jl @@ -0,0 +1,62 @@ + +@testset "style de-duplication" begin + x = Styles(Styles(), "background-color" => "gray", "color" => "white") + y = Styles(Styles(), "background-color" => "gray", "color" => "white") + @test Set(values(x.styles)) == Set(values(y.styles)) + + x = Styles("background-color" => "gray", "color" => "white") + y = Styles("background-color" => "gray", "color" => "white") + @test Set(values(x.styles)) == Set(values(y.styles)) + + x = CSS("background-color" => "gray", "color" => "white") + y = CSS("background-color" => "gray", "color" => "white") + @test Set(values(x.attributes)) == Set(values(y.attributes)) + + + x = CSS("background-color" => "gray", "color" => 1) + y = CSS("background-color" => "gray", "color" => "1") + @test Set(values(x.attributes)) == Set(values(y.attributes)) +end + +@testset "deduplication in rendered dom" begin + app = App() do + base = Styles("border" => "1px solid black", "padding" => "5px") + days = map(1:31) do day + DOM.div(day; style=Styles(base, "background-color" => "gray", "color" => "black")) + end + return DOM.div(days...) + end + s = Session() + JSServe.session_dom(s, app) + all_css = Set([css for (n, set) in s.stylesheets for css in set]) + @test length(all_css) == 1 + css = CSS( + "border" => "1px solid black", + "padding" => "5px", + "background-color" => "gray", + "color" => "black", + ) + @test first(all_css) == css +end + +@testset "merge & hash" begin + css1 = CSS( + "border" => "1px solid black", + "padding" => "5px", + "background-color" => "gray", + "color" => "black", + ) + css2 = CSS( + "border" => "1px solid black", + "padding" => "5px", + "background-color" => "gray", + "color" => "black", + ) + css3 = merge(css1, css2) + + @test css1 == css2 + @test css3 == css2 + + @test hash(css1) == hash(css2) + @test hash(css3) == hash(css2) +end