From ae0dc04a9e821715f3e8269f63c689b67cf6d2eb Mon Sep 17 00:00:00 2001 From: Paul-Henri Froidmont Date: Sat, 16 Aug 2025 04:49:13 +0200 Subject: [PATCH] Generate dom elements with scala-dom-types --- .scalafmt.conf | 24 ++- DomDefsGenerator.mill | 155 ++++++++++++++++++ build.mill | 13 +- core/src/scalive/Diff.scala | 13 +- core/src/scalive/DiffBuilder.scala | 19 ++- core/src/scalive/HtmlBuilder.scala | 6 +- core/src/scalive/LiveDyn.scala | 41 ++--- core/src/scalive/LiveView.scala | 53 +++--- core/src/scalive/Scalive.scala | 9 + core/src/scalive/View.scala | 46 +++--- core/src/scalive/codecs/Codec.scala | 29 ++++ .../defs/complex/ComplexHtmlKeys.scala | 63 +++++++ core/src/scalive/main.scala | 11 +- core/test/src/scalive/LiveViewSpec.scala | 16 +- zio/src/scalive/Example.scala | 17 +- 15 files changed, 407 insertions(+), 108 deletions(-) create mode 100644 DomDefsGenerator.mill create mode 100644 core/src/scalive/Scalive.scala create mode 100644 core/src/scalive/codecs/Codec.scala create mode 100644 core/src/scalive/defs/complex/ComplexHtmlKeys.scala diff --git a/.scalafmt.conf b/.scalafmt.conf index 1bd044a..d0bbe37 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,2 +1,24 @@ -version = "3.7.17" +version = "3.9.9" 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 diff --git a/DomDefsGenerator.mill b/DomDefsGenerator.mill new file mode 100644 index 0000000..8ac180d --- /dev/null +++ b/DomDefsGenerator.mill @@ -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 diff --git a/build.mill b/build.mill index a11c080..d53f335 100644 --- a/build.mill +++ b/build.mill @@ -1,16 +1,25 @@ +//| mvnDeps : ["com.raquo::domtypes:18.1.0"] + package build + import mill.*, scalalib.* +import mill.api.Task.Simple trait Common extends ScalaModule: - def scalaVersion = "3.7.2" + def scalaVersion = "3.7.2" def scalacOptions = Seq("-Wunused:all") object core extends Common: 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: def utestVersion = "0.9.0" 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) diff --git a/core/src/scalive/Diff.scala b/core/src/scalive/Diff.scala index 1c8735d..037c22c 100644 --- a/core/src/scalive/Diff.scala +++ b/core/src/scalive/Diff.scala @@ -6,13 +6,11 @@ import zio.json.ast.Json enum Diff: case Tag( - static: Seq[String] = Seq.empty, - dynamic: Seq[Diff.Dynamic] = Seq.empty - ) + static: Seq[String] = Seq.empty, + dynamic: Seq[Diff.Dynamic] = Seq.empty) case Split( - static: Seq[String] = Seq.empty, - entries: Seq[Diff.Dynamic] = Seq.empty - ) + static: Seq[String] = Seq.empty, + entries: Seq[Diff.Dynamic] = Seq.empty) case Static(value: String) case Dynamic(index: Int, diff: Diff) 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) => Json.Obj(index.toString -> toJson(diff)) case Diff.Deleted => Json.Bool(false) +end Diff diff --git a/core/src/scalive/DiffBuilder.scala b/core/src/scalive/DiffBuilder.scala index a5ae4d6..731aee8 100644 --- a/core/src/scalive/DiffBuilder.scala +++ b/core/src/scalive/DiffBuilder.scala @@ -2,9 +2,9 @@ package scalive object DiffBuilder: def build( - static: Seq[String], - dynamic: Seq[LiveDyn[?]], - includeUnchanged: Boolean = false + static: Seq[String], + dynamic: Seq[LiveDyn[?]], + includeUnchanged: Boolean = false ): Diff.Tag = Diff.Tag( static = static, @@ -13,16 +13,16 @@ object DiffBuilder: .map { case (v: LiveDyn.Value[?, ?], i) => 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) => Diff.Dynamic(i, build(v, includeUnchanged)) } ) private def build( - mod: LiveDyn.When[?], - index: Int, - includeUnchanged: Boolean + mod: LiveDyn.When[?], + index: Int, + includeUnchanged: Boolean ): Diff.Dynamic = if mod.displayed then if includeUnchanged || mod.cond.wasUpdated then @@ -46,8 +46,8 @@ object DiffBuilder: else Diff.Dynamic(index, Diff.Deleted) private def build( - mod: LiveDyn.Split[?, ?], - includeUnchanged: Boolean + mod: LiveDyn.Split[?, ?], + includeUnchanged: Boolean ): Diff.Split = Diff.Split( static = if includeUnchanged then mod.static else Seq.empty, @@ -68,3 +68,4 @@ object DiffBuilder: .map(i => Diff.Dynamic(i, Diff.Deleted)) ) ) +end DiffBuilder diff --git a/core/src/scalive/HtmlBuilder.scala b/core/src/scalive/HtmlBuilder.scala index 8242a35..b3db457 100644 --- a/core/src/scalive/HtmlBuilder.scala +++ b/core/src/scalive/HtmlBuilder.scala @@ -10,9 +10,9 @@ object HtmlBuilder: strw.toString() private def build( - static: Seq[String], - dynamic: Seq[LiveDyn[?]], - strw: StringWriter + static: Seq[String], + dynamic: Seq[LiveDyn[?]], + strw: StringWriter ): Unit = for i <- dynamic.indices do strw.append(static(i)) diff --git a/core/src/scalive/LiveDyn.scala b/core/src/scalive/LiveDyn.scala index 7f21cae..07f9ea7 100644 --- a/core/src/scalive/LiveDyn.scala +++ b/core/src/scalive/LiveDyn.scala @@ -9,13 +9,12 @@ sealed trait LiveDyn[Model]: object LiveDyn: - class Value[I, O](d: Dyn[I, O], init: I, startsUpdated: Boolean = false) - extends LiveDyn[I]: - private var value: O = d.run(init) + class Value[I, O](d: Dyn[I, O], init: I, startsUpdated: Boolean = false) extends LiveDyn[I]: + private var value: O = d.run(init) private var updated: Boolean = startsUpdated - def wasUpdated: Boolean = updated - def currentValue: O = value - def update(v: I): Unit = + def wasUpdated: Boolean = updated + def currentValue: O = value + def update(v: I): Unit = val newValue = d.run(v) if value == newValue then updated = false else @@ -23,25 +22,25 @@ object LiveDyn: updated = true class When[Model]( - dynCond: Dyn[Model, Boolean], - el: HtmlElement[Model], - init: Model - ) extends LiveDyn[Model]: - val cond = LiveDyn.Value(dynCond, init) - val nested = LiveView.render(el, init) - def displayed: Boolean = cond.currentValue - def wasUpdated: Boolean = cond.wasUpdated || nested.wasUpdated + dynCond: Dyn[Model, Boolean], + el: HtmlElement[Model], + init: Model) + extends LiveDyn[Model]: + val cond = LiveDyn.Value(dynCond, init) + val nested = LiveView.render(el, init) + def displayed: Boolean = cond.currentValue + def wasUpdated: Boolean = cond.wasUpdated || nested.wasUpdated def update(model: Model): Unit = cond.update(model) nested.update(model) class Split[Model, Item]( - dynList: Dyn[Model, List[Item]], - project: Dyn[Item, Item] => HtmlElement[Item], - init: Model - ) extends LiveDyn[Model]: - private val el = project(Dyn.id) - val static: ArraySeq[String] = LiveView.buildStatic(el) + dynList: Dyn[Model, List[Item]], + project: Dyn[Item, Item] => HtmlElement[Item], + init: Model) + extends LiveDyn[Model]: + private val el = project(Dyn.id) + val static: ArraySeq[String] = LiveView.buildStatic(el) val dynamic: ArrayBuffer[ArraySeq[LiveDyn[Item]]] = dynList .run(init) @@ -65,3 +64,5 @@ object LiveDyn: ) else dynamic(i).foreach(_.update(item)) ) + end Split +end LiveDyn diff --git a/core/src/scalive/LiveView.scala b/core/src/scalive/LiveView.scala index b59c887..b27b38c 100644 --- a/core/src/scalive/LiveView.scala +++ b/core/src/scalive/LiveView.scala @@ -3,11 +3,11 @@ package scalive import scala.annotation.nowarn import scala.collection.immutable.ArraySeq import scala.collection.mutable.ListBuffer +import scalive.codecs.BooleanAsAttrPresenceCodec class LiveView[Model] private ( - val static: ArraySeq[String], - val dynamic: ArraySeq[LiveDyn[Model]] -): + val static: ArraySeq[String], + val dynamic: ArraySeq[LiveDyn[Model]]): assert( static.size == dynamic.size + 1, s"Static size : ${static.size}, Dynamic size : ${dynamic.size}" @@ -26,14 +26,14 @@ class LiveView[Model] private ( object LiveView: inline def apply[Model]( - lv: View[Model], - model: Model + lv: View[Model], + model: Model ): LiveView[Model] = render(lv.root, model) def render[Model]( - tag: HtmlElement[Model], - model: Model + tag: HtmlElement[Model], + model: Model ): LiveView[Model] = new LiveView(buildStatic(tag), buildDynamic(tag, model).to(ArraySeq)) @@ -41,11 +41,11 @@ object LiveView: buildStaticFragments(el).flatten.to(ArraySeq) private def buildStaticFragments[Model]( - el: HtmlElement[Model] + el: HtmlElement[Model] ): Seq[Option[String]] = val (attrs, children) = buildStaticFragmentsByType(el) - val static = ListBuffer.empty[Option[String]] - var staticFragment = s"<${el.tag.name}" + val static = ListBuffer.empty[Option[String]] + var staticFragment = s"<${el.tag.name}" for attr <- attrs do attr match case Some(s) => @@ -54,7 +54,7 @@ object LiveView: static.append(Some(staticFragment)) static.append(None) staticFragment = "" - staticFragment += s">" + staticFragment += (if el.tag.void then "/>" else ">") for child <- children do child match case Some(s) => @@ -63,19 +63,22 @@ object LiveView: static.append(Some(staticFragment)) static.append(None) staticFragment = "" - staticFragment += s"" + staticFragment += (if el.tag.void then "" else s"") static.append(Some(staticFragment)) static.toSeq @nowarn("cat=unchecked") private def buildStaticFragmentsByType[Model]( - el: HtmlElement[Model] + el: HtmlElement[Model] ): (attrs: Seq[Option[String]], children: Seq[Option[String]]) = val (attrs, children) = el.mods.partitionMap { - case Mod.StaticAttr(attr, value) => - Left(List(Some(s""" ${attr.name}="$value""""))) - case Mod.DynAttr(attr, _) => + case Mod.StaticAttr(attr, value) => Left(List(Some(s""" ${attr.name}="$value""""))) + case Mod.StaticAttrValueAsPresence(attr, true) => Left(List(Some(s" ${attr.name}"))) + case Mod.StaticAttrValueAsPresence(attr, false) => Left(List.empty) + case Mod.DynAttr(attr, _) => 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.Text(text) => Right(List(Some(text))) case Mod.DynText[Model](_) => Right(List(None)) @@ -86,15 +89,18 @@ object LiveView: @nowarn("cat=unchecked") def buildDynamic[Model]( - el: HtmlElement[Model], - model: Model, - startsUpdated: Boolean = false + el: HtmlElement[Model], + model: Model, + startsUpdated: Boolean = false ): Seq[LiveDyn[Model]] = val (attrs, children) = el.mods.partitionMap { - case Mod.Text(_) => Right(List.empty) - case Mod.StaticAttr(_, _) => Left(List.empty) - case Mod.DynAttr(_, value) => - Right(List(LiveDyn.Value(value, model, startsUpdated))) + case Mod.Text(_) => Right(List.empty) + case Mod.StaticAttr(_, _) => Left(List.empty) + case Mod.StaticAttrValueAsPresence(_, _) => Left(List.empty) + 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) => Right(buildDynamic(el, model, startsUpdated)) case Mod.DynText[Model](dynText) => @@ -105,3 +111,4 @@ object LiveView: Right(List(LiveDyn.Split(dynList, project, model))) } attrs.flatten ++ children.flatten +end LiveView diff --git a/core/src/scalive/Scalive.scala b/core/src/scalive/Scalive.scala new file mode 100644 index 0000000..630225c --- /dev/null +++ b/core/src/scalive/Scalive.scala @@ -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.* diff --git a/core/src/scalive/View.scala b/core/src/scalive/View.scala index 5b026f7..0e0ada2 100644 --- a/core/src/scalive/View.scala +++ b/core/src/scalive/View.scala @@ -1,5 +1,8 @@ package scalive +import scalive.codecs.Codec +import scalive.codecs.BooleanAsAttrPresenceCodec + trait View[Model]: val model: Dyn[Model, Model] = Dyn.id 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] = when(f.andThen(!_))(el) - def splitByIndex[O2](f: O => List[O2])( - project: Dyn[O2, O2] => HtmlElement[O2] + def splitByIndex[O2]( + f: O => List[O2] + )( + project: Dyn[O2, O2] => HtmlElement[O2] ): Mod.Split[I, O2] = Mod.Split(d.andThen(f), project) @@ -25,34 +30,37 @@ object Dyn: def id[T]: Dyn[T, T] = identity enum Mod[T]: - case StaticAttr(attr: HtmlAttr, value: String) - case DynAttr(attr: HtmlAttr, value: Dyn[T, String]) + case StaticAttr(attr: HtmlAttr[?], value: 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 Text(text: String) case DynText(dynText: Dyn[T, String]) case When(dynCond: Dyn[T, Boolean], el: HtmlElement[T]) case Split[T, O]( - dynList: Dyn[T, List[O]], - project: Dyn[O, O] => HtmlElement[O] - ) extends Mod[T] + dynList: Dyn[T, List[O]], + project: Dyn[O, O] => HtmlElement[O]) extends Mod[T] 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(_) -class HtmlTag(val name: String): +class HtmlTag(val name: String, val void: Boolean = false): def apply[Model](mods: Mod[Model]*): HtmlElement[Model] = HtmlElement(this, mods.toVector) class HtmlElement[Model](val tag: HtmlTag, val mods: Vector[Mod[Model]]) -val div = HtmlTag("div") -val ul = HtmlTag("ul") -val li = HtmlTag("li") - -class HtmlAttr(val name: String): - def :=[T](value: String): Mod.StaticAttr[T] = Mod.StaticAttr(this, value) - def :=[T](value: Dyn[T, String]): Mod.DynAttr[T] = Mod.DynAttr(this, value) - -val idAttr = HtmlAttr("id") -val cls = HtmlAttr("class") +class HtmlAttr[V](val name: String, val codec: Codec[V, String]): + val isBooleanAsAttrPresence = codec == BooleanAsAttrPresenceCodec + def :=[T](value: V): Mod[T] = + val stringValue = codec.encode(value) + if isBooleanAsAttrPresence then + Mod.StaticAttrValueAsPresence(this, BooleanAsAttrPresenceCodec.decode(stringValue)) + else Mod.StaticAttr(this, stringValue) + def :=[T](value: Dyn[T, V]): Mod[T] = + val stringDyn = value(codec.encode) + if isBooleanAsAttrPresence then + Mod.DynAttrValueAsPresence(this, stringDyn(BooleanAsAttrPresenceCodec.decode)) + else Mod.DynAttr(this, value) diff --git a/core/src/scalive/codecs/Codec.scala b/core/src/scalive/codecs/Codec.scala new file mode 100644 index 0000000..95df7e1 --- /dev/null +++ b/core/src/scalive/codecs/Codec.scala @@ -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") diff --git a/core/src/scalive/defs/complex/ComplexHtmlKeys.scala b/core/src/scalive/defs/complex/ComplexHtmlKeys.scala new file mode 100644 index 0000000..a39caa7 --- /dev/null +++ b/core/src/scalive/defs/complex/ComplexHtmlKeys.scala @@ -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 diff --git a/core/src/scalive/main.scala b/core/src/scalive/main.scala index a534c2e..8dc0451 100644 --- a/core/src/scalive/main.scala +++ b/core/src/scalive/main.scala @@ -60,19 +60,22 @@ def main = println("Remove all") lv.update( - MyModel(List.empty, "text-lg") + MyModel(List.empty, "text-lg", bool = false) ) println(lv.diff.toJsonPretty) 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) object TestView extends View[MyModel]: val root: HtmlElement[MyModel] = div( - idAttr := "42", - cls := model(_.cls), + idAttr := "42", + cls := model(_.cls), + draggable := model(_.bool), + disabled := model(_.bool), ul( model.splitByIndex(_.elems)(elem => li( diff --git a/core/test/src/scalive/LiveViewSpec.scala b/core/test/src/scalive/LiveViewSpec.scala index 8655d2a..a96f45e 100644 --- a/core/test/src/scalive/LiveViewSpec.scala +++ b/core/test/src/scalive/LiveViewSpec.scala @@ -7,12 +7,11 @@ import zio.json.ast.Json object LiveViewSpec extends TestSuite: final case class TestModel( - title: String = "title value", - bool: Boolean = false, - nestedTitle: String = "nested title value", - cls: String = "text-sm", - items: List[NestedModel] = List.empty - ) + title: String = "title value", + bool: Boolean = false, + nestedTitle: String = "nested title value", + cls: String = "text-sm", + items: List[NestedModel] = List.empty) final case class NestedModel(name: String, age: Int) def assertEqualsJson(actual: Diff, expected: Json) = @@ -245,9 +244,7 @@ object LiveViewSpec extends TestSuite: } test("diff with item changed") { lv.update( - initModel.copy(items = - initModel.items.updated(2, NestedModel("c", 99)) - ) + initModel.copy(items = initModel.items.updated(2, NestedModel("c", 99))) ) assertEqualsJson( lv.diff, @@ -329,3 +326,4 @@ object LiveViewSpec extends TestSuite: } } } +end LiveViewSpec diff --git a/zio/src/scalive/Example.scala b/zio/src/scalive/Example.scala index 3b6be2c..7aa2937 100644 --- a/zio/src/scalive/Example.scala +++ b/zio/src/scalive/Example.scala @@ -1,18 +1,13 @@ package scalive -import zio._ +import zio.* -import zio.http.ChannelEvent.{ - ExceptionCaught, - Read, - UserEvent, - UserEventTriggered -} -import zio.http._ +import zio.http.ChannelEvent.{ExceptionCaught, Read, UserEvent, UserEventTriggered} +import zio.http.* import zio.http.codec.PathCodec.string import zio.http.template.Html -object Example extends ZIOAppDefault { +object Example extends ZIOAppDefault: val lv = LiveView( @@ -77,7 +72,7 @@ object Example extends ZIOAppDefault { ) 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 NestedModel(name: String, age: Int) @@ -86,7 +81,7 @@ object TestView extends View[MyModel]: val root: HtmlElement[MyModel] = div( idAttr := "42", - cls := model(_.cls), + cls := model(_.cls), ul( model.splitByIndex(_.elems)(elem => li(