Generate dom elements with scala-dom-types

This commit is contained in:
Paul-Henri Froidmont 2025-08-16 04:49:13 +02:00
parent f53a1cab66
commit ae0dc04a9e
Signed by: phfroidmont
GPG key ID: BE948AFD7E7873BE
15 changed files with 407 additions and 108 deletions

View file

@ -1,2 +1,24 @@
version = "3.7.17" version = "3.9.9"
runner.dialect = scala3 runner.dialect = scala3
preset=defaultWithAlign
assumeStandardLibraryStripMargin = true
maxColumn = 100
continuationIndent.callSite = 2
continuationIndent.defnSite = 2
align.arrowEnumeratorGenerator = true
align.openParenDefnSite = false
align.stripMargin = true
rewrite.rules = [RedundantBraces, Imports, RedundantParens, SortModifiers, PreferCurlyFors]
rewrite.redundantBraces.ifElseExpressions = true
rewrite.redundantBraces.stringInterpolation = true
verticalMultiline.atDefnSite = true
verticalMultiline.newlineAfterOpenParen = true
optIn.breaksInsideChains = true
lineEndings = unix
rewrite.scala3.convertToNewSyntax = true
rewrite.scala3.removeOptionalBraces = yes
rewrite.scala3.insertEndMarkerMinLines = 30
rewrite.scala3.removeEndMarkerMaxLines = 29

155
DomDefsGenerator.mill Normal file
View file

@ -0,0 +1,155 @@
package build
import com.raquo.domtypes.codegen.*
import com.raquo.domtypes.codegen.DefType.LazyVal
import com.raquo.domtypes.defs.styles.StyleTraitDefs
import com.raquo.domtypes.codegen.generators.*
import com.raquo.domtypes.common.*
import com.raquo.domtypes.defs.reflectedAttrs.ReflectedHtmlAttrDefs
class DomDefsGenerator(baseOutputDirectoryPath: String):
private object generator
extends CanonicalGenerator(
baseOutputDirectoryPath = baseOutputDirectoryPath,
basePackagePath = "scalive",
standardTraitCommentLines = List(
"#NOTE: GENERATED CODE",
s" - This file is generated at compile time from the data in Scala DOM Types",
" - See `DomDefsGenerator.mill` for code generation params",
" - Contribute to https://github.com/raquo/scala-dom-types to add missing tags / attrs / props / etc."
),
format = CodeFormatting()
):
override def settersPackagePath: String = basePackagePath + ".modifiers.KeySetter"
override def scalaJsElementTypeParam: String = "Ref"
override def scalaJsDomImport: String = ""
override def tagKeysPackagePath: String = "scalive"
override def keysPackagePath: String = "scalive"
override def generateTagsTrait(
tagType: TagType,
defGroups: List[(String, List[TagDef])],
printDefGroupComments: Boolean,
traitCommentLines: List[String],
traitModifiers: List[String],
traitName: String,
keyKind: String,
baseImplDefComments: List[String],
keyImplName: String,
defType: DefType
): String =
val (defs, defGroupComments) = defsAndGroupComments(defGroups, printDefGroupComments)
val baseImplDef =
if tagType == HtmlTagType then
List(
s"def $keyImplName($keyImplNameArgName: String, void: Boolean = false): $keyKind = ${keyKindConstructor(keyKind)}($keyImplNameArgName, void)"
)
else
List(
s"def $keyImplName($keyImplNameArgName: String): $keyKind = ${keyKindConstructor(keyKind)}($keyImplNameArgName)"
)
val headerLines = List(
s"package $tagDefsPackagePath",
"",
tagKeyTypeImport(keyKind),
scalaJsDomImport,
""
) ++ standardTraitCommentLines.map("// " + _)
new TagsTraitGenerator(
defs = defs,
defGroupComments = defGroupComments,
headerLines = headerLines,
traitCommentLines = traitCommentLines,
traitModifiers = traitModifiers,
traitName = traitName,
traitExtends = Nil,
traitThisType = None,
defType = _ => defType,
keyType = tag => keyKind,
keyImplName = _ => keyImplName,
keyImplNameArgName = keyImplNameArgName,
baseImplDefComments = baseImplDefComments,
baseImplDef = baseImplDef,
outputImplDefs = true,
format = format
).printTrait().getOutput()
end generateTagsTrait
end generator
def generate(): Unit =
val defGroups = new CanonicalDefGroups()
// -- HTML tags --
{
val traitName = "HtmlTags"
val fileContent = generator.generateTagsTrait(
tagType = HtmlTagType,
defGroups = defGroups.htmlTagsDefGroups,
printDefGroupComments = true,
traitCommentLines = Nil,
traitModifiers = Nil,
traitName = traitName,
keyKind = "HtmlTag",
baseImplDefComments = List(
"Create HTML tag",
"",
"Note: this simply creates an instance of HtmlTag.",
" - This does not create the element (to do that, call .apply() on the returned tag instance)",
"",
"@param name - e.g. \"div\" or \"mwc-input\""
),
keyImplName = "htmlTag",
defType = LazyVal
)
generator.writeToFile(
packagePath = generator.tagDefsPackagePath,
fileName = traitName,
fileContent = fileContent
)
}
// -- HTML attributes --
{
val traitName = "HtmlAttrs"
val fileContent = generator.generateAttrsTrait(
defGroups = defGroups.htmlAttrDefGroups.appended(
"Reflected Attributes" -> ReflectedHtmlAttrDefs.defs.map(_.toAttrDef)
),
printDefGroupComments = false,
traitCommentLines = Nil,
traitModifiers = Nil,
traitName = traitName,
keyKind = "HtmlAttr",
implNameSuffix = "HtmlAttr",
baseImplDefComments = List(
"Create HTML attribute (Note: for SVG attrs, use L.svg.svgAttr)",
"",
"@param name - name of the attribute, e.g. \"value\"",
"@param codec - used to encode V into String, e.g. StringAsIsCodec",
"",
"@tparam V - value type for this attr in Scala"
),
baseImplName = "htmlAttr",
namespaceImports = Nil,
namespaceImpl = _ => ???,
transformAttrDomName = identity,
defType = LazyVal
)
generator.writeToFile(
packagePath = generator.attrDefsPackagePath,
fileName = traitName,
fileContent = fileContent
)
}
end generate
end DomDefsGenerator

View file

@ -1,16 +1,25 @@
//| mvnDeps : ["com.raquo::domtypes:18.1.0"]
package build package build
import mill.*, scalalib.* import mill.*, scalalib.*
import mill.api.Task.Simple
trait Common extends ScalaModule: trait Common extends ScalaModule:
def scalaVersion = "3.7.2" def scalaVersion = "3.7.2"
def scalacOptions = Seq("-Wunused:all") def scalacOptions = Seq("-Wunused:all")
object core extends Common: object core extends Common:
def mvnDeps = Seq(mvn"dev.zio::zio-json:0.7.44") def mvnDeps = Seq(mvn"dev.zio::zio-json:0.7.44")
def generatedSources = Task {
new DomDefsGenerator((Task.dest / "core/src/scalive").toString).generate()
super.generatedSources() ++ Seq(PathRef(Task.dest))
}
object test extends ScalaTests with TestModule.Utest: object test extends ScalaTests with TestModule.Utest:
def utestVersion = "0.9.0" def utestVersion = "0.9.0"
object zio extends Common: object zio extends Common:
def mvnDeps = Seq(mvn"dev.zio::zio-http:3.4.0") def mvnDeps = Seq(mvn"dev.zio::zio-http:3.4.0")
override def moduleDeps = Seq(core) override def moduleDeps = Seq(core)

View file

@ -6,13 +6,11 @@ import zio.json.ast.Json
enum Diff: enum Diff:
case Tag( case Tag(
static: Seq[String] = Seq.empty, static: Seq[String] = Seq.empty,
dynamic: Seq[Diff.Dynamic] = Seq.empty dynamic: Seq[Diff.Dynamic] = Seq.empty)
)
case Split( case Split(
static: Seq[String] = Seq.empty, static: Seq[String] = Seq.empty,
entries: Seq[Diff.Dynamic] = Seq.empty entries: Seq[Diff.Dynamic] = Seq.empty)
)
case Static(value: String) case Static(value: String)
case Dynamic(index: Int, diff: Diff) case Dynamic(index: Int, diff: Diff)
case Deleted case Deleted
@ -45,7 +43,8 @@ object Diff:
) )
) )
) )
case Diff.Static(value) => Json.Str(value) case Diff.Static(value) => Json.Str(value)
case Diff.Dynamic(index, diff) => case Diff.Dynamic(index, diff) =>
Json.Obj(index.toString -> toJson(diff)) Json.Obj(index.toString -> toJson(diff))
case Diff.Deleted => Json.Bool(false) case Diff.Deleted => Json.Bool(false)
end Diff

View file

@ -2,9 +2,9 @@ package scalive
object DiffBuilder: object DiffBuilder:
def build( def build(
static: Seq[String], static: Seq[String],
dynamic: Seq[LiveDyn[?]], dynamic: Seq[LiveDyn[?]],
includeUnchanged: Boolean = false includeUnchanged: Boolean = false
): Diff.Tag = ): Diff.Tag =
Diff.Tag( Diff.Tag(
static = static, static = static,
@ -13,16 +13,16 @@ object DiffBuilder:
.map { .map {
case (v: LiveDyn.Value[?, ?], i) => case (v: LiveDyn.Value[?, ?], i) =>
Diff.Dynamic(i, Diff.Static(v.currentValue.toString)) Diff.Dynamic(i, Diff.Static(v.currentValue.toString))
case (v: LiveDyn.When[?], i) => build(v, i, includeUnchanged) case (v: LiveDyn.When[?], i) => build(v, i, includeUnchanged)
case (v: LiveDyn.Split[?, ?], i) => case (v: LiveDyn.Split[?, ?], i) =>
Diff.Dynamic(i, build(v, includeUnchanged)) Diff.Dynamic(i, build(v, includeUnchanged))
} }
) )
private def build( private def build(
mod: LiveDyn.When[?], mod: LiveDyn.When[?],
index: Int, index: Int,
includeUnchanged: Boolean includeUnchanged: Boolean
): Diff.Dynamic = ): Diff.Dynamic =
if mod.displayed then if mod.displayed then
if includeUnchanged || mod.cond.wasUpdated then if includeUnchanged || mod.cond.wasUpdated then
@ -46,8 +46,8 @@ object DiffBuilder:
else Diff.Dynamic(index, Diff.Deleted) else Diff.Dynamic(index, Diff.Deleted)
private def build( private def build(
mod: LiveDyn.Split[?, ?], mod: LiveDyn.Split[?, ?],
includeUnchanged: Boolean includeUnchanged: Boolean
): Diff.Split = ): Diff.Split =
Diff.Split( Diff.Split(
static = if includeUnchanged then mod.static else Seq.empty, static = if includeUnchanged then mod.static else Seq.empty,
@ -68,3 +68,4 @@ object DiffBuilder:
.map(i => Diff.Dynamic(i, Diff.Deleted)) .map(i => Diff.Dynamic(i, Diff.Deleted))
) )
) )
end DiffBuilder

View file

@ -10,9 +10,9 @@ object HtmlBuilder:
strw.toString() strw.toString()
private def build( private def build(
static: Seq[String], static: Seq[String],
dynamic: Seq[LiveDyn[?]], dynamic: Seq[LiveDyn[?]],
strw: StringWriter strw: StringWriter
): Unit = ): Unit =
for i <- dynamic.indices do for i <- dynamic.indices do
strw.append(static(i)) strw.append(static(i))

View file

@ -9,13 +9,12 @@ sealed trait LiveDyn[Model]:
object LiveDyn: object LiveDyn:
class Value[I, O](d: Dyn[I, O], init: I, startsUpdated: Boolean = false) class Value[I, O](d: Dyn[I, O], init: I, startsUpdated: Boolean = false) extends LiveDyn[I]:
extends LiveDyn[I]: private var value: O = d.run(init)
private var value: O = d.run(init)
private var updated: Boolean = startsUpdated private var updated: Boolean = startsUpdated
def wasUpdated: Boolean = updated def wasUpdated: Boolean = updated
def currentValue: O = value def currentValue: O = value
def update(v: I): Unit = def update(v: I): Unit =
val newValue = d.run(v) val newValue = d.run(v)
if value == newValue then updated = false if value == newValue then updated = false
else else
@ -23,25 +22,25 @@ object LiveDyn:
updated = true updated = true
class When[Model]( class When[Model](
dynCond: Dyn[Model, Boolean], dynCond: Dyn[Model, Boolean],
el: HtmlElement[Model], el: HtmlElement[Model],
init: Model init: Model)
) extends LiveDyn[Model]: extends LiveDyn[Model]:
val cond = LiveDyn.Value(dynCond, init) val cond = LiveDyn.Value(dynCond, init)
val nested = LiveView.render(el, init) val nested = LiveView.render(el, init)
def displayed: Boolean = cond.currentValue def displayed: Boolean = cond.currentValue
def wasUpdated: Boolean = cond.wasUpdated || nested.wasUpdated def wasUpdated: Boolean = cond.wasUpdated || nested.wasUpdated
def update(model: Model): Unit = def update(model: Model): Unit =
cond.update(model) cond.update(model)
nested.update(model) nested.update(model)
class Split[Model, Item]( class Split[Model, Item](
dynList: Dyn[Model, List[Item]], dynList: Dyn[Model, List[Item]],
project: Dyn[Item, Item] => HtmlElement[Item], project: Dyn[Item, Item] => HtmlElement[Item],
init: Model init: Model)
) extends LiveDyn[Model]: extends LiveDyn[Model]:
private val el = project(Dyn.id) private val el = project(Dyn.id)
val static: ArraySeq[String] = LiveView.buildStatic(el) val static: ArraySeq[String] = LiveView.buildStatic(el)
val dynamic: ArrayBuffer[ArraySeq[LiveDyn[Item]]] = val dynamic: ArrayBuffer[ArraySeq[LiveDyn[Item]]] =
dynList dynList
.run(init) .run(init)
@ -65,3 +64,5 @@ object LiveDyn:
) )
else dynamic(i).foreach(_.update(item)) else dynamic(i).foreach(_.update(item))
) )
end Split
end LiveDyn

View file

@ -3,11 +3,11 @@ package scalive
import scala.annotation.nowarn import scala.annotation.nowarn
import scala.collection.immutable.ArraySeq import scala.collection.immutable.ArraySeq
import scala.collection.mutable.ListBuffer import scala.collection.mutable.ListBuffer
import scalive.codecs.BooleanAsAttrPresenceCodec
class LiveView[Model] private ( class LiveView[Model] private (
val static: ArraySeq[String], val static: ArraySeq[String],
val dynamic: ArraySeq[LiveDyn[Model]] val dynamic: ArraySeq[LiveDyn[Model]]):
):
assert( assert(
static.size == dynamic.size + 1, static.size == dynamic.size + 1,
s"Static size : ${static.size}, Dynamic size : ${dynamic.size}" s"Static size : ${static.size}, Dynamic size : ${dynamic.size}"
@ -26,14 +26,14 @@ class LiveView[Model] private (
object LiveView: object LiveView:
inline def apply[Model]( inline def apply[Model](
lv: View[Model], lv: View[Model],
model: Model model: Model
): LiveView[Model] = ): LiveView[Model] =
render(lv.root, model) render(lv.root, model)
def render[Model]( def render[Model](
tag: HtmlElement[Model], tag: HtmlElement[Model],
model: Model model: Model
): LiveView[Model] = ): LiveView[Model] =
new LiveView(buildStatic(tag), buildDynamic(tag, model).to(ArraySeq)) new LiveView(buildStatic(tag), buildDynamic(tag, model).to(ArraySeq))
@ -41,11 +41,11 @@ object LiveView:
buildStaticFragments(el).flatten.to(ArraySeq) buildStaticFragments(el).flatten.to(ArraySeq)
private def buildStaticFragments[Model]( private def buildStaticFragments[Model](
el: HtmlElement[Model] el: HtmlElement[Model]
): Seq[Option[String]] = ): Seq[Option[String]] =
val (attrs, children) = buildStaticFragmentsByType(el) val (attrs, children) = buildStaticFragmentsByType(el)
val static = ListBuffer.empty[Option[String]] val static = ListBuffer.empty[Option[String]]
var staticFragment = s"<${el.tag.name}" var staticFragment = s"<${el.tag.name}"
for attr <- attrs do for attr <- attrs do
attr match attr match
case Some(s) => case Some(s) =>
@ -54,7 +54,7 @@ object LiveView:
static.append(Some(staticFragment)) static.append(Some(staticFragment))
static.append(None) static.append(None)
staticFragment = "" staticFragment = ""
staticFragment += s">" staticFragment += (if el.tag.void then "/>" else ">")
for child <- children do for child <- children do
child match child match
case Some(s) => case Some(s) =>
@ -63,19 +63,22 @@ object LiveView:
static.append(Some(staticFragment)) static.append(Some(staticFragment))
static.append(None) static.append(None)
staticFragment = "" staticFragment = ""
staticFragment += s"</${el.tag.name}>" staticFragment += (if el.tag.void then "" else s"</${el.tag.name}>")
static.append(Some(staticFragment)) static.append(Some(staticFragment))
static.toSeq static.toSeq
@nowarn("cat=unchecked") @nowarn("cat=unchecked")
private def buildStaticFragmentsByType[Model]( private def buildStaticFragmentsByType[Model](
el: HtmlElement[Model] el: HtmlElement[Model]
): (attrs: Seq[Option[String]], children: Seq[Option[String]]) = ): (attrs: Seq[Option[String]], children: Seq[Option[String]]) =
val (attrs, children) = el.mods.partitionMap { val (attrs, children) = el.mods.partitionMap {
case Mod.StaticAttr(attr, value) => case Mod.StaticAttr(attr, value) => Left(List(Some(s""" ${attr.name}="$value"""")))
Left(List(Some(s""" ${attr.name}="$value""""))) case Mod.StaticAttrValueAsPresence(attr, true) => Left(List(Some(s" ${attr.name}")))
case Mod.DynAttr(attr, _) => case Mod.StaticAttrValueAsPresence(attr, false) => Left(List.empty)
case Mod.DynAttr(attr, _) =>
Left(List(Some(s""" ${attr.name}=""""), None, Some('"'.toString))) Left(List(Some(s""" ${attr.name}=""""), None, Some('"'.toString)))
case Mod.DynAttrValueAsPresence(attr, _) =>
Left(List(Some(""), None, Some("")))
case Mod.Tag(el) => Right(buildStaticFragments(el)) case Mod.Tag(el) => Right(buildStaticFragments(el))
case Mod.Text(text) => Right(List(Some(text))) case Mod.Text(text) => Right(List(Some(text)))
case Mod.DynText[Model](_) => Right(List(None)) case Mod.DynText[Model](_) => Right(List(None))
@ -86,15 +89,18 @@ object LiveView:
@nowarn("cat=unchecked") @nowarn("cat=unchecked")
def buildDynamic[Model]( def buildDynamic[Model](
el: HtmlElement[Model], el: HtmlElement[Model],
model: Model, model: Model,
startsUpdated: Boolean = false startsUpdated: Boolean = false
): Seq[LiveDyn[Model]] = ): Seq[LiveDyn[Model]] =
val (attrs, children) = el.mods.partitionMap { val (attrs, children) = el.mods.partitionMap {
case Mod.Text(_) => Right(List.empty) case Mod.Text(_) => Right(List.empty)
case Mod.StaticAttr(_, _) => Left(List.empty) case Mod.StaticAttr(_, _) => Left(List.empty)
case Mod.DynAttr(_, value) => case Mod.StaticAttrValueAsPresence(_, _) => Left(List.empty)
Right(List(LiveDyn.Value(value, model, startsUpdated))) case Mod.DynAttr(attr, value) =>
Right(List(LiveDyn.Value(value(attr.codec.encode), model, startsUpdated)))
case Mod.DynAttrValueAsPresence(attr, value) =>
Right(List(LiveDyn.Value(value(if _ then s" ${attr.name}" else ""), model, startsUpdated)))
case Mod.Tag(el) => case Mod.Tag(el) =>
Right(buildDynamic(el, model, startsUpdated)) Right(buildDynamic(el, model, startsUpdated))
case Mod.DynText[Model](dynText) => case Mod.DynText[Model](dynText) =>
@ -105,3 +111,4 @@ object LiveView:
Right(List(LiveDyn.Split(dynList, project, model))) Right(List(LiveDyn.Split(dynList, project, model)))
} }
attrs.flatten ++ children.flatten attrs.flatten ++ children.flatten
end LiveView

View file

@ -0,0 +1,9 @@
package scalive
import scalive.defs.tags.HtmlTags
import scalive.defs.attrs.HtmlAttrs
import scalive.defs.complex.ComplexHtmlKeys
object Scalive extends HtmlTags with HtmlAttrs with ComplexHtmlKeys
export Scalive.*

View file

@ -1,5 +1,8 @@
package scalive package scalive
import scalive.codecs.Codec
import scalive.codecs.BooleanAsAttrPresenceCodec
trait View[Model]: trait View[Model]:
val model: Dyn[Model, Model] = Dyn.id val model: Dyn[Model, Model] = Dyn.id
def root: HtmlElement[Model] def root: HtmlElement[Model]
@ -14,8 +17,10 @@ extension [I, O](d: Dyn[I, O])
inline def whenNot(f: O => Boolean)(el: HtmlElement[I]): Mod.When[I] = inline def whenNot(f: O => Boolean)(el: HtmlElement[I]): Mod.When[I] =
when(f.andThen(!_))(el) when(f.andThen(!_))(el)
def splitByIndex[O2](f: O => List[O2])( def splitByIndex[O2](
project: Dyn[O2, O2] => HtmlElement[O2] f: O => List[O2]
)(
project: Dyn[O2, O2] => HtmlElement[O2]
): Mod.Split[I, O2] = ): Mod.Split[I, O2] =
Mod.Split(d.andThen(f), project) Mod.Split(d.andThen(f), project)
@ -25,34 +30,37 @@ object Dyn:
def id[T]: Dyn[T, T] = identity def id[T]: Dyn[T, T] = identity
enum Mod[T]: enum Mod[T]:
case StaticAttr(attr: HtmlAttr, value: String) case StaticAttr(attr: HtmlAttr[?], value: String)
case DynAttr(attr: HtmlAttr, value: Dyn[T, String]) case StaticAttrValueAsPresence(attr: HtmlAttr[?], value: Boolean)
case DynAttr[T, V](attr: HtmlAttr[V], value: Dyn[T, V]) extends Mod[T]
case DynAttrValueAsPresence[T, V](attr: HtmlAttr[V], value: Dyn[T, Boolean]) extends Mod[T]
case Tag(el: HtmlElement[T]) case Tag(el: HtmlElement[T])
case Text(text: String) case Text(text: String)
case DynText(dynText: Dyn[T, String]) case DynText(dynText: Dyn[T, String])
case When(dynCond: Dyn[T, Boolean], el: HtmlElement[T]) case When(dynCond: Dyn[T, Boolean], el: HtmlElement[T])
case Split[T, O]( case Split[T, O](
dynList: Dyn[T, List[O]], dynList: Dyn[T, List[O]],
project: Dyn[O, O] => HtmlElement[O] project: Dyn[O, O] => HtmlElement[O]) extends Mod[T]
) extends Mod[T]
given [T]: Conversion[HtmlElement[T], Mod[T]] = Mod.Tag(_) given [T]: Conversion[HtmlElement[T], Mod[T]] = Mod.Tag(_)
given [T]: Conversion[String, Mod[T]] = Mod.Text(_) given [T]: Conversion[String, Mod[T]] = Mod.Text(_)
given [T]: Conversion[Dyn[T, String], Mod[T]] = Mod.DynText(_) given [T]: Conversion[Dyn[T, String], Mod[T]] = Mod.DynText(_)
class HtmlTag(val name: String): class HtmlTag(val name: String, val void: Boolean = false):
def apply[Model](mods: Mod[Model]*): HtmlElement[Model] = def apply[Model](mods: Mod[Model]*): HtmlElement[Model] =
HtmlElement(this, mods.toVector) HtmlElement(this, mods.toVector)
class HtmlElement[Model](val tag: HtmlTag, val mods: Vector[Mod[Model]]) class HtmlElement[Model](val tag: HtmlTag, val mods: Vector[Mod[Model]])
val div = HtmlTag("div") class HtmlAttr[V](val name: String, val codec: Codec[V, String]):
val ul = HtmlTag("ul") val isBooleanAsAttrPresence = codec == BooleanAsAttrPresenceCodec
val li = HtmlTag("li") def :=[T](value: V): Mod[T] =
val stringValue = codec.encode(value)
class HtmlAttr(val name: String): if isBooleanAsAttrPresence then
def :=[T](value: String): Mod.StaticAttr[T] = Mod.StaticAttr(this, value) Mod.StaticAttrValueAsPresence(this, BooleanAsAttrPresenceCodec.decode(stringValue))
def :=[T](value: Dyn[T, String]): Mod.DynAttr[T] = Mod.DynAttr(this, value) else Mod.StaticAttr(this, stringValue)
def :=[T](value: Dyn[T, V]): Mod[T] =
val idAttr = HtmlAttr("id") val stringDyn = value(codec.encode)
val cls = HtmlAttr("class") if isBooleanAsAttrPresence then
Mod.DynAttrValueAsPresence(this, stringDyn(BooleanAsAttrPresenceCodec.decode))
else Mod.DynAttr(this, value)

View file

@ -0,0 +1,29 @@
package scalive.codecs
class Codec[ScalaType, DomType](val encode: ScalaType => DomType, val decode: DomType => ScalaType)
def AsIsCodec[V](): Codec[V, V] = Codec(identity, identity)
val StringAsIsCodec: Codec[String, String] = AsIsCodec()
val IntAsIsCodec: Codec[Int, Int] = AsIsCodec()
lazy val IntAsStringCodec: Codec[Int, String] = Codec[Int, String](_.toString, _.toInt)
lazy val DoubleAsIsCodec: Codec[Double, Double] = AsIsCodec()
lazy val DoubleAsStringCodec: Codec[Double, String] = Codec[Double, String](_.toString, _.toDouble)
val BooleanAsIsCodec: Codec[Boolean, Boolean] = AsIsCodec()
lazy val BooleanAsAttrPresenceCodec: Codec[Boolean, String] =
Codec[Boolean, String](if _ then "" else null, _ != null)
lazy val BooleanAsTrueFalseStringCodec: Codec[Boolean, String] =
Codec[Boolean, String](if _ then "true" else "false", _ == "true")
lazy val BooleanAsYesNoStringCodec: Codec[Boolean, String] =
Codec[Boolean, String](if _ then "yes" else "no", _ == "yes")
lazy val BooleanAsOnOffStringCodec: Codec[Boolean, String] =
Codec[Boolean, String](if _ then "on" else "off", _ == "on")

View file

@ -0,0 +1,63 @@
package scalive.defs.complex
import scalive.HtmlAttr
import scalive.codecs.*
// TODO implement composite keys
trait ComplexHtmlKeys:
// #Note: we use attrs instead of props here because of https://github.com/raquo/Laminar/issues/136
/** This attribute is a list of the classes of the element. Classes allow CSS and Javascript to
* select and access specific elements via the class selectors or functions like the DOM method
* document.getElementsByClassName
*/
val className: HtmlAttr[String] = new HtmlAttr("class", StringAsIsCodec)
val cls: HtmlAttr[String] = className
/** This attribute names a relationship of the linked document to the current document. The
* attribute must be a space-separated list of the link types values. The most common use of this
* attribute is to specify a link to an external style sheet: the rel attribute is set to
* stylesheet, and the href attribute is set to the URL of an external style sheet to format the
* page.
*/
lazy val rel: HtmlAttr[String] = new HtmlAttr("rel", StringAsIsCodec)
/** The attribute describes the role(s) the current element plays in the context of the document.
* This can be used, for example, by applications and assistive technologies to determine the
* purpose of an element. This could allow a user to make informed decisions on which actions may
* be taken on an element and activate the selected action in a device independent way. It could
* also be used as a mechanism for annotating portions of a document in a domain specific way
* (e.g., a legal term taxonomy). Although the role attribute may be used to add semantics to an
* element, authors should use elements with inherent semantics, such as p, rather than layering
* semantics on semantically neutral elements, such as div role="paragraph".
*
* See: [[http://www.w3.org/TR/role-attribute/#s_role_module_attributes]]
*/
lazy val role: HtmlAttr[String] = new HtmlAttr("role", StringAsIsCodec)
/** This class of attributes, called custom data attributes, allows proprietary information to be
* exchanged between the HTML and its DOM representation that may be used by scripts. All such
* custom data are available via the HTMLElement interface of the element the attribute is set
* on. The HTMLElement.dataset property gives access to them.
*
* The `suffix` is subject to the following restrictions:
*
* must not start with xml, whatever case is used for these letters; must not contain any
* semicolon (U+003A); must not contain capital A to Z letters.
*
* Note that the HTMLElement.dataset attribute is a StringMap and the name of the custom data
* attribute data-test-value will be accessible via HTMLElement.dataset.testValue as any dash
* (U+002D) is replaced by the capitalization of the next letter (camelcase).
*/
def dataAttr(suffix: String): HtmlAttr[String] = new HtmlAttr(s"data-$suffix", StringAsIsCodec)
/** This attribute contains CSS styling declarations to be applied to the element. Note that it is
* recommended for styles to be defined in a separate file or files. This attribute and the style
* element have mainly the purpose of allowing for quick styling, for example for testing
* purposes.
*/
lazy val styleAttr: HtmlAttr[String] = new HtmlAttr("style", StringAsIsCodec)
end ComplexHtmlKeys

View file

@ -60,19 +60,22 @@ def main =
println("Remove all") println("Remove all")
lv.update( lv.update(
MyModel(List.empty, "text-lg") MyModel(List.empty, "text-lg", bool = false)
) )
println(lv.diff.toJsonPretty) println(lv.diff.toJsonPretty)
println(HtmlBuilder.build(lv)) println(HtmlBuilder.build(lv))
end main
final case class MyModel(elems: List[NestedModel], cls: String = "text-xs") final case class MyModel(elems: List[NestedModel], cls: String = "text-xs", bool: Boolean = true)
final case class NestedModel(name: String, age: Int) final case class NestedModel(name: String, age: Int)
object TestView extends View[MyModel]: object TestView extends View[MyModel]:
val root: HtmlElement[MyModel] = val root: HtmlElement[MyModel] =
div( div(
idAttr := "42", idAttr := "42",
cls := model(_.cls), cls := model(_.cls),
draggable := model(_.bool),
disabled := model(_.bool),
ul( ul(
model.splitByIndex(_.elems)(elem => model.splitByIndex(_.elems)(elem =>
li( li(

View file

@ -7,12 +7,11 @@ import zio.json.ast.Json
object LiveViewSpec extends TestSuite: object LiveViewSpec extends TestSuite:
final case class TestModel( final case class TestModel(
title: String = "title value", title: String = "title value",
bool: Boolean = false, bool: Boolean = false,
nestedTitle: String = "nested title value", nestedTitle: String = "nested title value",
cls: String = "text-sm", cls: String = "text-sm",
items: List[NestedModel] = List.empty items: List[NestedModel] = List.empty)
)
final case class NestedModel(name: String, age: Int) final case class NestedModel(name: String, age: Int)
def assertEqualsJson(actual: Diff, expected: Json) = def assertEqualsJson(actual: Diff, expected: Json) =
@ -245,9 +244,7 @@ object LiveViewSpec extends TestSuite:
} }
test("diff with item changed") { test("diff with item changed") {
lv.update( lv.update(
initModel.copy(items = initModel.copy(items = initModel.items.updated(2, NestedModel("c", 99)))
initModel.items.updated(2, NestedModel("c", 99))
)
) )
assertEqualsJson( assertEqualsJson(
lv.diff, lv.diff,
@ -329,3 +326,4 @@ object LiveViewSpec extends TestSuite:
} }
} }
} }
end LiveViewSpec

View file

@ -1,18 +1,13 @@
package scalive package scalive
import zio._ import zio.*
import zio.http.ChannelEvent.{ import zio.http.ChannelEvent.{ExceptionCaught, Read, UserEvent, UserEventTriggered}
ExceptionCaught, import zio.http.*
Read,
UserEvent,
UserEventTriggered
}
import zio.http._
import zio.http.codec.PathCodec.string import zio.http.codec.PathCodec.string
import zio.http.template.Html import zio.http.template.Html
object Example extends ZIOAppDefault { object Example extends ZIOAppDefault:
val lv = val lv =
LiveView( LiveView(
@ -77,7 +72,7 @@ object Example extends ZIOAppDefault {
) )
override val run = Server.serve(routes).provide(Server.default) override val run = Server.serve(routes).provide(Server.default)
} end Example
final case class MyModel(elems: List[NestedModel], cls: String = "text-xs") final case class MyModel(elems: List[NestedModel], cls: String = "text-xs")
final case class NestedModel(name: String, age: Int) final case class NestedModel(name: String, age: Int)
@ -86,7 +81,7 @@ object TestView extends View[MyModel]:
val root: HtmlElement[MyModel] = val root: HtmlElement[MyModel] =
div( div(
idAttr := "42", idAttr := "42",
cls := model(_.cls), cls := model(_.cls),
ul( ul(
model.splitByIndex(_.elems)(elem => model.splitByIndex(_.elems)(elem =>
li( li(