Layouts
Before you start
Start from an application with a few named routes, such as the quick start after URL state and navigation. This guide adds a navigation bar to every page and a header to one group of pages.
Scalive has two kinds of layout:
A
LiveRootLayouttrait LiveRootLayout[-A, -Ctx]Renders the outer HTML document: <html>, <head>, scripts, stylesheets, and <body>. renders the HTML document:<html>,<head>, scripts, stylesheets, and<body>. Every page has exactly one.A
LiveLayouttrait LiveLayout[-A, -Ctx]Wraps a LiveView's content inside the document, for example in shared navigation. wraps the page inside the document, for example in a navigation bar. A page can have several.
Both are classes that extend a trait and implement one view method.
Render the document
The quick start's root layout renders the document for every page:
package quickstart
import scalive.*
final class RootLayout(clientAssets: LiveViewClientAssets, assets: StaticAssets)
extends LiveRootLayout[Any, Any]:
def view[Msg](
content: HtmlElement[Msg],
pageTitle: Option[String],
ctx: LiveRootLayoutContext[Any, Any]
): HtmlElement[Msg] =
htmlRootTag(
lang := "en",
headTag(
metaTag(charset := "utf-8"),
metaTag(nameAttr := "viewport", contentAttr := "width=device-width, initial-scale=1"),
liveTitle(pageTitle, default = "Scalive quick start"),
clientAssets.phoenixScript,
clientAssets.liveViewScript,
assets.trackedScript("app.js", defer := true, typ := "text/javascript")
),
bodyTag(content)
)View source (documentation/fixtures/quick-start/src/quickstart/RootLayout.scala:2-24)
The class takes the loaded assets in its constructor, so it can render their
versioned URLs. Its two type parameters are the route parameter type and the
mount context type the layout reads. A layout shared by every route reads
neither, so both are Any.
view is generic in Msg because the same layout wraps pages with different
message types. content is the page, and the layout places it in the body.
pageTitle is the page's
pageTitledef pageTitle(model: Model): Option[String]Returns the document title for model, or None to use the root layout's default. at the first render.
Pass it to liveTitle so later title changes reach the browser; the default
applies when the page has no title.
Scalive calls a root layout's view only for the first HTTP response. Live
updates patch the page inside the document and never render the document again,
so ctx holds plain values: the route parameters, the request, the URL, and
the mount context.
Install the root layout on the router with withRootLayout, as the quick
start's Main does.
Wrap every page in shared navigation
An ordinary layout renders the markup that pages share inside <body>. This
one adds a navigation bar and marks the link to the current section:
package layouts
import scalive.*
final class AppLayout extends LiveLayout[Any, Any]:
def view[Msg](content: HtmlElement[Msg], ctx: LiveLayoutContext[Any, Any]): HtmlElement[Msg] =
val path = ctx.currentUrl.map(_.path.encode)
div(
headerTag(
navTag(
aria.label := "Main",
navLink(Routes.home.location, "Home", path.map(_ == "/")),
navLink(Routes.projects.location, "Projects", path.map(_.startsWith("/projects")))
)
),
mainTag(content)
)
private def navLink[Msg](
location: LiveLocation,
label: String,
current: Signal[Boolean]
): HtmlElement[Msg] =
link.pushNavigate(
location,
aria.current.optional(current.map(Option.when(_)("page"))),
label
)View source (documentation/fixtures/guides/src/layouts/AppLayout.scala:2-29)
Install it on the router with withLayout, next to the root layout:
def apply(clientAssets: LiveViewClientAssets, assets: StaticAssets): LiveApplication[Any] =
Live.router
.withRootLayout(RootLayout(clientAssets, assets))
.withLayout(AppLayout())(
Routes.home -> HomeLiveView(),
Routes.projects -> ProjectsLiveView(),
Routes.project.withLayout(ProjectLayout()).params -> ProjectLiveView()
)View source (documentation/fixtures/guides/src/layouts/Application.scala:9-18)
Scalive calls an ordinary layout's view once per page lifecycle, like a
LiveView's view. Its ctx.params, ctx.request, and ctx.currentUrl are
signals, so the layout follows live navigation and patches without being
rebuilt. Here aria-current moves to Projects when the visitor opens a
project, while the navigation bar itself stays in place.
A layout cannot send messages of its own: its view must accept content of any
message type, so it has no message type to send. Use links, as above,
nested LiveViews, or
browser hooks for interactive chrome.
Give one route its own layout
Install a layout on a route builder to wrap only that route. The registration above adds a header to each project page:
Routes.project.withLayout(ProjectLayout()).params -> ProjectLiveView()View source (documentation/fixtures/guides/src/layouts/Application.scala:16-16)
A route layout can read the route's decoded parameters. ProjectLayout
declares String, the type of the project path segment, so ctx.params is a
Signal[String]:
package layouts
import scalive.*
final class ProjectLayout extends LiveLayout[String, Any]:
def view[Msg](content: HtmlElement[Msg], ctx: LiveLayoutContext[String, Any]): HtmlElement[Msg] =
sectionTag(
p(link.pushNavigate(Routes.projects.location, "All projects")),
h1(ctx.params.map(project => s"Project $project")),
content
)View source (documentation/fixtures/guides/src/layouts/ProjectLayout.scala:2-12)
The compiler checks that a route layout's parameter type matches the route.
Install route layouts before .params or .query. When a route prefix with a
layout is extended with /, the layout keeps receiving the parameters of the
prefix it was installed on.
Know where each layout applies
Layouts can be installed at three levels:
| Level | Install with | Wraps |
|---|---|---|
| Router | Live.router.withRootLayout(root).withLayout(layout) | Every route |
| Named live session | Live.session(name).withRootLayout(root).withLayout(layout) | The session's routes |
| Route | route.withRootLayout(root).withLayout(layout) | One route |
Ordinary layouts nest: router layouts are outermost, then session layouts, then route layouts. Within one level, they nest in installation order. Root layouts do not nest: a route's root layout replaces its session's, which replaces the router's. The router accepts one ordinary layout; sessions and routes accept several. Live sessions and mount aspects shows how to group routes in a named live session.
Decide when pages share a document
Live navigation replaces the page but keeps the document, including its
<head>, scripts, and <body> attributes. That is only correct when the
destination would render the same document. Scalive compares the two root
layouts' keydef key(context: LiveRootLayoutContext[A, Ctx]): StringIdentifies the document this layout renders. values. When they
differ, the browser loads the destination with a full page request instead.
The default key is the root layout's class name. Routes that use the same root
layout class therefore share the document, and routes with different classes
do not. Override key when one class renders documents that cannot replace each
other. This root layout renders a different lang per context, so it includes
the language in its key:
object DocumentRoot extends LiveRootLayout[Any, DocumentContext]:
override def key(ctx: LiveRootLayoutContext[Any, DocumentContext]): String =
s"document:${ctx.context.language}"
def view[Msg](
content: HtmlElement[Msg],
pageTitle: Option[String],
ctx: LiveRootLayoutContext[Any, DocumentContext]
): HtmlElement[Msg] =
htmlRootTag(
lang := ctx.context.language,
headTag(titleTag(pageTitle.getOrElse("Scalive"))),
bodyTag(
dataAttr("params") := ctx.params.toString,
dataAttr("request-url") := ctx.request.url.encode,
dataAttr("current-url") := ctx.currentUrl.encode,
content
)
)View source (documentation/site/src/scalive/docs/examples/LayoutContextExample.scala:32-50)
Navigating from a French page to a German page then reloads the document,
instead of keeping <html lang="fr">. Equal keys do not bypass named live
session boundaries: navigation between sessions always loads a new document.
Reuse layouts across contexts
A layout installed on a named live session or behind a mount aspect can read the typed context they produce, such as the signed-in user. Declare the context type the layout needs as its second type parameter.
A shared layout should depend on a small context, not on a session's full
context. Use forContextdef forContext[NewContext](select: NewContext => Ctx): LiveLayout[A, NewContext]Adapts this layout to another context type by selecting the context it needs. to
adapt it where it is installed:
// DocumentRoot reads DocumentContext; AccountContext contains .document.
val accountRoot = DocumentRoot.forContext[AccountContext](_.document)When the selection is local to installation, pass the selector as a second argument instead:
Live.session("account")
.withMountAspect(accountContext)
.withRootLayout(DocumentRoot, _.document)
.withLayout(DocumentLayout, _.document)The builder supplies the selector's input type. These two-argument overloads
exist on named live session builders, including admitted ones, and on route
builders after a mount aspect, before .params or .query. The router accepts
only layouts whose context type is Any; install a context-dependent root
layout on a session or route.
Selection uses the context available where the layout is installed.
Appending another mount aspect afterward does not make the layout read the
final accumulated context. Only the layout's context value is adapted;
parameters, request, URL, content, and page title are unchanged. Selectors must
be pure and deterministic: a root layout applies its selector separately for
key and view. The identity root, the default when no root layout is
installed, stays unchanged and does not evaluate the selector.
This compiled example shares one document across public and account contexts, keeps a layout through a later aspect, and overrides the root for one route. Its fixed context aspects demonstrate composition, not authentication:
object LayoutContextExample:
final case class DocumentContext(language: String)
final case class PublicContext(document: DocumentContext)
final case class AccountContext(document: DocumentContext)
final class EventlessPage(content: String, title: String) extends LiveView.Eventless[String]:
def mount(ctx: MountContext) = ZIO.succeed(content)
override def pageTitle(model: String): Option[String] = Some(title)
def view(model: Signal[String]) = div(idAttr := "page", model)
object DocumentLayout extends LiveLayout[Any, DocumentContext]:
def view[Msg](
content: HtmlElement[Msg],
ctx: LiveLayoutContext[Any, DocumentContext]
): HtmlElement[Msg] =
sectionTag(
idAttr := s"document-${ctx.context.language}",
dataAttr("params") := ctx.params.map(_.toString),
dataAttr("request-url") := ctx.request.map(_.url.encode),
dataAttr("current-url") := ctx.currentUrl.map(_.encode),
content
)
object DocumentRoot extends LiveRootLayout[Any, DocumentContext]:
override def key(ctx: LiveRootLayoutContext[Any, DocumentContext]): String =
s"document:${ctx.context.language}"
def view[Msg](
content: HtmlElement[Msg],
pageTitle: Option[String],
ctx: LiveRootLayoutContext[Any, DocumentContext]
): HtmlElement[Msg] =
htmlRootTag(
lang := ctx.context.language,
headTag(titleTag(pageTitle.getOrElse("Scalive"))),
bodyTag(
dataAttr("params") := ctx.params.toString,
dataAttr("request-url") := ctx.request.url.encode,
dataAttr("current-url") := ctx.currentUrl.encode,
content
)
)
object ApplicationLayout extends LiveLayout[Any, Any]:
def view[Msg](content: HtmlElement[Msg], ctx: LiveLayoutContext[Any, Any]): HtmlElement[Msg] =
mainTag(idAttr := "application", content)
object ApplicationRoot extends LiveRootLayout[Any, Any]:
def view[Msg](
content: HtmlElement[Msg],
pageTitle: Option[String],
ctx: LiveRootLayoutContext[Any, Any]
): HtmlElement[Msg] =
htmlRootTag(
lang := "en",
headTag(titleTag(pageTitle.getOrElse("Scalive"))),
bodyTag(content)
)
val publicContext = LiveSessionMountAspect.fromRequest[Any, String, PublicContext](
_ => ZIO.succeed("fr" -> PublicContext(DocumentContext("fr"))),
(language, _) => ZIO.succeed(PublicContext(DocumentContext(language)))
)
val accountContext = LiveSessionMountAspect.fromRequest[Any, String, AccountContext](
_ => ZIO.succeed("de" -> AccountContext(DocumentContext("de"))),
(language, _) => ZIO.succeed(AccountContext(DocumentContext(language)))
)
val accountAudit = LiveSessionMountAspect.make[Any, AccountContext, String, String](
(_, _) => ZIO.succeed("audited" -> "audited"),
(claim, _, _) => ZIO.succeed(claim)
)
val accountVariant =
LiveRouteMountAspect.make[Any, Unit, (AccountContext, String), DocumentContext]((_, _) =>
ZIO.succeed(DocumentContext("de-CH"))
)
val defaultRoute = scalive.live / "default" -> EventlessPage("default", "Default title")
val publicRoute = (scalive.live / "public").context { (context: PublicContext) =>
EventlessPage(s"public-${context.document.language}", "Public title")
}
val publicSession = scalive.Live
.session("public")
.withMountAspect(publicContext)
.withLayout(DocumentLayout.forContext[PublicContext](_.document))
.withRootLayout(DocumentRoot.forContext[PublicContext](_.document))(publicRoute)
val accountBuilder = scalive.Live
.session("account")
.withMountAspect(accountContext)
.withLayout(DocumentLayout, _.document)
.withRootLayout(DocumentRoot, _.document)
.withMountAspect(accountAudit)
val accountRoute = (scalive.live / "account").context { (context: (AccountContext, String)) =>
EventlessPage(s"account-${context._1.document.language}-${context._2}", "Account title")
}
val accountOverride = (scalive.live / "account" / "variant")
.withMountAspect(accountVariant)
.withLayout(DocumentLayout, _._2)
.withRootLayout(DocumentRoot, _._2)(EventlessPage("account-variant", "Variant title"))
val application = scalive.Live.router
.withLayout(ApplicationLayout)
.withRootLayout(ApplicationRoot)(
defaultRoute,
publicSession,
accountBuilder(accountRoute, accountOverride)
)
end LayoutContextExampleView source (documentation/site/src/scalive/docs/examples/LayoutContextExample.scala:8-117)
Related tasks
Group routes and derive typed context with Live sessions and mount aspects.
Derive the page title from the model with Flash messages, page titles, and connection status.
Add stylesheets and scripts to the document with Client setup and static assets.